- **Enumeration naming**: OCPP spec names exactly (e.g., `ConnectorStatusEnumType`); prefix with `OCPP16`/`OCPP20` for version-specific enums
- **Version handling**: OCPP 1.6 and 2.0.x in separate directories/namespaces
- **Message format**: SRPC format: `[messageTypeId, messageId, action, payload]`
-- **Per-station state**: Instance-based (each `ChargingStation` owns its own Maps/state). `WeakMap` used only in `OCPP20IncomingRequestService.stationsState` for handler-scoped state keyed by station reference
+- **Per-station state**: Durable per-station state is instance-based (each `ChargingStation` owns its own Maps/state) or lives on the persisted `ConnectorStatus`. Transient, process-local runtime state (delivery/barrier ownership, in-flight/staged/blocked markers, send chains, saturation flags) is held in module-level or `static` `WeakMap`/`WeakSet` keyed by a live object reference (`ChargingStation`, `ConnectorStatus`, queued `TransactionEvent`) so it stays out of persisted/serialized types and is garbage-collected with its key. Never persist such runtime markers on `ConnectorStatus`. `OCPP20IncomingRequestService.stationsState` is one instance of this pattern
### Request Architecture
## Component Boundaries
- `charging-station/` and `ocpp/` are SEPARATE components with their own barrels (files under `src/charging-station/ocpp/` belong to `ocpp/`)
+- `meter-values/` is a SEPARATE component with its own barrel (`meter-values/index.js`): physics-based coherent MeterValues generation plus the shared transaction MeterValue delivery/interval-accounting helpers. Consumers (`ocpp/`, `broadcast-channel/`, the `charging-station/` root) import via the barrel, never deep-import its sub-modules
- `ocpp/1.6/` and `ocpp/2.0/` are separate sub-components of ocpp
- `ocpp/auth/` is an independent subsystem with its own barrel, interfaces, and strategy pattern
- `worker/` is **fully standalone** — zero imports from other local modules. Has its own `sleep()`, `secureRandom()`, `mergeDeepRight()`. Uses `new Error()` (not `BaseError`). Portable to other projects
signal: AbortSignal
): Promise<void> {
let logged = false
- while (
- this.chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true &&
- !signal.aborted
- ) {
+ while (!signal.aborted) {
+ const transactionStatus = this.chargingStation.getConnectorStatus(connectorId)
+ const remoteStartReserved =
+ transactionStatus?.transactionPending === true && transactionStatus.remoteStartId != null
+ if (transactionStatus?.transactionStarted !== true && !remoteStartReserved) return
if (!logged) {
- const transactionId = this.chargingStation.getConnectorStatus(connectorId)?.transactionId
logger.info(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- `${this.logPrefix(connectorId)} ${moduleName}.waitRunningTransactionStopped: Transaction loop waiting for started transaction ${transactionId?.toString()} on connector ${connectorId.toString()} to be stopped`
+ `${this.logPrefix(connectorId)} ${moduleName}.waitRunningTransactionStopped: Transaction loop waiting for transaction ${transactionStatus.transactionId?.toString()} on connector ${connectorId.toString()} to be released`
)
logged = true
}
type HeartbeatResponse,
type IncomingRequest,
type IncomingRequestCommand,
+ type JsonType,
MapStringifyFormat,
MessageType,
MeterValueMeasurand,
+ OCPP20ComponentName,
+ OCPP20ReadingContextEnumType,
+ OCPP20RequiredVariableName,
+ type OCPP20TransactionEventRequest,
OCPPVersion,
type OutgoingRequest,
PowerUnits,
import { CoherentMeterValuesManager } from './CoherentMeterValuesManager.js'
import {
addConfigurationKey,
+ buildConfigKey,
deleteConfigurationKey,
getConfigurationKey,
setConfigurationKeyValue,
hasReservationExpired,
initializeConnectorsMapStatus,
prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
propagateSerialNumber,
setChargingStationOptions,
stationTemplateToStationInfo,
import { SharedLRUCache } from './SharedLRUCache.js'
import { CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
import { validateTemplate } from './TemplateValidation.js'
+import { resetTransactionEventQueueRuntimeState } from './TransactionEventQueueUtils.js'
const moduleName = 'ChargingStation'
+const MAX_BUFFERED_RESPONSE_CALLBACK_BYTES = 1024 * 1024
+const MAX_BUFFERED_RESPONSE_CALLBACK_COUNT = 1024
+const TRANSACTION_EVENT_QUEUE_CHECKPOINT_INTERVAL_MS = 60_000
+
+interface BufferedMessageCallbackReservation {
+ readonly bytes: number
+ readonly onCancelled?: () => void
+}
+
+interface BufferedMessageCallbacks {
+ readonly onDiscarded?: () => void
+ readonly onSent?: () => void
+ readonly retainedBytes?: number
+}
+
+interface BufferedMessageEntry {
+ callbacks?: BufferedMessageCallbacks
+ reservation?: BufferedMessageCallbackReservation
+}
+
+interface ConfigurationSaveRequest {
+ readonly configurationHash: string
+ readonly lifecycleSignal: AbortSignal
+ readonly onErrorCallbacks: ((error: Error) => void)[]
+}
+
+interface TransactionEventQueueSaveOutcome {
+ readonly error?: Error
+}
+
+interface TransactionEventQueueSaveWaiter {
+ readonly lifecycleSignal: AbortSignal
+ readonly reject: (error: Error) => void
+ readonly resolve: (value: undefined) => void
+}
+
+class TransactionEventQueuePersistenceDiscardedError extends BaseError {}
+
+class TransactionEventQueuePersistenceSupersededError extends BaseError {}
export class ChargingStation extends EventEmitter {
public automaticTransactionGenerator?: AutomaticTransactionGenerator
return isEmpty(this.connectors) && !isEmpty(this.evses)
}
+ public get lifecycleAbortSignal (): AbortSignal {
+ this.lifecycleAbortController ??= new AbortController()
+ return this.lifecycleAbortController.signal
+ }
+
public get wsConnectionUrl (): URL {
const wsConnectionBaseUrlStr = `${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
)
}
+ private acceptedMessageSequenceStartup?: Promise<void>
+ private readonly acknowledgedBufferedMessages = new Set<string>()
+ private alignedMeterValuesGeneration = 0
+ private alignedMeterValuesRestartPending = false
+ private alignedMeterValuesSetTimeout?: NodeJS.Timeout
+ private alignedMeterValuesStartup?: Promise<void>
private automaticTransactionGeneratorConfiguration?: AutomaticTransactionGeneratorConfiguration
+ private bufferedMessageCallbackBytes = 0
+ private bufferedMessageCallbackCount = 0
+ private readonly bufferedMessageCallbackReservations =
+ new Set<BufferedMessageCallbackReservation>()
+
+ private readonly bufferedMessageEntries: BufferedMessageEntry[] = []
+
+ private bufferedMessageInFlight?: {
+ entry: BufferedMessageEntry
+ isRequest: boolean
+ message: string
+ messageId?: string
+ retracted: boolean
+ stopDrain?: boolean
+ waitingForCallGate: boolean
+ }
+
private readonly chargingStationWorkerBroadcastChannel: ChargingStationWorkerBroadcastChannel
private configurationFile: string
private configurationFileHash: string
// started/stopping (which reset() itself toggles through stop()) so a station
// deleted during the reset window is not resurrected and reconnected.
private readonly deleteAbortController: AbortController
+ private deleteConfigurationRequested = false
+ private deletePromise?: Promise<void>
private readonly evses: Map<number, EvseStatus>
private evsesConfigurationHash: string
private flushingMessageBuffer: boolean
private flushMessageBufferSetInterval?: NodeJS.Timeout
+ private lifecycleAbortController?: AbortController
private readonly messageQueue: string[]
private ocppIncomingRequestService!: OCPPIncomingRequestService
+ private pendingConfigurationSave: Promise<void> = Promise.resolve()
+ private pendingConfigurationSaveRequest?: ConfigurationSaveRequest
+ private persistenceDiscarded = false
private readonly sharedLRUCache: SharedLRUCache
+ private startAfterStopPromise?: Promise<void>
private stopping: boolean
+ private stopPromise?: Promise<void>
private templateFileHash: string
private templateFileWatcher?: FSWatcher
- private wsConnectionClosedByRequest: boolean
+ private transactionEventQueueCommittedVersion = 0
+ private transactionEventQueueMutationVersion = 0
+ private transactionEventQueueSaveDelayResolve?: () => void
+ private transactionEventQueueSaveDirty = false
+ private transactionEventQueueSaveImmediate = false
+ private transactionEventQueueSaveOwner?: object
+ private transactionEventQueueSavePromise?: Promise<TransactionEventQueueSaveOutcome>
+ private transactionEventQueueSaveSetTimeout?: NodeJS.Timeout
+ private readonly transactionEventQueueSaveWaiters = new Map<
+ number,
+ Set<TransactionEventQueueSaveWaiter>
+ >()
+
private wsConnectionRetryCount: number
+ private readonly wsConnectionsClosedByRequest: WeakSet<WebSocket>
private wsPingSetInterval?: NodeJS.Timeout
-
/**
* Creation options to re-apply when re-initializing on a reset or template
* reload. Only a non-persistent station needs them: a persistent one restores
this.starting = false
this.stopping = false
this.deleteAbortController = new AbortController()
+ this.lifecycleAbortController = new AbortController()
this.wsConnection = null
- this.wsConnectionClosedByRequest = false
+ this.wsConnectionsClosedByRequest = new WeakSet<WebSocket>()
this.wsConnectionRetryCount = 0
this.index = index
this.templateFile = templateFile
parentPort?.postMessage(buildUpdatedMessage(this))
})
this.on(ChargingStationEvents.accepted, () => {
- this.startMessageSequence(
+ this.startAcceptedMessageSequence(
this.wsConnectionRetryCount > 0
? true
: this.getAutomaticTransactionGeneratorConfiguration()?.stopAbsoluteDuration
).catch((error: unknown) => {
logger.error(
- `${this.logPrefix()} ${moduleName}.onAccepted: Error while starting the message sequence:`,
+ `${this.logPrefix()} ${moduleName}.onAccepted: Error while starting accepted-state services:`,
error
)
})
this.wsConnectionRetryCount = 0
})
this.on(ChargingStationEvents.rejected, () => {
+ this.acceptedMessageSequenceStartup = undefined
this.wsConnectionRetryCount = 0
})
this.on(ChargingStationEvents.connected, () => {
}
})
this.on(ChargingStationEvents.disconnected, () => {
+ this.acceptedMessageSequenceStartup = undefined
try {
this.internalStopMessageSequence()
} catch (error) {
/**
* Adds a reservation to the specified connector.
* @param reservation - The reservation to add
+ * @param isOperationCurrent - Optional operation-ownership guard for post-await mutation.
*/
- public async addReservation (reservation: Reservation): Promise<void> {
+ public async addReservation (
+ reservation: Reservation,
+ isOperationCurrent?: () => boolean
+ ): Promise<void> {
const reservationFound = this.getReservationBy('reservationId', reservation.reservationId)
if (reservationFound != null) {
- await this.removeReservation(reservationFound, ReservationTerminationReason.REPLACE_EXISTING)
+ await this.removeReservation(
+ reservationFound,
+ ReservationTerminationReason.REPLACE_EXISTING,
+ isOperationCurrent
+ )
}
+ if (isOperationCurrent?.() === false) return
const connectorStatus = this.getConnectorStatus(reservation.connectorId)
if (connectorStatus == null) {
logger.error(
connectorId: reservation.connectorId,
status: ConnectorStatusEnum.Reserved,
},
- { send: reservation.connectorId !== 0 }
+ { isOperationCurrent, send: reservation.connectorId !== 0 }
)
}
+ /* eslint-disable perfectionist/sort-classes */
+ /**
+ * Reserves bounded memory for response-delivery callbacks before transport starts.
+ * @param retainedBytes - Serialized frame and callback-owned payload bytes.
+ * @param onCancelled - Releases a response whose transport callback never settles.
+ * @returns The reservation, or `undefined` when the callback budget is exhausted.
+ */
+ public reserveBufferedMessageCallbacks (
+ retainedBytes: number,
+ onCancelled?: () => void
+ ): BufferedMessageCallbackReservation | undefined {
+ if (!Number.isFinite(this.bufferedMessageCallbackCount)) {
+ this.bufferedMessageCallbackCount = 0
+ }
+ if (!Number.isFinite(this.bufferedMessageCallbackBytes)) {
+ this.bufferedMessageCallbackBytes = 0
+ }
+ const bytes = Math.max(0, Number.isFinite(retainedBytes) ? retainedBytes : 0)
+ if (
+ this.bufferedMessageCallbackCount >= MAX_BUFFERED_RESPONSE_CALLBACK_COUNT ||
+ this.bufferedMessageCallbackBytes + bytes > MAX_BUFFERED_RESPONSE_CALLBACK_BYTES
+ ) {
+ return undefined
+ }
+ const reservation = { bytes, onCancelled }
+ this.bufferedMessageCallbackReservations.add(reservation)
+ ++this.bufferedMessageCallbackCount
+ this.bufferedMessageCallbackBytes += bytes
+ return reservation
+ }
+
+ /**
+ * Releases a response callback reservation exactly once.
+ * @param reservation - Reservation to release, if still owned.
+ */
+ public releaseBufferedMessageCallbacks (
+ reservation: BufferedMessageCallbackReservation | undefined
+ ): void {
+ if (reservation == null || !this.bufferedMessageCallbackReservations.delete(reservation)) return
+ --this.bufferedMessageCallbackCount
+ this.bufferedMessageCallbackBytes -= reservation.bytes
+ }
+
+ /**
+ * Cancels every response callback reservation owned by this station lifecycle.
+ * Buffered responses are detached before callbacks run so cancellation cannot
+ * replay stale delivery side effects after a restart.
+ */
+ public releaseAllBufferedMessageCallbacks (): void {
+ const discardedCallbacks: BufferedMessageCallbacks[] = []
+ for (
+ let messageIndex = this.bufferedMessageEntries.length - 1;
+ messageIndex >= 0;
+ messageIndex--
+ ) {
+ if (this.bufferedMessageEntries[messageIndex]?.callbacks == null) continue
+ const callbacks = ChargingStation.prototype.removeBufferedMessageEntry.call(
+ this,
+ messageIndex,
+ false
+ )
+ if (callbacks != null) discardedCallbacks.unshift(callbacks)
+ }
+ const reservations = [...this.bufferedMessageCallbackReservations]
+ this.bufferedMessageCallbackReservations.clear()
+ this.bufferedMessageCallbackCount = 0
+ this.bufferedMessageCallbackBytes = 0
+ for (const callbacks of discardedCallbacks) {
+ try {
+ callbacks.onDiscarded?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.releaseAllBufferedMessageCallbacks: Buffered message discard callback failed:`,
+ error
+ )
+ }
+ }
+ for (const reservation of reservations) reservation.onCancelled?.()
+ }
+
/**
* Buffers an OCPP message for deferred sending when connection is unavailable.
- * @param message - The OCPP message to buffer
+ * @param message - The OCPP message to buffer.
+ * @param prepend - Whether to restore the message ahead of queued successors.
+ * @param callbacks - Optional callbacks retained with this exact buffered frame.
+ * @param callbackReservation - Existing in-flight reservation transferred to this buffer entry.
+ * @returns Whether the frame was accepted into the buffer.
*/
- public bufferMessage (message: string): void {
- this.messageQueue.push(message)
+ public bufferMessage (
+ message: string,
+ prepend = false,
+ callbacks?: BufferedMessageCallbacks,
+ callbackReservation?: BufferedMessageCallbackReservation
+ ): boolean {
+ if (this.bufferedMessageEntries.length < this.messageQueue.length) {
+ this.bufferedMessageEntries.push(
+ ...Array.from(
+ { length: this.messageQueue.length - this.bufferedMessageEntries.length },
+ (): BufferedMessageEntry => ({})
+ )
+ )
+ } else if (this.bufferedMessageEntries.length > this.messageQueue.length) {
+ this.bufferedMessageEntries.splice(this.messageQueue.length)
+ }
+ const serializedBytes = Buffer.byteLength(message, 'utf8')
+ const callbackBytes =
+ callbacks == null
+ ? 0
+ : Math.max(
+ serializedBytes,
+ Number.isFinite(callbacks.retainedBytes) ? (callbacks.retainedBytes ?? 0) : 0
+ )
+ const reservation =
+ callbacks == null
+ ? undefined
+ : (callbackReservation ?? this.reserveBufferedMessageCallbacks(callbackBytes))
+ if (
+ callbacks != null &&
+ (reservation == null || !this.bufferedMessageCallbackReservations.has(reservation))
+ ) {
+ try {
+ callbacks.onDiscarded?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.bufferMessage: Rejected buffered response discard callback failed:`,
+ error
+ )
+ }
+ return false
+ }
+ const entry: BufferedMessageEntry = { callbacks, reservation }
+ if (prepend) {
+ this.messageQueue.unshift(message)
+ this.bufferedMessageEntries.unshift(entry)
+ } else {
+ this.messageQueue.push(message)
+ this.bufferedMessageEntries.push(entry)
+ }
+ const inFlight = this.bufferedMessageInFlight
+ if (inFlight?.isRequest === true && inFlight.waitingForCallGate && inFlight.messageId != null) {
+ try {
+ const [messageType] = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ if (messageType !== MessageType.CALL_MESSAGE) {
+ const cancelled = this.ocppRequestService.cancelOutgoingCallWaiter(
+ this,
+ inFlight.messageId,
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Buffered response preempted replay of CALL message id '${inFlight.messageId}'`
+ )
+ )
+ if (!cancelled) {
+ // The gate can hand ownership to this replay immediately before this
+ // callback runs. Retract that now-active CALL so the response is not
+ // stranded behind it.
+ inFlight.retracted = true
+ this.ocppRequestService.releaseOutgoingCall(this, inFlight.messageId)
+ }
+ }
+ } catch {
+ // Malformed frames are discarded by the normal buffered replay path.
+ }
+ }
this.setIntervalFlushMessageBuffer()
+ return true
}
/**
return status
}
+ /** Clears buffered OCPP messages that must no longer be replayed. */
+ public clearMessageBuffer (): void {
+ if (this.bufferedMessageInFlight != null) this.bufferedMessageInFlight.retracted = true
+ this.acknowledgedBufferedMessages.clear()
+ this.messageQueue.length = 0
+ for (const { callbacks, reservation } of this.bufferedMessageEntries) {
+ this.releaseBufferedMessageCallbacks(reservation)
+ try {
+ callbacks?.onDiscarded?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.clearMessageBuffer: Buffered message discard callback failed:`,
+ error
+ )
+ }
+ }
+ this.bufferedMessageEntries.length = 0
+ this.releaseAllBufferedMessageCallbacks()
+ this.clearIntervalFlushMessageBuffer()
+ }
+
/**
* Closes the WebSocket connection to the central server.
* @param options - Close options
- * @param options.byRequest - Whether the close is requested (the UI disconnect
- * action); a requested close is terminal (onClose does not reconnect), any
- * other close (e.g. certificate rotation) reconnects like a server-initiated drop
+ * @param options.byRequest - Whether the close is terminal (for example, a UI
+ * disconnect or station stop); terminal closes do not reconnect, while other
+ * closes (for example, certificate rotation) reconnect like a server-initiated drop
*/
+ /* eslint-enable perfectionist/sort-classes */
public closeWSConnection ({ byRequest = false }: { byRequest?: boolean } = {}): void {
- if (this.isWebSocketConnectionOpened()) {
+ const wsConnection = this.wsConnection
+ if (wsConnection?.readyState === WebSocket.OPEN) {
if (byRequest) {
- this.wsConnectionClosedByRequest = true
+ this.wsConnectionsClosedByRequest.add(wsConnection)
}
- this.wsConnection?.close()
+ wsConnection.close()
}
}
* `manager.createSession` would return `undefined` anyway.
* @param transactionId - Transaction identifier from the CSMS.
* @param connectorId - Connector on which the transaction is running.
+ * @param evseId - EVSE containing the connector when connector ids are EVSE-local.
* @returns The active or newly-created session, or `undefined` when
* coherent mode is not usable.
*/
public createCoherentSession (
transactionId: number | string,
- connectorId: number
+ connectorId: number,
+ evseId?: number
): CoherentSession | undefined {
- return CoherentMeterValuesManager.peekInstance(this)?.createSession(transactionId, connectorId)
+ return CoherentMeterValuesManager.peekInstance(this)?.createSession(
+ transactionId,
+ connectorId,
+ evseId
+ )
}
/**
* Deletes the charging station instance and optionally its persisted configuration.
* @param deleteConfiguration - Whether to delete the persisted configuration file
+ * @returns Promise that resolves after the shared delete operation completes
*/
- public async delete (deleteConfiguration = true): Promise<void> {
- // Cancel any pending reset() so a station deleted during its reset window is
- // not re-initialized and reconnected to the CSMS.
+ public delete (deleteConfiguration = true): Promise<void> {
+ if (deleteConfiguration) this.deleteConfigurationRequested = true
+ // Cancel any pending reset() synchronously so a station deleted during its
+ // reset window cannot be re-initialized before deletion begins.
this.deleteAbortController.abort()
- if (this.started) {
- try {
- await this.stop()
- } catch (error) {
- const e = ensureError(error)
- logger.error(
- `${this.logPrefix()} ${moduleName}.delete: Error stopping station during delete:`,
- e
- )
- }
- }
- AutomaticTransactionGenerator.deleteInstance(this)
- CoherentMeterValuesManager.deleteInstance(this)
- PerformanceStatistics.deleteInstance(this.stationInfo?.hashId)
- OCPPAuthServiceFactory.clearInstance(this)
- if (this.stationInfo != null) {
- const idTagsFile = getIdTagsFile(this.stationInfo)
- if (idTagsFile != null) {
- this.idTagsCache.deleteIdTags(idTagsFile)
- } else {
- logger.warn(
- `${this.logPrefix()} ${moduleName}.delete: No ID tags file found during deletion`
- )
- }
- } else {
- logger.warn(
- `${this.logPrefix()} ${moduleName}.delete: No station info available during deletion`
- )
- }
- this.requests.clear()
- this.connectors.clear()
- this.evses.clear()
- this.messageQueue.length = 0
- this.templateFileWatcher?.unref()
- if (deleteConfiguration && existsSync(this.configurationFile)) {
- try {
- rmSync(this.configurationFile, { force: true })
- } catch (error) {
- const e = ensureError(error)
- logger.error(
- `${this.logPrefix()} ${moduleName}.delete: Failed to delete configuration file ${this.configurationFile}:`,
- e
- )
- }
- }
- this.chargingStationWorkerBroadcastChannel.unref()
- this.emitChargingStationEvent(ChargingStationEvents.deleted)
- this.removeAllListeners()
+ // Deletion is single-flight: concurrent callers join the same bounded stop
+ // and cleanup instead of cancelling lifecycle requests started by the first.
+ this.deletePromise ??= Promise.resolve().then(() =>
+ ChargingStation.prototype.performDelete.call(this)
+ )
+ if (!deleteConfiguration) return this.deletePromise
+ return this.deletePromise.finally(() => {
+ ChargingStation.prototype.discardPersistence.call(this)
+ ChargingStation.prototype.performDeleteConfiguration.call(this)
+ })
}
/**
return this.getConfigurationFromFile()?.automaticTransactionGeneratorStatuses
}
+ /**
+ * Restores acknowledged-but-unanswered replayed CALLs to their original FIFO prefix,
+ * then returns the ids of all buffered CALL frames. Malformed frames are ignored here
+ * and handled by the normal flush path.
+ * @returns Buffered OCPP CALL message ids.
+ */
+ public getBufferedRequestIds (): Set<string> {
+ this.restoreAcknowledgedBufferedMessages()
+ const messageIds = new Set<string>()
+ for (const message of [...this.messageQueue, ...this.acknowledgedBufferedMessages.values()]) {
+ try {
+ const parsedMessage = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ if (parsedMessage[0] === MessageType.CALL_MESSAGE) messageIds.add(parsedMessage[1])
+ } catch {
+ // Ignore malformed frames: they cannot identify a pending CALL.
+ }
+ }
+ return messageIds
+ }
+
/**
* Retrieves the coherent session for a transaction, if any. Delegates
* to {@link CoherentMeterValuesManager.getSession}. Uses `peekInstance`
/**
* Computes the maximum power available on a connector considering amperage limitations and charging profiles.
* @param connectorId - The connector ID
+ * @param evseId - Optional EVSE id when connector ids are EVSE-local
* @returns The maximum available power in watts
*/
- public getConnectorMaximumAvailablePower (connectorId: number): number {
+ public getConnectorMaximumAvailablePower (connectorId: number, evseId?: number): number {
let connectorAmperageLimitationLimit: number | undefined
+ const connectorStatus = this.getConnectorStatus(connectorId, evseId)
const amperageLimitation = this.getAmperageLimitation()
if (
amperageLimitation != null &&
? (this.stationInfo?.conversionEfficiency ?? 1)
: 1
const connectorMaximumPower = (maximumPower / (this.powerDivider ?? 1)) * conversionEfficiency
- const connectorHardwareMaximumPowerInput = this.getConnectorStatus(connectorId)?.maximumPower
+ const connectorHardwareMaximumPowerInput = connectorStatus?.maximumPower
const connectorHardwareMaximumPower =
connectorHardwareMaximumPowerInput == null
? undefined
const chargingStationChargingProfilesLimit =
(getChargingStationChargingProfilesLimit(this) ?? Number.POSITIVE_INFINITY) /
(this.powerDivider ?? 1)
- const connectorChargingProfilesLimit = getConnectorChargingProfilesLimit(this, connectorId)
+ const connectorChargingProfilesLimit = getConnectorChargingProfilesLimit(
+ this,
+ connectorId,
+ connectorStatus
+ )
return min(
Number.isNaN(connectorMaximumPower) ? Number.POSITIVE_INFINITY : connectorMaximumPower,
connectorHardwareMaximumPower == null || Number.isNaN(connectorHardwareMaximumPower)
)
}
- public getConnectorStatus (connectorId: number): ConnectorStatus | undefined {
+ public getConnectorStatus (connectorId: number, evseId?: number): ConnectorStatus | undefined {
+ if (evseId != null) {
+ return this.getEvseStatus(evseId)?.connectors.get(connectorId)
+ }
return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId)
?.connectorStatus
}
* @param connectorId - The connector ID
* @param rounded - Whether to round the value
* @returns The cumulative active energy imported in watt-hours
+ * @param evseId - Optional EVSE id when connector ids are EVSE-local
*/
- public getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number {
- return this.getEnergyActiveImportRegister(this.getConnectorStatus(connectorId), rounded)
+ public getEnergyActiveImportRegisterByConnectorId (
+ connectorId: number,
+ rounded = false,
+ evseId?: number
+ ): number {
+ return this.getEnergyActiveImportRegister(this.getConnectorStatus(connectorId, evseId), rounded)
}
/**
transactionId: number | string | undefined,
rounded = false
): number {
- const connectorId = this.getConnectorIdByTransactionId(transactionId)
return this.getEnergyActiveImportRegister(
- connectorId != null ? this.getConnectorStatus(connectorId) : undefined,
+ transactionId != null
+ ? this.iterateConnectors().find(
+ ({ connectorStatus }) => connectorStatus.transactionId === transactionId
+ )?.connectorStatus
+ : undefined,
rounded
)
}
: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS
}
+ /**
+ * Returns whether a buffered CALL frame is awaiting a response under this message id.
+ * @param messageId - OCPP unique message identifier
+ * @returns Whether the request is queued for replay
+ */
+ public hasBufferedRequest (messageId: string): boolean {
+ return this.getBufferedRequestIds().has(messageId)
+ }
+
public hasConnector (connectorId: number): boolean {
return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId)
}
return false
}
+ public isStopping (): boolean {
+ return this.stopping
+ }
+
public isWebSocketConnectionOpened (): boolean {
return this.wsConnection?.readyState === WebSocket.OPEN
}
if (params.terminateOpened) {
this.terminateWSConnection()
}
+ if (this.wsConnection != null && !this.isWebSocketConnectionOpened()) {
+ this.restoreAcknowledgedBufferedMessages()
+ }
if (this.isWebSocketConnectionOpened()) {
logger.warn(
`${this.logPrefix()} ${moduleName}.openWSConnection: Open OCPP connection to URL ${this.wsConnectionUrl.href}`
)
- this.wsConnection = new WebSocket(
+ const wsConnection = new WebSocket(
this.wsConnectionUrl,
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`ocpp${this.stationInfo?.ocppVersion}`,
options
)
+ this.wsConnection = wsConnection
- this.wsConnection.on('message', data => {
- this.onMessage(data).catch((error: unknown) =>
+ wsConnection.on('message', data => {
+ this.onMessage(data, wsConnection).catch((error: unknown) =>
logger.error(
`${this.logPrefix()} ${moduleName}.openWSConnection: Error while processing WebSocket message:`,
error
)
)
})
- this.wsConnection.on('error', this.onError.bind(this))
- this.wsConnection.on('close', this.onClose.bind(this))
- this.wsConnection.on('open', () => {
+ wsConnection.on('error', error => {
+ this.onError(wsConnection, error)
+ })
+ wsConnection.on('close', (code, reason) => {
+ this.onClose(wsConnection, code, reason)
+ })
+ wsConnection.on('open', () => {
this.onOpen().catch((error: unknown) =>
logger.error(
`${this.logPrefix()} ${moduleName}.openWSConnection: Error while opening WebSocket connection:`,
)
)
})
- this.wsConnection.on('ping', this.onPing.bind(this))
- this.wsConnection.on('pong', this.onPong.bind(this))
+ wsConnection.on('ping', this.onPing.bind(this))
+ wsConnection.on('pong', this.onPong.bind(this))
+ }
+
+ /** Forces and awaits durable persistence of the current transaction event queue snapshot. */
+ public async persistTransactionEventQueues (): Promise<void> {
+ if (this.persistenceDiscarded) {
+ throw new TransactionEventQueuePersistenceDiscardedError(
+ 'TransactionEvent queue persistence was discarded by station deletion'
+ )
+ }
+ this.saveTransactionEventQueues()
+ const persistenceState = this as unknown as {
+ transactionEventQueueCommittedVersion?: number
+ transactionEventQueueMutationVersion?: number
+ transactionEventQueueSaveWaiters?: Map<number, Set<TransactionEventQueueSaveWaiter>>
+ }
+ const targetVersion = persistenceState.transactionEventQueueMutationVersion ?? 0
+ if ((persistenceState.transactionEventQueueCommittedVersion ?? 0) >= targetVersion) return
+ const waitersByVersion =
+ persistenceState.transactionEventQueueSaveWaiters ??
+ new Map<number, Set<TransactionEventQueueSaveWaiter>>()
+ persistenceState.transactionEventQueueSaveWaiters = waitersByVersion
+ const { promise, reject, resolve } = Promise.withResolvers<undefined>()
+ const waiter = { lifecycleSignal: this.lifecycleAbortSignal, reject, resolve }
+ let waiters = waitersByVersion.get(targetVersion)
+ if (waiters == null) {
+ waiters = new Set()
+ waitersByVersion.set(targetVersion, waiters)
+ }
+ waiters.add(waiter)
+ if ((persistenceState.transactionEventQueueCommittedVersion ?? 0) >= targetVersion) {
+ waiters.delete(waiter)
+ if (isEmpty(waiters)) waitersByVersion.delete(targetVersion)
+ resolve(undefined)
+ }
+ await promise
}
/**
}
}
+ /**
+ * Retracts one exact serialized OCPP frame from deferred replay.
+ * If that frame is currently being flushed, its eventual send callback is
+ * prevented from removing the next queued frame.
+ * @param message - Exact serialized OCPP frame to retract
+ * @returns Whether a matching queued frame was removed
+ */
+ public removeBufferedMessage (message: string): boolean {
+ const messageIndex = this.messageQueue.indexOf(message)
+ if (messageIndex === -1) return this.acknowledgedBufferedMessages.delete(message)
+ ChargingStation.prototype.removeBufferedMessageEntry.call(this, messageIndex, true)
+ return true
+ }
+
+ /**
+ * Retracts the currently owned buffered CALL for an OCPP message id.
+ * Payload replacement keeps the message id stable, so terminal request cleanup
+ * must not depend on the serialized frame captured before replacement.
+ * @param messageId - OCPP unique message identifier
+ * @returns Whether a matching queued or acknowledged request was removed
+ */
+ public removeBufferedRequest (messageId: string): boolean {
+ const messageIndex = this.messageQueue.findIndex(message => {
+ try {
+ const parsedMessage = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ return parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId
+ } catch {
+ return false
+ }
+ })
+ if (messageIndex >= 0) {
+ ChargingStation.prototype.removeBufferedMessageEntry.call(this, messageIndex, true)
+ return true
+ }
+ for (const message of this.acknowledgedBufferedMessages) {
+ try {
+ const parsedMessage = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ if (parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId) {
+ return this.acknowledgedBufferedMessages.delete(message)
+ }
+ } catch {
+ // Malformed frames cannot match a request id and are handled by the flush path.
+ }
+ }
+ return false
+ }
+
/**
* Removes a reservation and restores the connector to its previous status.
* @param reservation - The reservation to remove
* @param reason - The reason for removing the reservation
+ * @param isOperationCurrent - Optional operation-ownership guard for post-await mutation.
*/
public async removeReservation (
reservation: Reservation,
- reason: ReservationTerminationReason
+ reason: ReservationTerminationReason,
+ isOperationCurrent?: () => boolean
): Promise<void> {
+ if (isOperationCurrent?.() === false) return
const connectorStatus = this.getConnectorStatus(reservation.connectorId)
if (connectorStatus == null) {
logger.error(
connectorId: reservation.connectorId,
status: ConnectorStatusEnum.Available,
},
- { send: reservation.connectorId !== 0 }
+ { isOperationCurrent, send: reservation.connectorId !== 0 }
)
+ if (isOperationCurrent?.() === false) return
delete connectorStatus.reservation
break
default:
}
}
+ /**
+ * Replaces the payload of an exact buffered CALL before replay reaches it.
+ * @param commandName - Command of the buffered request.
+ * @param previousPayload - Payload object used to identify the cached request.
+ * @param replacementPayload - Reconciled payload to persist and replay.
+ * @returns Whether the buffered request was replaced.
+ */
+ public replaceBufferedRequestPayload (
+ commandName: RequestCommand,
+ previousPayload: JsonType,
+ replacementPayload: JsonType
+ ): boolean {
+ for (const [messageId, cachedRequest] of this.requests) {
+ if (cachedRequest[2] !== commandName || cachedRequest[3] !== previousPayload) continue
+ const messageIndex = this.messageQueue.findIndex(message => {
+ try {
+ const [, bufferedMessageId] = JSON.parse(message) as OutgoingRequest
+ return bufferedMessageId === messageId
+ } catch {
+ return false
+ }
+ })
+ if (messageIndex < 0) return false
+ const previousMessage = this.messageQueue[messageIndex]
+ const selectedReplay =
+ this.bufferedMessageInFlight?.message === previousMessage
+ ? this.bufferedMessageInFlight
+ : undefined
+ if (selectedReplay != null && !selectedReplay.waitingForCallGate) return false
+ const replacementMessage = JSON.stringify([
+ MessageType.CALL_MESSAGE,
+ messageId,
+ commandName,
+ replacementPayload,
+ ] satisfies OutgoingRequest)
+ this.messageQueue[messageIndex] = replacementMessage
+ if (selectedReplay != null) selectedReplay.message = replacementMessage
+ cachedRequest[3] = replacementPayload
+ this.saveConfiguration()
+ return true
+ }
+ return false
+ }
+
/**
* Resets the charging station with optional graceful shutdown.
* @param reason - Optional reason for the reset
this.start()
}
+ /**
+ * Restarts the autonomous clock-aligned MeterValues timer.
+ * @param previousInterval - Previous interval in milliseconds, used to settle elapsed energy.
+ * @param settleDisabledInterval - Settle the current interval while disabling aligned sampling.
+ */
+ public restartAlignedMeterValues (
+ previousInterval?: number,
+ settleDisabledInterval = false
+ ): void {
+ const disabledIntervalSeconds = settleDisabledInterval
+ ? OCPP20ServiceUtils.readAlignedDataIntervalSeconds(this)
+ : undefined
+ const settlementInterval =
+ previousInterval ??
+ (disabledIntervalSeconds != null ? secondsToMilliseconds(disabledIntervalSeconds) : undefined)
+ if (
+ this.started &&
+ !this.stopping &&
+ settlementInterval != null &&
+ settlementInterval > 0 &&
+ (settleDisabledInterval ||
+ OCPP20ServiceUtils.readVariableAsBoolean(
+ this,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Enabled,
+ false
+ ))
+ ) {
+ const timestamp = new Date()
+ const energyNominalInterval = OCPP20ServiceUtils.getTransactionEnergyNominalInterval(
+ this,
+ settlementInterval
+ )
+ for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors(true)) {
+ if (
+ connectorStatus.transactionStarted !== true ||
+ connectorStatus.transactionId == null ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionRestored === true
+ ) {
+ continue
+ }
+ try {
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ this,
+ connectorId,
+ evseId,
+ connectorStatus.transactionId,
+ settlementInterval,
+ buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ ),
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ timestamp,
+ energyNominalInterval,
+ true
+ )
+ } catch (error: unknown) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.restartAlignedMeterValues: Failed to settle connector ${connectorId.toString()}: ${getErrorMessage(error)}`
+ )
+ }
+ }
+ }
+ this.stopAlignedMeterValues()
+ if (!this.started || this.stopping) {
+ this.alignedMeterValuesRestartPending = false
+ return
+ }
+ if (this.alignedMeterValuesStartup != null) {
+ this.alignedMeterValuesRestartPending = true
+ return
+ }
+ this.alignedMeterValuesRestartPending = false
+ this.startAlignedMeterValues()
+ }
+
/** Restarts the periodic heartbeat to the central server. */
public restartHeartbeat (): void {
this.stopHeartbeat()
this.startHeartbeat()
}
+ /**
+ * Restarts active OCPP 2.0 transaction sampling timers after an interval change.
+ * @param kind - Timer family to restart.
+ * @param interval - New interval in milliseconds.
+ * @param previousInterval - Previous interval in milliseconds, used to settle elapsed energy.
+ */
+ public restartTransactionMeterValues (
+ kind: 'ended' | 'updated',
+ interval: number,
+ previousInterval?: number
+ ): void {
+ const timestamp = new Date()
+ const previousEnergyNominalInterval =
+ previousInterval != null && previousInterval > 0
+ ? OCPP20ServiceUtils.getTransactionEnergyNominalInterval(this, previousInterval, {
+ ...(kind === 'ended' && { txEndedInterval: previousInterval }),
+ ...(kind === 'updated' && { txUpdatedInterval: previousInterval }),
+ })
+ : undefined
+ for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors(true)) {
+ if (
+ connectorStatus.transactionStarted !== true ||
+ connectorStatus.transactionId == null ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionRestored === true
+ ) {
+ continue
+ }
+ if (previousInterval != null && previousInterval > 0) {
+ try {
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ this,
+ connectorId,
+ evseId,
+ connectorStatus.transactionId,
+ previousInterval,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ kind === 'updated'
+ ? OCPP20RequiredVariableName.TxUpdatedMeasurands
+ : OCPP20RequiredVariableName.TxEndedMeasurands
+ ),
+ kind === 'updated'
+ ? OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ : OCPP20ReadingContextEnumType.TRANSACTION_END,
+ timestamp,
+ previousEnergyNominalInterval,
+ true
+ )
+ } catch (error: unknown) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.restartTransactionMeterValues: Failed to settle connector ${connectorId.toString()}: ${getErrorMessage(error)}`
+ )
+ }
+ }
+ if (kind === 'updated') {
+ OCPP20ServiceUtils.stopUpdatedMeterValues(this, connectorId, evseId)
+ OCPP20ServiceUtils.startUpdatedMeterValues(this, connectorId, interval, evseId)
+ } else {
+ OCPP20ServiceUtils.stopEndedMeterValues(this, connectorId, evseId)
+ OCPP20ServiceUtils.startEndedMeterValues(this, connectorId, interval, evseId)
+ }
+ }
+ }
+
/** Restarts the WebSocket ping interval. */
public restartWebSocketPing (): void {
this.stopWebSocketPing()
this.startWebSocketPing()
}
+ /**
+ * Keeps an exact deferred frame queued when its in-flight replay is cancelled.
+ * @param message - Exact serialized OCPP frame to retain
+ * @returns Whether a matching queued frame exists
+ */
+ public retainBufferedMessage (message: string): boolean {
+ const messageIndex = this.messageQueue.indexOf(message)
+ if (messageIndex !== -1) {
+ if (messageIndex === 0 && this.bufferedMessageInFlight?.message === message) {
+ this.bufferedMessageInFlight.retracted = true
+ this.bufferedMessageInFlight.stopDrain = true
+ }
+ return true
+ }
+ if (!this.acknowledgedBufferedMessages.has(message)) return false
+ this.restoreAcknowledgedBufferedMessages()
+ return true
+ }
+
+ /**
+ * Keeps a buffered CALL identified by its OCPP message id.
+ * @param messageId - OCPP unique message identifier
+ * @returns Whether a matching queued request exists
+ */
+ public retainBufferedRequest (messageId: string): boolean {
+ for (const message of [...this.messageQueue, ...this.acknowledgedBufferedMessages.values()]) {
+ try {
+ const parsedMessage = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ if (parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId) {
+ return this.retainBufferedMessage(message)
+ }
+ } catch {
+ // Malformed frames cannot match a request id and are handled by the flush path.
+ }
+ }
+ return false
+ }
+
/** Persists the current OCPP configuration to storage. */
public saveOcppConfiguration (): void {
if (this.stationInfo?.ocppPersistentConfiguration === true) {
}
}
+ /**
+ * Coalesces queue snapshots to one in-flight save plus one latest-state save.
+ * @param deferred - Whether to pace this checkpoint behind the queue save interval
+ */
+ public saveTransactionEventQueues (deferred = false): void {
+ if (this.persistenceDiscarded) return
+ const persistenceState = this as unknown as {
+ transactionEventQueueMutationVersion?: number
+ }
+ persistenceState.transactionEventQueueMutationVersion =
+ (persistenceState.transactionEventQueueMutationVersion ?? 0) + 1
+ this.transactionEventQueueSaveDirty = true
+ ChargingStation.prototype.startTransactionEventQueueSave.call(this, deferred)
+ }
+
/**
* Updates the supervision server URL and optionally the CSMS basic auth credentials.
* @param url - The new supervision server URL
}
}
- /** Starts the charging station, initializes connectors, and connects to the central server. */
- public start (): void {
- if (!this.started) {
- if (!this.starting) {
- this.starting = true
- try {
- if (this.stationInfo?.enableStatistics === true) {
- this.performanceStatistics?.start()
- }
- this.openWSConnection()
+ /** Settles elapsed transaction energy before changing the active measurand family. */
+ public settleTransactionEnergyMeterValues (): void {
+ if (!this.started || this.stopping) return
+ const meteringConfiguration = OCPP20ServiceUtils.getTransactionEnergyMeteringConfiguration(this)
+ if (meteringConfiguration == null) return
+ const timestamp = new Date()
+ for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors(true)) {
+ if (
+ connectorStatus.transactionStarted !== true ||
+ connectorStatus.transactionId == null ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionRestored === true
+ ) {
+ continue
+ }
+ try {
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ this,
+ connectorId,
+ evseId,
+ connectorStatus.transactionId,
+ meteringConfiguration.interval,
+ meteringConfiguration.measurandsKey,
+ meteringConfiguration.context,
+ timestamp,
+ meteringConfiguration.interval,
+ true
+ )
+ } catch (error: unknown) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.settleTransactionEnergyMeterValues: Failed to settle connector ${connectorId.toString()}: ${getErrorMessage(error)}`
+ )
+ }
+ }
+ }
+
+ /** Starts the charging station, initializes connectors, and connects to the central server. */
+ public start (): void {
+ if (this.deleteAbortController.signal.aborted || this.deletePromise != null) {
+ logger.warn(`${this.logPrefix()} ${moduleName}.start: Charging station deletion is terminal`)
+ return
+ }
+ if (this.stopPromise != null) {
+ if (this.startAfterStopPromise == null) {
+ const pendingStop = this.stopPromise
+ const startAfterStop = pendingStop
+ .then(() => {
+ if (!this.deleteAbortController.signal.aborted && this.deletePromise == null) {
+ this.start()
+ }
+ return undefined
+ })
+ .catch((error: unknown) => {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.start: Cannot start after stop failed:`,
+ error
+ )
+ })
+ .finally(() => {
+ if (this.startAfterStopPromise === startAfterStop) {
+ delete this.startAfterStopPromise
+ }
+ })
+ this.startAfterStopPromise = startAfterStop
+ }
+ return
+ }
+ if (this.stopping) {
+ logger.warn(`${this.logPrefix()} ${moduleName}.start: Charging station is stopping`)
+ return
+ }
+ if (!this.started) {
+ if (!this.starting) {
+ if (this.lifecycleAbortController?.signal.aborted !== false) {
+ this.lifecycleAbortController = new AbortController()
+ }
+ this.starting = true
+ try {
+ this.ocppIncomingRequestService.activate(this, this.lifecycleAbortSignal)
+ if (this.stationInfo?.enableStatistics === true) {
+ this.performanceStatistics?.start()
+ }
+ this.openWSConnection()
// Monitor charging station template file
this.templateFileWatcher = watchJsonFile(
this.templateFile,
}
this.restartHeartbeat()
this.restartWebSocketPing()
+ this.restartAlignedMeterValues()
} catch (error) {
const e = ensureError(error)
logger.error(
}
}
+ /**
+ * 2F). OCPP 2.0.x only; a no-op when `AlignedDataCtrlr.Enabled` is false or
+ * `AlignedDataCtrlr.Interval` resolves to 0 or falls outside the supported
+ * UTC-day range.
+ */
+ public startAlignedMeterValues (): void {
+ if (!isOCPP20x(this.stationInfo?.ocppVersion)) {
+ return
+ }
+ // Defensive: validateStationInfo rejects Connectors-only OCPP 2.0.1
+ // templates, so a station reaching this point without EVSE topology would
+ // make the sweep a silent no-op.
+ if (!this.hasEvses) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.startAlignedMeterValues: Station has no EVSE topology (Connectors-only template), not starting the clock-aligned MeterValues`
+ )
+ return
+ }
+ if (!OCPP20ServiceUtils.isAlignedDataEnabled(this)) {
+ logger.info(
+ `${this.logPrefix()} ${moduleName}.startAlignedMeterValues: Clock-aligned MeterValues disabled by AlignedDataCtrlr.Enabled=false`
+ )
+ return
+ }
+ const intervalSeconds = OCPP20ServiceUtils.readAlignedDataIntervalSeconds(this)
+ if (intervalSeconds == null) return
+ if (intervalSeconds === 0) {
+ logger.info(
+ `${this.logPrefix()} ${moduleName}.startAlignedMeterValues: Clock-aligned MeterValues disabled by AlignedDataCtrlr.Interval=0`
+ )
+ return
+ }
+ const intervalMs = secondsToMilliseconds(intervalSeconds)
+ if (this.alignedMeterValuesSetTimeout != null) return
+ const generation = this.alignedMeterValuesGeneration
+ const emitCurrentSample = (): Promise<void> =>
+ OCPP20ServiceUtils.emitClockAlignedMeterValues(this, new Date()).catch((error: unknown) => {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.startAlignedMeterValues: Error emitting clock-aligned MeterValues:`,
+ error
+ )
+ })
+ const scheduleNext = (): void => {
+ if (generation !== this.alignedMeterValuesGeneration) return
+ const now = Date.now()
+ const dayStartMs = now - (now % Constants.MS_PER_DAY)
+ const dayEndMs = dayStartMs + Constants.MS_PER_DAY
+ const elapsedTodayMs = now - dayStartMs
+ const nextSlotMs = dayStartMs + (Math.floor(elapsedTodayMs / intervalMs) + 1) * intervalMs
+ const targetMs = Math.min(nextSlotMs, dayEndMs)
+ this.alignedMeterValuesSetTimeout = setTimeout(() => {
+ if (generation !== this.alignedMeterValuesGeneration) return
+ delete this.alignedMeterValuesSetTimeout
+ scheduleNext()
+ emitCurrentSample().catch(logger.error)
+ }, targetMs - now)
+ }
+ scheduleNext()
+ logger.info(
+ `${this.logPrefix()} ${moduleName}.startAlignedMeterValues: Clock-aligned MeterValues timer started every ${formatDurationMilliSeconds(intervalMs)} on UTC-day boundaries`
+ )
+ }
+
/**
* Starts automatic transaction generation on the specified connectors.
* @param connectorIds - Optional array of connector IDs to start ATG on
reason?: StopTransactionReason,
stopTransactions = this.stationInfo?.stopTransactionsOnStopped
): Promise<void> {
- if (this.started) {
- if (!this.stopping) {
- this.stopping = true
- try {
- try {
- await promiseWithTimeout(
- this.stopMessageSequence(reason, stopTransactions),
- Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS,
- `Timeout ${formatDurationMilliSeconds(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)} reached at stopping message sequence`
- )
- } catch (error: unknown) {
- logger.error(
- `${this.logPrefix()} ${moduleName}.stop: Error while stopping message sequence:`,
- error
- )
- }
- this.ocppIncomingRequestService.stop(this)
- this.closeWSConnection()
- if (this.stationInfo?.enableStatistics === true) {
- this.performanceStatistics?.stop()
- }
- this.templateFileWatcher?.close()
- delete this.bootNotificationResponse
- this.started = false
- this.saveConfiguration()
- this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
- this.emitChargingStationEvent(ChargingStationEvents.stopped)
- } finally {
- // Drop any coherent sessions still tracked at shutdown so a
- // subsequent restart cannot resurrect stale state or leak
- // module-scope runtime PRNG closures.
- CoherentMeterValuesManager.peekInstance(this)?.dispose()
- this.stopping = false
+ if (this.stopPromise != null) {
+ logger.warn(`${this.logPrefix()} ${moduleName}.stop: Already stopping`)
+ await this.stopPromise
+ return
+ }
+ if (!this.started) {
+ logger.warn(`${this.logPrefix()} ${moduleName}.stop: Already stopped`)
+ return
+ }
+
+ const { promise: stopPromise, reject, resolve } = Promise.withResolvers<undefined>()
+ this.stopPromise = stopPromise
+ this.stopping = true
+ try {
+ if (this.bufferedMessageInFlight != null) {
+ this.bufferedMessageInFlight.stopDrain = true
+ if (this.bufferedMessageInFlight.isRequest) {
+ this.bufferedMessageInFlight.retracted = true
}
- } else {
- logger.warn(`${this.logPrefix()} ${moduleName}.stop: Already stopping`)
}
- } else {
- logger.warn(`${this.logPrefix()} ${moduleName}.stop: Already stopped`)
+ this.lifecycleAbortController?.abort()
+ // Settle old-generation non-buffered requests before shutdown work starts.
+ // Buffered CALLs remain available for replay.
+ this.ocppRequestService.cancelPendingRequests(this, undefined, false, {
+ handleTransportStartedSends: true,
+ })
+ // Shutdown work uses a fresh generation while operations that began before
+ // stop() observe the aborted generation and release their lifecycle waits.
+ this.lifecycleAbortController = new AbortController()
+ await this.performStop(reason, stopTransactions)
+ resolve(undefined)
+ } catch (error: unknown) {
+ reject(error)
+ } finally {
+ // Drop any coherent sessions still tracked at shutdown so a
+ // subsequent restart cannot resurrect stale state or leak
+ // module-scope runtime PRNG closures.
+ CoherentMeterValuesManager.peekInstance(this)?.dispose()
+ if (this.stopPromise === stopPromise) delete this.stopPromise
+ this.stopping = false
+ }
+ await stopPromise
+ }
+
+ /** Stops the autonomous clock-aligned MeterValues timer. */
+ public stopAlignedMeterValues (): void {
+ this.alignedMeterValuesGeneration++
+ if (this.alignedMeterValuesSetTimeout != null) {
+ clearTimeout(this.alignedMeterValuesSetTimeout)
+ delete this.alignedMeterValuesSetTimeout
}
}
}
}
+ private discardPersistence (): void {
+ this.persistenceDiscarded = true
+ this.transactionEventQueueSaveDirty = false
+ this.transactionEventQueueSaveImmediate = false
+ ChargingStation.prototype.releaseTransactionEventQueueSaveDelay.call(this)
+ ChargingStation.prototype.rejectTransactionEventQueueSaveWaiters.call(
+ this,
+ new TransactionEventQueuePersistenceDiscardedError(
+ 'TransactionEvent queue persistence was discarded by station deletion'
+ )
+ )
+ }
+
+ private async drainTransactionEventQueueSaves (
+ owner: object
+ ): Promise<TransactionEventQueueSaveOutcome> {
+ const lifecycleSignal = this.lifecycleAbortSignal
+ const generationIsCurrent = (): boolean =>
+ this.lifecycleAbortSignal === lifecycleSignal && !this.persistenceDiscarded
+ let saveError: Error | undefined
+ while (this.transactionEventQueueSaveDirty && generationIsCurrent()) {
+ if (!this.transactionEventQueueSaveImmediate) {
+ await ChargingStation.prototype.waitForTransactionEventQueueSaveDelay.call(this)
+ if (!generationIsCurrent()) break
+ }
+ const savedVersion = this.transactionEventQueueMutationVersion
+ this.transactionEventQueueSaveDirty = false
+ this.transactionEventQueueSaveImmediate = false
+ let currentSaveError: Error | undefined
+ this.saveConfiguration(error => {
+ currentSaveError = error
+ })
+ await this.pendingConfigurationSave
+ saveError = currentSaveError
+ if (currentSaveError != null) {
+ this.transactionEventQueueSaveDirty = true
+ ChargingStation.prototype.rejectTransactionEventQueueSaveWaiters.call(
+ this,
+ currentSaveError,
+ savedVersion
+ )
+ break
+ }
+ this.transactionEventQueueCommittedVersion = Math.max(
+ this.transactionEventQueueCommittedVersion,
+ savedVersion
+ )
+ ChargingStation.prototype.resolveTransactionEventQueueSaveWaiters.call(this, savedVersion)
+ if (!generationIsCurrent()) break
+ }
+ if (!generationIsCurrent() && !this.persistenceDiscarded) {
+ this.transactionEventQueueSaveDirty = true
+ saveError ??= new TransactionEventQueuePersistenceSupersededError(
+ 'TransactionEvent queue persistence lifecycle generation was superseded'
+ )
+ ChargingStation.prototype.rejectTransactionEventQueueSaveWaiters.call(
+ this,
+ saveError,
+ undefined,
+ lifecycleSignal
+ )
+ }
+ if (this.persistenceDiscarded && saveError == null) {
+ saveError = new TransactionEventQueuePersistenceDiscardedError(
+ 'TransactionEvent queue persistence was discarded by station deletion'
+ )
+ }
+ if (this.transactionEventQueueSaveOwner === owner) {
+ delete this.transactionEventQueueSaveOwner
+ delete this.transactionEventQueueSavePromise
+ if (this.transactionEventQueueSaveDirty && !this.persistenceDiscarded) {
+ ChargingStation.prototype.startTransactionEventQueueSave.call(
+ this,
+ !this.transactionEventQueueSaveImmediate
+ )
+ }
+ }
+ return saveError == null ? {} : { error: saveError }
+ }
+
private flushMessageBuffer (): void {
- if (!this.flushingMessageBuffer && isNotEmptyArray<string>(this.messageQueue)) {
+ if (
+ !this.isStopping() &&
+ !this.flushingMessageBuffer &&
+ isNotEmptyArray<string>(this.messageQueue)
+ ) {
this.flushingMessageBuffer = true
this.sendMessageBuffer(() => {
this.flushingMessageBuffer = false
errorCallback(new OCPPError(errorType, errorMessage, requestCommandName, errorDetails))
}
- private async handleIncomingMessage (request: IncomingRequest): Promise<void> {
+ private async handleIncomingMessage (
+ request: IncomingRequest,
+ messageLifecycleSignal: AbortSignal,
+ messageLifecycleIsCurrent: () => boolean,
+ messageSourceIsCurrent: () => boolean
+ ): Promise<void> {
const [messageType, messageId, commandName, commandPayload] = request
if (this.requests.has(messageId)) {
throw new OCPPError(
this,
messageId,
commandName,
- commandPayload
+ commandPayload,
+ messageLifecycleSignal,
+ messageLifecycleIsCurrent,
+ messageSourceIsCurrent
)
this.emitChargingStationEvent(ChargingStationEvents.updated)
}
}
private initialize (options?: ChargingStationOptions): void {
+ const restorePersistedTransactions = !this.started && !this.starting
const stationTemplate = this.getTemplateFromFile()
if (stationTemplate == null) {
const errorMsg = `Failed to read charging station template file ${this.templateFile}`
(stationConfiguration?.connectorsStatus != null || stationConfiguration?.evsesStatus != null)
) {
checkConfiguration(stationConfiguration, this.logPrefix(), this.configurationFile)
- this.initializeConnectorsOrEvsesFromFile(stationConfiguration)
+ this.initializeConnectorsOrEvsesFromFile(
+ stationConfiguration,
+ stationTemplate,
+ options?.persistentConfiguration,
+ restorePersistedTransactions
+ )
} else {
this.initializeConnectorsOrEvsesFromTemplate(stationTemplate)
}
warnOnOCPP16TemplateKeys(this)
this.initializeOcppConfiguration()
this.initializeOcppServices()
+ const transactionEventRequestValidator = isOCPP20x(this.stationInfo.ocppVersion)
+ ? (request: OCPP20TransactionEventRequest): boolean => {
+ try {
+ return this.ocppRequestService.validateRequestPayload(
+ this,
+ RequestCommand.TRANSACTION_EVENT,
+ request,
+ { forceValidation: true }
+ )
+ } catch {
+ return false
+ }
+ }
+ : undefined
+ const queueNormalizationPhaseCount = Math.max(1, this.getNumberOfPhases())
+ const queueInletToOutputEfficiency =
+ this.stationInfo.currentOutType === CurrentType.DC
+ ? (this.stationInfo.conversionEfficiency ?? 1)
+ : 1
+ if (restorePersistedTransactions) {
+ for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors()) {
+ preparePersistedTransactionEventQueue(
+ connectorStatus,
+ transactionEventRequestValidator,
+ evseId ?? connectorId,
+ connectorId,
+ queueNormalizationPhaseCount,
+ queueInletToOutputEfficiency
+ )
+ }
+ }
if (this.stationInfo.autoRegister === true) {
this.bootNotificationResponse = {
currentTime: new Date(),
}
}
- private initializeConnectorsOrEvsesFromFile (configuration: ChargingStationConfiguration): void {
+ private initializeConnectorsOrEvsesFromFile (
+ configuration: ChargingStationConfiguration,
+ stationTemplate: ChargingStationTemplate,
+ persistentConfiguration?: boolean,
+ restorePersistedTransactions = true
+ ): void {
+ const usePersistedStationInfo =
+ persistentConfiguration ??
+ stationTemplate.stationInfoPersistentConfiguration ??
+ Constants.DEFAULT_STATION_INFO.stationInfoPersistentConfiguration
+ const electricalStationInfo = usePersistedStationInfo
+ ? configuration.stationInfo
+ : stationTemplate
+ const currentOutType =
+ electricalStationInfo?.currentOutType ??
+ Constants.DEFAULT_STATION_INFO.currentOutType ??
+ CurrentType.AC
+ const physicalNumberOfPhases =
+ currentOutType === CurrentType.DC ? 0 : (electricalStationInfo?.numberOfPhases ?? 3)
+ const normalizationPhaseCount = Math.max(1, physicalNumberOfPhases)
+ const inletToOutputEfficiency =
+ currentOutType === CurrentType.DC ? (electricalStationInfo?.conversionEfficiency ?? 1) : 1
if (configuration.connectorsStatus != null && configuration.evsesStatus == null) {
const isTupleFormat =
isNotEmptyArray(configuration.connectorsStatus) &&
status,
])
for (const [connectorId, connectorStatus] of entries) {
- this.connectors.set(connectorId, prepareConnectorStatus(clone(connectorStatus)))
+ const liveConnectorStatus = this.connectors.get(connectorId)
+ this.connectors.set(
+ connectorId,
+ !restorePersistedTransactions && liveConnectorStatus != null
+ ? liveConnectorStatus
+ : prepareConnectorStatus(
+ clone(connectorStatus),
+ normalizationPhaseCount,
+ inletToOutputEfficiency,
+ restorePersistedTransactions
+ )
+ )
}
} else if (configuration.evsesStatus != null && configuration.connectorsStatus == null) {
const isTupleFormat =
])
for (const [evseId, evseStatusConfiguration] of evseEntries) {
const evseStatus = clone(evseStatusConfiguration)
+ if (
+ typeof evseStatus.energyActiveImportRegisterValue !== 'number' ||
+ !Number.isFinite(evseStatus.energyActiveImportRegisterValue) ||
+ evseStatus.energyActiveImportRegisterValue < 0
+ ) {
+ delete evseStatus.energyActiveImportRegisterValue
+ }
+ if (
+ typeof evseStatus.energyActiveImportIntervalBaseline !== 'number' ||
+ !Number.isFinite(evseStatus.energyActiveImportIntervalBaseline) ||
+ evseStatus.energyActiveImportIntervalBaseline < 0
+ ) {
+ delete evseStatus.energyActiveImportIntervalBaseline
+ }
delete evseStatus.connectorsStatus
const connIsTupleFormat =
evseStatusConfiguration.connectorsStatus != null &&
: ((evseStatusConfiguration.connectorsStatus ?? []) as ConnectorStatus[]).map(
(status, index) => [index, status]
)
+ const templateEvse = stationTemplate.Evses?.[evseId]
+ if (templateEvse == null) {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.initializeConnectorsOrEvsesFromFile: Ignoring persisted EVSE ${evseId.toString()} absent from template ${this.templateFile}`
+ )
+ continue
+ }
+ const templateMeterValues = templateEvse.MeterValues
+ const liveEvseStatus = this.evses.get(evseId)
this.evses.set(evseId, {
...(evseStatus as EvseStatus),
connectors: new Map<number, ConnectorStatus>(
- connEntries.map(([connectorId, connectorStatus]) => [
- connectorId,
- prepareConnectorStatus(connectorStatus),
- ])
+ connEntries.map(([connectorId, connectorStatus]) => {
+ const liveConnectorStatus = liveEvseStatus?.connectors.get(connectorId)
+ return [
+ connectorId,
+ !restorePersistedTransactions && liveConnectorStatus != null
+ ? liveConnectorStatus
+ : prepareConnectorStatus(
+ connectorStatus,
+ normalizationPhaseCount,
+ inletToOutputEfficiency,
+ restorePersistedTransactions
+ ),
+ ]
+ })
),
+ MeterValues: clone(templateMeterValues ?? []),
})
}
} else if (configuration.evsesStatus != null && configuration.connectorsStatus != null) {
this.logPrefix(),
this.templateFile
),
+ ...(isNotEmptyArray(stationTemplate.Evses[evseKey].MeterValues) && {
+ MeterValues: clone(stationTemplate.Evses[evseKey].MeterValues),
+ }),
}
this.evses.set(evseId, evseStatus)
initializeConnectorsMapStatus(
}
}
- private onClose (code: WebSocketCloseEventStatusCode, reason: Buffer): void {
+ private onClose (
+ wsConnection: WebSocket,
+ code: WebSocketCloseEventStatusCode,
+ reason: Buffer
+ ): void {
+ const closedByRequest = this.wsConnectionsClosedByRequest.delete(wsConnection)
+ if (wsConnection !== this.wsConnection) {
+ return
+ }
+ this.wsConnection = null
+ this.restoreAcknowledgedBufferedMessages()
+ this.ocppRequestService.cancelPendingRequests(
+ this,
+ 'WebSocket closed while awaiting an OCPP response',
+ false,
+ { bufferInFlightSends: true, preserveRetainableWaiters: true }
+ )
this.emitChargingStationEvent(ChargingStationEvents.disconnected)
this.emitChargingStationEvent(ChargingStationEvents.updated)
- // Capture and clear the requested-close marker before deciding whether to
- // reconnect.
- const closedByRequest = this.wsConnectionClosedByRequest
- this.wsConnectionClosedByRequest = false
switch (code) {
// Normal close
case WebSocketCloseEventStatusCode.CLOSE_NO_STATUS:
}
// Reconnect on any close we did not request while still started: a
// server-initiated drop, or an internal close wanting a fresh connection
- // (e.g. certificate rotation), clean or abnormal. Only a requested close
- // stays terminal.
- if (this.started && !closedByRequest) {
+ // (e.g. certificate rotation), clean or abnormal. A requested close or
+ // an in-progress shutdown stays terminal.
+ if (this.started && !this.stopping && !closedByRequest) {
this.reconnect()
.then(() => {
this.emitChargingStationEvent(ChargingStationEvents.updated)
}
}
- private onError (error: WSError): void {
- logger.error(`${this.logPrefix()} ${moduleName}.onError: WebSocket error:`, error)
+ private onError (wsConnection: WebSocket, error: WSError): void {
+ if (wsConnection === this.wsConnection) {
+ logger.error(`${this.logPrefix()} ${moduleName}.onError: WebSocket error:`, error)
+ }
}
- private async onMessage (data: RawData): Promise<void> {
+ private async onMessage (
+ data: RawData,
+ sourceConnection: undefined | WebSocket = this.wsConnection ?? undefined
+ ): Promise<void> {
+ if (sourceConnection !== this.wsConnection || !this.started) return
+ const messageLifecycleSignal = this.lifecycleAbortSignal
+ const messageSourceIsCurrent = (): boolean => sourceConnection === this.wsConnection
+ const messageLifecycleIsCurrent = (): boolean =>
+ this.lifecycleAbortSignal === messageLifecycleSignal && !messageLifecycleSignal.aborted
let request: ErrorResponse | IncomingRequest | Response | undefined
let messageType: MessageType | undefined
let errorMsg: string
request = JSON.parse(data.toString()) as ErrorResponse | IncomingRequest | Response
if (Array.isArray(request)) {
;[messageType] = request
+ if (
+ this.isStopping() &&
+ messageType !== MessageType.CALL_RESULT_MESSAGE &&
+ messageType !== MessageType.CALL_ERROR_MESSAGE
+ ) {
+ return
+ }
switch (messageType) {
// Error Message
case MessageType.CALL_ERROR_MESSAGE:
break
// Incoming Message
case MessageType.CALL_MESSAGE:
- await this.handleIncomingMessage(request as IncomingRequest)
+ if (!messageLifecycleIsCurrent()) return
+ await this.handleIncomingMessage(
+ request as IncomingRequest,
+ messageLifecycleSignal,
+ messageLifecycleIsCurrent,
+ messageSourceIsCurrent
+ )
break
// Response Message
case MessageType.CALL_RESULT_MESSAGE:
)
}
} catch (error) {
+ if (this.isStopping()) return
if (!Array.isArray(request)) {
+ if (!messageLifecycleIsCurrent() || !messageSourceIsCurrent()) return
const e = ensureError(error)
logger.error(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
}
break
case MessageType.CALL_MESSAGE:
+ if (!messageLifecycleIsCurrent() || !messageSourceIsCurrent()) return
;[, , commandName] = request as IncomingRequest
await this.ocppRequestService.sendError(this, messageId, ocppError, commandName)
break
} succeeded`
)
let registrationRetryCount = 0
- if (!this.inAcceptedState()) {
+ const wasAcceptedAtOpen = this.inAcceptedState()
+ if (isNotEmptyArray(this.messageQueue)) this.setIntervalFlushMessageBuffer()
+ this.flushMessageBuffer()
+ if (!wasAcceptedAtOpen) {
do {
await this.ocppRequestService.requestHandler<
BootNotificationRequest,
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
`${this.logPrefix()} ${moduleName}.onOpen: Registration failure: maximum retries reached (${registrationRetryCount.toString()}) or retry disabled (${this.stationInfo?.registrationMaxRetries?.toString()})`
)
- } else {
- await flushQueuedTransactionMessages(this)
+ } else if (wasAcceptedAtOpen) {
+ await this.startAlignedMeterValuesAfterReplay()
}
this.emitChargingStationEvent(ChargingStationEvents.updated)
} else {
)
}
+ private async performDelete (): Promise<void> {
+ const stopPromise = this.started || this.stopPromise != null ? this.stop() : undefined
+ if (stopPromise != null) {
+ try {
+ await stopPromise
+ } catch (error) {
+ const e = ensureError(error)
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.delete: Error stopping station during delete:`,
+ e
+ )
+ }
+ }
+ if (this.deleteConfigurationRequested) {
+ ChargingStation.prototype.discardPersistence.call(this)
+ }
+ ChargingStation.prototype.releaseTransactionEventQueueSaveDelay.call(this)
+ let persistenceError: Error | undefined
+ if (!this.persistenceDiscarded) {
+ try {
+ await this.persistTransactionEventQueues()
+ } catch (error) {
+ if (!(error instanceof TransactionEventQueuePersistenceDiscardedError)) {
+ persistenceError = ensureError(error)
+ }
+ }
+ }
+ // A failed final checkpoint has already armed a deferred retry. Seal all
+ // persistence before clearing the topology so no continuation can overwrite
+ // the retained configuration with an empty snapshot.
+ ChargingStation.prototype.discardPersistence.call(this)
+ this.ocppRequestService.cancelPendingRequests(
+ this,
+ 'Charging station deleted while awaiting an OCPP response',
+ true
+ )
+ AutomaticTransactionGenerator.deleteInstance(this)
+ CoherentMeterValuesManager.deleteInstance(this)
+ PerformanceStatistics.deleteInstance(this.stationInfo?.hashId)
+ OCPPAuthServiceFactory.clearInstance(this)
+ if (this.stationInfo != null) {
+ const idTagsFile = getIdTagsFile(this.stationInfo)
+ if (idTagsFile != null) {
+ this.idTagsCache.deleteIdTags(idTagsFile)
+ } else {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.delete: No ID tags file found during deletion`
+ )
+ }
+ } else {
+ logger.warn(
+ `${this.logPrefix()} ${moduleName}.delete: No station info available during deletion`
+ )
+ }
+ this.connectors.clear()
+ this.evses.clear()
+ this.clearMessageBuffer()
+ this.templateFileWatcher?.unref()
+ this.chargingStationWorkerBroadcastChannel.unref()
+ this.emitChargingStationEvent(ChargingStationEvents.deleted)
+ this.removeAllListeners()
+ if (persistenceError != null) throw persistenceError
+ }
+
+ private performDeleteConfiguration (): void {
+ if (!existsSync(this.configurationFile)) return
+ try {
+ rmSync(this.configurationFile, { force: true })
+ } catch (error) {
+ const e = ensureError(error)
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.delete: Failed to delete configuration file ${this.configurationFile}:`,
+ e
+ )
+ }
+ }
+
+ private async performStop (
+ reason?: StopTransactionReason,
+ stopTransactions?: boolean
+ ): Promise<void> {
+ const shutdownDeadline = Date.now() + OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS
+ const shutdownLifecycleSignal =
+ this.lifecycleAbortController?.signal ?? this.lifecycleAbortSignal
+ OCPP20ServiceUtils.pauseTransactionMeterValues(this)
+ const runStopMessageSequence = async (): Promise<void> => {
+ try {
+ await this.stopMessageSequence(reason, stopTransactions, shutdownLifecycleSignal)
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.stop: Error while stopping message sequence:`,
+ error
+ )
+ }
+ }
+ let incomingRequestsStopped = false
+ let bufferedMessageCallbacksReleased = false
+ const releaseIncomingRequests = (): void => {
+ if (!incomingRequestsStopped) {
+ this.ocppIncomingRequestService.stop(this)
+ incomingRequestsStopped = true
+ }
+ if (!bufferedMessageCallbacksReleased) {
+ this.releaseAllBufferedMessageCallbacks()
+ bufferedMessageCallbacksReleased = true
+ }
+ }
+ let transportFinalized = false
+ const finalizeTransport = (): void => {
+ if (transportFinalized) return
+ transportFinalized = true
+ releaseIncomingRequests()
+ this.closeWSConnection({ byRequest: true })
+ this.lifecycleAbortController?.abort()
+ OCPP20ServiceUtils.cancelTransactionTerminations(this)
+ this.ocppRequestService.cancelPendingRequests(this, undefined, false, {
+ handleTransportStartedSends: true,
+ preserveRetainableWaiters: false,
+ })
+ for (const { connectorStatus } of this.iterateConnectors()) {
+ resetTransactionEventQueueRuntimeState(connectorStatus)
+ }
+ // Tear down the autonomous clock-aligned timer here too: the stop sequence
+ // may be short-circuited by a persistence error or timeout before reaching
+ // stopMessageSequence, otherwise the timer re-arms indefinitely and keeps
+ // emitting after the station has stopped.
+ this.stopAlignedMeterValues()
+ this.started = false
+ }
+ const shutdownGenerationIsCurrent = (): boolean =>
+ (this.lifecycleAbortController?.signal ?? this.lifecycleAbortSignal) ===
+ shutdownLifecycleSignal && !shutdownLifecycleSignal.aborted
+ let awaitedTransactionEventQueueSave: Promise<TransactionEventQueueSaveOutcome> | undefined
+ const flushTransactionEventQueues = async (): Promise<void> => {
+ ChargingStation.prototype.releaseTransactionEventQueueSaveDelay.call(this)
+ if (this.transactionEventQueueSaveDirty) {
+ const persistence = this.persistTransactionEventQueues()
+ awaitedTransactionEventQueueSave = this.transactionEventQueueSavePromise
+ await persistence
+ } else {
+ awaitedTransactionEventQueueSave = this.transactionEventQueueSavePromise
+ const outcome = await awaitedTransactionEventQueueSave
+ if (outcome?.error != null) throw outcome.error
+ }
+ }
+ const finalizeShutdown = async (): Promise<void> => {
+ // Settle accepted responses before the stop sequence snapshots transactions.
+ releaseIncomingRequests()
+ await OCPP20ServiceUtils.waitForTransactionTerminations(this)
+ await flushTransactionEventQueues()
+ if (!shutdownGenerationIsCurrent()) return
+ await runStopMessageSequence()
+ if (!shutdownGenerationIsCurrent()) return
+ await Promise.all(
+ this.iterateConnectors().map(({ connectorStatus }) =>
+ OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+ )
+ )
+ if (!shutdownGenerationIsCurrent()) return
+ await flushTransactionEventQueues()
+ if (!shutdownGenerationIsCurrent()) return
+ this.saveConfiguration()
+ await this.pendingConfigurationSave
+ }
+ try {
+ await promiseWithTimeout(
+ finalizeShutdown(),
+ Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS,
+ `Timeout ${formatDurationMilliSeconds(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)} reached while finalizing shutdown`
+ )
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.stop: Error while finalizing shutdown:`,
+ error
+ )
+ } finally {
+ OCPP20ServiceUtils.cancelTransactionTerminations(this)
+ const remainingShutdownTime = Math.max(shutdownDeadline - Date.now(), 0)
+ if (
+ remainingShutdownTime > 0 &&
+ shutdownGenerationIsCurrent() &&
+ (this.transactionEventQueueSaveDirty ||
+ (this.transactionEventQueueSavePromise != null &&
+ this.transactionEventQueueSavePromise !== awaitedTransactionEventQueueSave))
+ ) {
+ try {
+ await promiseWithTimeout(
+ flushTransactionEventQueues(),
+ remainingShutdownTime,
+ `Timeout ${formatDurationMilliSeconds(remainingShutdownTime)} reached while persisting the final transaction event queue snapshot`
+ )
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.stop: Error while persisting the final transaction event queue snapshot:`,
+ error
+ )
+ }
+ }
+ finalizeTransport()
+ // Seal this lifecycle epoch. Any persistence or delivery continuation that
+ // settles after the absolute finalization deadline observes a stale signal.
+ if (
+ (this.lifecycleAbortController?.signal ?? this.lifecycleAbortSignal) ===
+ shutdownLifecycleSignal
+ ) {
+ this.lifecycleAbortController = new AbortController()
+ }
+ }
+
+ if (this.stationInfo?.enableStatistics === true) {
+ this.performanceStatistics?.stop()
+ }
+ this.templateFileWatcher?.close()
+ delete this.bootNotificationResponse
+ this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
+ this.emitChargingStationEvent(ChargingStationEvents.stopped)
+ }
+
private async reconnect (): Promise<void> {
+ const reconnectLifecycleSignal = this.lifecycleAbortSignal
if (
this.stationInfo?.autoReconnectMaxRetries === -1 ||
this.wsConnectionRetryCount < (this.stationInfo?.autoReconnectMaxRetries ?? 0)
logger.error(
`${this.logPrefix()} ${moduleName}.reconnect: WebSocket connection retry in ${formatDurationMilliSeconds(reconnectDelay)}, timeout ${formatDurationMilliSeconds(reconnectTimeout)}`
)
- await sleep(reconnectDelay)
+ await interruptibleSleep(reconnectDelay, reconnectLifecycleSignal)
+ if (
+ this.lifecycleAbortSignal !== reconnectLifecycleSignal ||
+ reconnectLifecycleSignal.aborted ||
+ !this.started ||
+ this.stopping
+ ) {
+ return
+ }
logger.error(
`${this.logPrefix()} ${moduleName}.reconnect: WebSocket connection retry #${this.wsConnectionRetryCount.toString()}`
)
}
}
+ private rejectTransactionEventQueueSaveWaiters (
+ error: Error,
+ throughVersion?: number,
+ lifecycleSignal?: AbortSignal
+ ): void {
+ const waitersByVersion = (
+ this as unknown as {
+ transactionEventQueueSaveWaiters?: Map<number, Set<TransactionEventQueueSaveWaiter>>
+ }
+ ).transactionEventQueueSaveWaiters
+ if (waitersByVersion == null) return
+ for (const [version, waiters] of waitersByVersion) {
+ if (throughVersion != null && version > throughVersion) continue
+ for (const waiter of waiters) {
+ if (lifecycleSignal != null && waiter.lifecycleSignal !== lifecycleSignal) continue
+ waiters.delete(waiter)
+ waiter.reject(error)
+ }
+ if (isEmpty(waiters)) waitersByVersion.delete(version)
+ }
+ }
+
+ private releaseTransactionEventQueueSaveDelay (): void {
+ if (this.transactionEventQueueSaveDirty) this.transactionEventQueueSaveImmediate = true
+ if (this.transactionEventQueueSaveSetTimeout != null) {
+ clearTimeout(this.transactionEventQueueSaveSetTimeout)
+ delete this.transactionEventQueueSaveSetTimeout
+ }
+ const resolve = this.transactionEventQueueSaveDelayResolve
+ delete this.transactionEventQueueSaveDelayResolve
+ resolve?.()
+ }
+
+ private removeBufferedMessageEntry (
+ messageIndex: number,
+ notifyDiscarded: boolean
+ ): BufferedMessageCallbacks | undefined {
+ const [message] = this.messageQueue.splice(messageIndex, 1)
+ const entry = this.bufferedMessageEntries.splice(messageIndex, 1)[0] ?? {}
+ const { callbacks, reservation } = entry
+ this.releaseBufferedMessageCallbacks(reservation)
+ if (this.bufferedMessageInFlight?.entry === entry) {
+ this.bufferedMessageInFlight.retracted = true
+ }
+ this.acknowledgedBufferedMessages.delete(message)
+ if (notifyDiscarded) {
+ try {
+ callbacks?.onDiscarded?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.removeBufferedMessage: Buffered message discard callback failed:`,
+ error
+ )
+ }
+ }
+ if (isEmpty(this.messageQueue)) {
+ ChargingStation.prototype.clearIntervalFlushMessageBuffer.call(this)
+ }
+ return callbacks
+ }
+
+ private resolveTransactionEventQueueSaveWaiters (throughVersion: number): void {
+ const waitersByVersion = (
+ this as unknown as {
+ transactionEventQueueSaveWaiters?: Map<number, Set<TransactionEventQueueSaveWaiter>>
+ }
+ ).transactionEventQueueSaveWaiters
+ if (waitersByVersion == null) return
+ for (const [version, waiters] of waitersByVersion) {
+ if (version > throughVersion) continue
+ waitersByVersion.delete(version)
+ for (const waiter of waiters) waiter.resolve(undefined)
+ }
+ }
+
+ private restoreAcknowledgedBufferedMessages (): void {
+ if (isEmpty(this.acknowledgedBufferedMessages)) return
+ for (const message of this.acknowledgedBufferedMessages) {
+ try {
+ const [, messageId] = JSON.parse(message) as OutgoingRequest
+ this.requests.get(messageId)?.[6]?.()
+ } catch {
+ // Invalid buffered frames are discarded by sendMessageBuffer after reconnect.
+ }
+ }
+ const acknowledgedMessages = [...this.acknowledgedBufferedMessages.values()]
+ this.messageQueue.unshift(...acknowledgedMessages)
+ this.bufferedMessageEntries.unshift(
+ ...acknowledgedMessages.map((): BufferedMessageEntry => ({}))
+ )
+ this.acknowledgedBufferedMessages.clear()
+ this.setIntervalFlushMessageBuffer()
+ }
+
private saveAutomaticTransactionGeneratorConfiguration (): void {
if (this.stationInfo?.automaticTransactionGeneratorPersistentConfiguration === true) {
this.saveConfiguration()
}
}
- private saveConfiguration (): void {
+ private saveConfiguration (onError?: (error: Error) => void): void {
+ const lifecycleSignal = this.lifecycleAbortSignal
+ if (this.persistenceDiscarded) {
+ const error = new BaseError('Configuration persistence was discarded by station deletion')
+ logger.warn(`${this.logPrefix()} ${moduleName}.saveConfiguration: ${error.message}`)
+ onError?.(error)
+ return
+ }
if (isNotEmptyString(this.configurationFile)) {
try {
if (!existsSync(dirname(this.configurationFile))) {
} else {
delete configurationData.evsesStatus
}
+ // Freeze one coherent persistence snapshot before hashing and before
+ // waiting for the shared configuration-file lock.
+ configurationData = clone(configurationData)
delete configurationData.configurationHash
const configurationHash = hash(
Constants.DEFAULT_HASH_ALGORITHM,
} satisfies ChargingStationConfiguration),
'hex'
)
- if (this.configurationFileHash !== configurationHash) {
- AsyncLock.runExclusive(AsyncLockType.configuration, () => {
- configurationData.configurationHash = configurationHash
- const measureId = `${FileType.ChargingStationConfiguration} write`
- const beginId = PerformanceStatistics.beginMeasure(measureId)
- atomicWriteFileSync(
- this.configurationFile,
- JSONStringify(configurationData, 2, MapStringifyFormat.object),
- FileType.ChargingStationConfiguration,
- this.logPrefix()
- )
- PerformanceStatistics.endMeasure(measureId, beginId)
- this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
- this.sharedLRUCache.setChargingStationConfiguration(configurationData)
- this.configurationFileHash = configurationHash
- }).catch((error: unknown) => {
- // File-system failures are already logged at error level by the atomic
- // write via handleFileException; absorb them here at debug level. Other
- // failures inside the lock body (JSON serialization, cache mutation, ...)
- // would otherwise go unobserved, so log them at error level.
- const isErrnoException =
- typeof error === 'object' &&
- error !== null &&
- 'code' in error &&
- typeof (error as NodeJS.ErrnoException).code === 'string'
- if (isErrnoException) {
- logger.debug(
- `${this.logPrefix()} ${moduleName}.saveConfiguration: configuration save rejected:`,
- error
- )
- } else {
- logger.error(
- `${this.logPrefix()} ${moduleName}.saveConfiguration: unexpected error inside configuration save lock:`,
- ensureError(error)
+ const pendingSaveRequest = this.pendingConfigurationSaveRequest
+ if (
+ (pendingSaveRequest == null && this.configurationFileHash !== configurationHash) ||
+ (pendingSaveRequest != null &&
+ (pendingSaveRequest.configurationHash !== configurationHash ||
+ pendingSaveRequest.lifecycleSignal !== lifecycleSignal))
+ ) {
+ const saveRequest: ConfigurationSaveRequest = {
+ configurationHash,
+ lifecycleSignal,
+ onErrorCallbacks: onError == null ? [] : [onError],
+ }
+ this.pendingConfigurationSaveRequest = saveRequest
+ this.pendingConfigurationSave = AsyncLock.runExclusive(
+ AsyncLockType.configuration,
+ () => {
+ if (this.lifecycleAbortSignal !== lifecycleSignal) {
+ throw new BaseError('Configuration persistence lifecycle generation was superseded')
+ }
+ if (this.persistenceDiscarded) {
+ throw new BaseError('Configuration persistence was discarded by station deletion')
+ }
+ configurationData.configurationHash = configurationHash
+ const measureId = `${FileType.ChargingStationConfiguration} write`
+ const beginId = PerformanceStatistics.beginMeasure(measureId)
+ atomicWriteFileSync(
+ this.configurationFile,
+ JSONStringify(configurationData, 2, MapStringifyFormat.object),
+ FileType.ChargingStationConfiguration,
+ this.logPrefix()
)
+ PerformanceStatistics.endMeasure(measureId, beginId)
+ this.sharedLRUCache.deleteChargingStationConfiguration(this.configurationFileHash)
+ this.sharedLRUCache.setChargingStationConfiguration(configurationData)
+ this.configurationFileHash = configurationHash
}
- })
+ )
+ .catch((error: unknown) => {
+ // File-system failures are already logged at error level by the atomic
+ // write via handleFileException; absorb them here at debug level. Other
+ // failures inside the lock body (JSON serialization, cache mutation, ...)
+ // would otherwise go unobserved, so log them at error level.
+ const saveError = ensureError(error)
+ const isErrnoException =
+ 'code' in saveError && typeof (saveError as NodeJS.ErrnoException).code === 'string'
+ if (isErrnoException) {
+ logger.debug(
+ `${this.logPrefix()} ${moduleName}.saveConfiguration: configuration save rejected:`,
+ saveError
+ )
+ } else {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.saveConfiguration: unexpected error inside configuration save lock:`,
+ saveError
+ )
+ }
+ for (const errorCallback of saveRequest.onErrorCallbacks) {
+ errorCallback(saveError)
+ }
+ })
+ .finally(() => {
+ if (this.pendingConfigurationSaveRequest === saveRequest) {
+ delete this.pendingConfigurationSaveRequest
+ }
+ })
+ } else if (pendingSaveRequest != null) {
+ if (onError != null) pendingSaveRequest.onErrorCallbacks.push(onError)
+ logger.debug(
+ `${this.logPrefix()} ${moduleName}.saveConfiguration: Coalescing unchanged pending charging station configuration file ${
+ this.configurationFile
+ }`
+ )
} else {
logger.debug(
`${this.logPrefix()} ${moduleName}.saveConfiguration: Not saving unchanged charging station configuration file ${
)
}
} catch (error) {
+ const saveError = ensureError(error)
handleFileException(
this.configurationFile,
FileType.ChargingStationConfiguration,
- ensureError(error),
+ saveError,
this.logPrefix()
)
+ onError?.(saveError)
}
} else {
logger.error(
}
}
- private readonly sendMessageBuffer = (
- onCompleteCallback: () => void,
- messageIdx?: number
- ): void => {
+ private sendMessageBuffer (onCompleteCallback: () => void, messageIdx?: number): void {
+ if (this.bufferedMessageEntries.length < this.messageQueue.length) {
+ this.bufferedMessageEntries.push(
+ ...Array.from(
+ { length: this.messageQueue.length - this.bufferedMessageEntries.length },
+ (): BufferedMessageEntry => ({})
+ )
+ )
+ }
+ if (this.isStopping() || !this.isWebSocketConnectionOpened()) {
+ onCompleteCallback()
+ return
+ }
+ const replayLifecycleSignal = this.lifecycleAbortController?.signal
if (isNotEmptyArray<string>(this.messageQueue)) {
- const message = this.messageQueue[0]
- let beginId: string | undefined
+ const responseMessageIndex = this.messageQueue.findIndex(bufferedMessage => {
+ try {
+ const [messageType] = JSON.parse(bufferedMessage) as
+ ErrorResponse | OutgoingRequest | Response
+ return messageType !== MessageType.CALL_MESSAGE
+ } catch {
+ return true
+ }
+ })
+ const messageIndex =
+ responseMessageIndex >= 0 ? responseMessageIndex : this.inAcceptedState() ? 0 : -1
+ if (messageIndex < 0) {
+ onCompleteCallback()
+ return
+ }
+ const message = this.messageQueue[messageIndex]
+ const entry = this.bufferedMessageEntries[messageIndex] ?? {}
+ this.bufferedMessageEntries[messageIndex] = entry
let commandName: RequestCommand | undefined
+ let messageId: string | undefined
let parsedMessage: ErrorResponse | OutgoingRequest | Response
messageIdx ??= 0
try {
- parsedMessage = JSON.parse(message) as ErrorResponse | OutgoingRequest | Response
+ const parsedBufferedMessage: unknown = JSON.parse(message)
+ if (!Array.isArray(parsedBufferedMessage)) {
+ throw new TypeError('Buffered OCPP message is not an array')
+ }
+ parsedMessage = parsedBufferedMessage as ErrorResponse | OutgoingRequest | Response
} catch (error) {
logger.error(
`${this.logPrefix()} ${moduleName}.sendMessageBuffer: Error while parsing buffered OCPP message '${message}' to JSON:`,
error
)
- this.messageQueue.shift()
+ ChargingStation.prototype.removeBufferedMessageEntry.call(this, messageIndex, true)
this.sendMessageBuffer(onCompleteCallback, messageIdx)
return
}
const [messageType] = parsedMessage
const isRequest = messageType === MessageType.CALL_MESSAGE
+ if (isRequest && !this.inAcceptedState()) {
+ onCompleteCallback()
+ return
+ }
if (isRequest) {
- ;[, , commandName] = parsedMessage as OutgoingRequest
- beginId = PerformanceStatistics.beginMeasure(commandName)
+ ;[, messageId, commandName] = parsedMessage as OutgoingRequest
}
- this.wsConnection?.send(message, (error?: Error) => {
- if (isRequest && commandName != null && beginId != null) {
- PerformanceStatistics.endMeasure(commandName, beginId)
- }
- if (error == null) {
- logger.debug(
- `${this.logPrefix()} ${moduleName}.sendMessageBuffer: >> Buffered ${getMessageTypeString(messageType)} OCPP message sent '${message}'`
+ const bufferedMessageInFlight: {
+ entry: BufferedMessageEntry
+ isRequest: boolean
+ message: string
+ messageId?: string
+ retracted: boolean
+ stopDrain?: boolean
+ waitingForCallGate: boolean
+ } = {
+ entry,
+ isRequest,
+ message,
+ ...(messageId != null && { messageId }),
+ retracted: false,
+ waitingForCallGate: isRequest,
+ }
+ this.bufferedMessageInFlight = bufferedMessageInFlight
+ const replayConnection = this.wsConnection
+ if (replayConnection == null) {
+ delete this.bufferedMessageInFlight
+ onCompleteCallback()
+ return
+ }
+ const sendBufferedMessage = (): void => {
+ const messageToSend = bufferedMessageInFlight.message
+ const beginId =
+ isRequest && commandName != null
+ ? PerformanceStatistics.beginMeasure(commandName)
+ : undefined
+ let settled = false
+ const settleBufferedSend = (error?: Error): void => {
+ if (settled) return
+ settled = true
+ clearTimeout(sendTimeout)
+ if (isRequest && commandName != null && beginId != null) {
+ PerformanceStatistics.endMeasure(commandName, beginId)
+ }
+ if (
+ isRequest &&
+ messageId != null &&
+ (error != null ||
+ bufferedMessageInFlight.retracted ||
+ replayConnection !== this.wsConnection ||
+ !this.requests.has(messageId))
+ ) {
+ this.ocppRequestService.releaseOutgoingCall(this, messageId)
+ }
+ if (this.bufferedMessageInFlight === bufferedMessageInFlight) {
+ delete this.bufferedMessageInFlight
+ }
+ if (error == null) {
+ if (!bufferedMessageInFlight.retracted && replayConnection === this.wsConnection) {
+ if (isRequest && messageId != null) {
+ try {
+ this.requests.get(messageId)?.[5]?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.sendMessageBuffer: onMessageSent callback failed for buffered message id '${messageId}':`,
+ error
+ )
+ }
+ }
+ logger.debug(
+ `${this.logPrefix()} ${moduleName}.sendMessageBuffer: >> Buffered ${getMessageTypeString(messageType)} OCPP message sent '${messageToSend}'`
+ )
+ const currentMessageIndex = this.bufferedMessageEntries.indexOf(entry)
+ const callbacks =
+ currentMessageIndex >= 0
+ ? ChargingStation.prototype.removeBufferedMessageEntry.call(
+ this,
+ currentMessageIndex,
+ false
+ )
+ : undefined
+ if (isRequest) this.acknowledgedBufferedMessages.add(messageToSend)
+ try {
+ callbacks?.onSent?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.sendMessageBuffer: Buffered message sent callback failed:`,
+ error
+ )
+ }
+ }
+ } else {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.sendMessageBuffer: Error while sending buffered ${getMessageTypeString(messageType)} OCPP message '${messageToSend}':`,
+ error
+ )
+ }
+ if (bufferedMessageInFlight.stopDrain === true) {
+ onCompleteCallback()
+ this.flushMessageBuffer()
+ return
+ }
+ // eslint-disable-next-line promise/no-promise-in-callback -- exponential-backoff sleep inside a WebSocket callback; failures on the outer send are surfaced separately
+ sleep(
+ computeExponentialBackOffDelay({
+ baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
+ jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT,
+ retryNumber: messageIdx ?? 0,
+ })
)
- this.messageQueue.shift()
- } else {
- logger.error(
- `${this.logPrefix()} ${moduleName}.sendMessageBuffer: Error while sending buffered ${getMessageTypeString(messageType)} OCPP message '${message}':`,
- error
+ .then(() => {
+ if (replayLifecycleSignal?.aborted === true) {
+ onCompleteCallback()
+ this.flushMessageBuffer()
+ return undefined
+ }
+ if (messageIdx != null) {
+ ++messageIdx
+ }
+ this.sendMessageBuffer(onCompleteCallback, messageIdx)
+ return undefined
+ })
+ .catch((error: unknown) => {
+ throw error
+ })
+ }
+ const sendTimeout = setTimeout(() => {
+ settleBufferedSend(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Timeout ${formatDurationMilliSeconds(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)} sending buffered ${getMessageTypeString(messageType)} OCPP message '${messageToSend}'`
+ )
)
+ }, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)
+ try {
+ replayConnection.send(messageToSend, settleBufferedSend)
+ } catch (error: unknown) {
+ settleBufferedSend(ensureError(error))
}
- // eslint-disable-next-line promise/no-promise-in-callback -- exponential-backoff sleep inside a WebSocket callback; failures on the outer send are surfaced separately
- sleep(
- computeExponentialBackOffDelay({
- baseDelayMs: Constants.DEFAULT_EXPONENTIAL_BACKOFF_BASE_DELAY_MS,
- jitterPercent: Constants.DEFAULT_RECONNECT_JITTER_PERCENT,
- retryNumber: messageIdx ?? 0,
- })
+ }
+ if (isRequest && messageId != null) {
+ const cachedResponseTimeoutMs = this.requests.get(messageId)?.[7] ?? 0
+ const replaySlotTimeoutMs = clampToSafeTimerValue(
+ clampToSafeTimerValue(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS) +
+ clampToSafeTimerValue(cachedResponseTimeoutMs)
)
+ this.ocppRequestService
+ .acquireOutgoingCall(this, messageId, replaySlotTimeoutMs, () => true)
.then(() => {
- if (messageIdx != null) {
- ++messageIdx
+ bufferedMessageInFlight.waitingForCallGate = false
+ if (
+ this.isStopping() ||
+ !this.inAcceptedState() ||
+ bufferedMessageInFlight.retracted ||
+ replayConnection !== this.wsConnection
+ ) {
+ this.ocppRequestService.releaseOutgoingCall(this, messageId)
+ if (this.bufferedMessageInFlight === bufferedMessageInFlight) {
+ delete this.bufferedMessageInFlight
+ }
+ onCompleteCallback()
+ this.flushMessageBuffer()
+ return undefined
}
- this.sendMessageBuffer(onCompleteCallback, messageIdx)
+ sendBufferedMessage()
return undefined
})
- .catch((error: unknown) => {
- throw error
+ .catch(() => {
+ if (this.bufferedMessageInFlight === bufferedMessageInFlight) {
+ delete this.bufferedMessageInFlight
+ }
+ onCompleteCallback()
+ if (
+ !this.isStopping() &&
+ this.isWebSocketConnectionOpened() &&
+ isNotEmptyArray(this.messageQueue)
+ ) {
+ const hasBufferedResponse = this.messageQueue.some(bufferedMessage => {
+ try {
+ const [bufferedMessageType] = JSON.parse(bufferedMessage) as
+ ErrorResponse | OutgoingRequest | Response
+ return bufferedMessageType !== MessageType.CALL_MESSAGE
+ } catch {
+ return true
+ }
+ })
+ if (hasBufferedResponse) this.flushMessageBuffer()
+ else this.setIntervalFlushMessageBuffer()
+ }
})
- })
+ } else {
+ sendBufferedMessage()
+ }
} else {
onCompleteCallback()
}
private setIntervalFlushMessageBuffer (): void {
this.flushMessageBufferSetInterval ??= setInterval(() => {
- if (this.isWebSocketConnectionOpened() && this.inAcceptedState()) {
+ if (this.isWebSocketConnectionOpened()) {
this.flushMessageBuffer()
}
if (!this.isWebSocketConnectionOpened() || isEmpty(this.messageQueue)) {
}, Constants.DEFAULT_MESSAGE_BUFFER_FLUSH_INTERVAL_MS)
}
+ private async startAcceptedMessageSequence (ATGStopAbsoluteDuration?: boolean): Promise<void> {
+ this.acceptedMessageSequenceStartup ??= (async () => {
+ try {
+ await this.startMessageSequence(ATGStopAbsoluteDuration)
+ } catch (error) {
+ logger.error(
+ `${this.logPrefix()} ${moduleName}.startAcceptedMessageSequence: Error while starting the message sequence:`,
+ error
+ )
+ }
+ if (
+ this.started &&
+ !this.stopping &&
+ this.isWebSocketConnectionOpened() &&
+ this.inAcceptedState()
+ ) {
+ await this.startAlignedMeterValuesAfterReplay()
+ }
+ })()
+ const startup = this.acceptedMessageSequenceStartup
+ try {
+ await startup
+ } finally {
+ if (this.acceptedMessageSequenceStartup === startup) {
+ this.acceptedMessageSequenceStartup = undefined
+ }
+ }
+ }
+
+ private async startAlignedMeterValuesAfterReplay (): Promise<void> {
+ this.alignedMeterValuesStartup ??= (async () => {
+ const generation = this.alignedMeterValuesGeneration
+ await flushQueuedTransactionMessages(this)
+ if (
+ generation === this.alignedMeterValuesGeneration &&
+ this.started &&
+ !this.stopping &&
+ this.isWebSocketConnectionOpened() &&
+ this.inAcceptedState()
+ ) {
+ this.startAlignedMeterValues()
+ }
+ })()
+ const startup = this.alignedMeterValuesStartup
+ try {
+ await startup
+ } finally {
+ if (this.alignedMeterValuesStartup === startup) {
+ this.alignedMeterValuesStartup = undefined
+ if (this.alignedMeterValuesRestartPending) {
+ this.alignedMeterValuesRestartPending = false
+ if (
+ this.started &&
+ !this.stopping &&
+ this.isWebSocketConnectionOpened() &&
+ this.inAcceptedState()
+ ) {
+ this.startAlignedMeterValues()
+ }
+ }
+ }
+ }
+ }
+
private async startMessageSequence (ATGStopAbsoluteDuration?: boolean): Promise<void> {
+ const responseTimeoutMs = isOCPP20x(this.stationInfo?.ocppVersion)
+ ? OCPP20ServiceUtils.getMessageTimeout(this)
+ : secondsToMilliseconds(Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS)
if (this.stationInfo?.autoRegister === true) {
await this.ocppRequestService.requestHandler<
BootNotificationRequest,
BootNotificationResponse
>(this, RequestCommand.BOOT_NOTIFICATION, this.bootNotificationRequest, {
+ responseTimeoutMs,
skipBufferingOnError: true,
})
}
this.startHeartbeat()
}
for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors(true)) {
- await sendAndSetConnectorStatus(this, {
- connectorId,
- ...(evseId != null && { evseId }),
- status: getBootConnectorStatus(this, connectorId, connectorStatus),
- })
+ await sendAndSetConnectorStatus(
+ this,
+ {
+ connectorId,
+ ...(evseId != null && { evseId }),
+ status: getBootConnectorStatus(this, connectorId, connectorStatus),
+ },
+ { responseTimeoutMs, send: true }
+ )
}
if (this.stationInfo?.firmwareStatus === FirmwareStatus.Installing) {
await this.ocppRequestService.requestHandler<
FirmwareStatusNotificationRequest,
FirmwareStatusNotificationResponse
- >(this, RequestCommand.FIRMWARE_STATUS_NOTIFICATION, {
- status: FirmwareStatus.Installed,
- })
+ >(
+ this,
+ RequestCommand.FIRMWARE_STATUS_NOTIFICATION,
+ {
+ status: FirmwareStatus.Installed,
+ },
+ {
+ responseTimeoutMs,
+ }
+ )
this.stationInfo.firmwareStatus = FirmwareStatus.Installed
}
this.flushMessageBuffer()
}
+ private startTransactionEventQueueSave (deferred: boolean): void {
+ if (!deferred) {
+ ChargingStation.prototype.releaseTransactionEventQueueSaveDelay.call(this)
+ }
+ if (this.transactionEventQueueSavePromise == null) {
+ const owner = {}
+ this.transactionEventQueueSaveOwner = owner
+ this.transactionEventQueueSavePromise =
+ ChargingStation.prototype.drainTransactionEventQueueSaves.call(this, owner)
+ }
+ }
+
private startWebSocketPing (): void {
const webSocketPingInterval = this.getWebSocketPingInterval()
if (webSocketPingInterval > 0 && this.wsPingSetInterval == null) {
private async stopMessageSequence (
reason?: StopTransactionReason,
- stopTransactions?: boolean
+ stopTransactions?: boolean,
+ lifecycleAbortSignal = this.lifecycleAbortSignal
): Promise<void> {
+ const generationIsCurrent = (): boolean =>
+ this.lifecycleAbortSignal === lifecycleAbortSignal && !lifecycleAbortSignal.aborted
+ if (!generationIsCurrent()) return
this.internalStopMessageSequence()
- stopTransactions && (await stopRunningTransactions(this, reason))
+ if (!generationIsCurrent()) return
+ this.stopAlignedMeterValues()
+ if (stopTransactions) {
+ await stopRunningTransactions(this, reason)
+ if (!generationIsCurrent()) return
+ }
for (const { connectorId, connectorStatus, evseId } of this.iterateConnectors(true)) {
- await sendAndSetConnectorStatus(this, {
- connectorId,
- ...(evseId != null && { evseId }),
- status: ConnectorStatusEnum.Unavailable,
- })
+ if (!generationIsCurrent()) break
+ await sendAndSetConnectorStatus(
+ this,
+ {
+ connectorId,
+ ...(evseId != null && { evseId }),
+ status: ConnectorStatusEnum.Unavailable,
+ },
+ { lifecycleAbortSignal, send: true }
+ )
+ if (!generationIsCurrent()) break
delete connectorStatus.status
}
}
this.wsConnection?.terminate()
}
}
+
+ private async waitForTransactionEventQueueSaveDelay (): Promise<void> {
+ await new Promise<void>(resolve => {
+ this.transactionEventQueueSaveDelayResolve = resolve
+ this.transactionEventQueueSaveSetTimeout = setTimeout(() => {
+ ChargingStation.prototype.releaseTransactionEventQueueSaveDelay.call(this)
+ }, TRANSACTION_EVENT_QUEUE_CHECKPOINT_INTERVAL_MS)
+ this.transactionEventQueueSaveSetTimeout.unref()
+ })
+ }
}
* disabled or no valid EV profile file is loaded.
* @param transactionId - Transaction identifier from the CSMS.
* @param connectorId - Connector on which the transaction is running.
+ * @param evseId - EVSE containing the connector when connector ids are EVSE-local.
* @returns The active or newly-created session, or `undefined` when
* coherent mode is not usable.
*/
public createSession (
transactionId: number | string,
- connectorId: number
+ connectorId: number,
+ evseId?: number
): CoherentSession | undefined {
const existing = this.sessions.get(transactionId)
if (existing != null) {
}
const session = createCoherentSession(this.chargingStation, {
connectorId,
+ ...(evseId != null && { evseId }),
profiles: this.evProfiles.profiles,
rootSeed: resolveRootSeed(this.chargingStation.stationInfo),
transactionId,
getBootConnectorStatus,
initializeConnectorsMapStatus,
prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
resetAuthorizeConnectorStatus,
resetConnectorStatus,
} from './HelpersConnectorStatus.js'
* and sorted by priorities
* @param chargingStation - Charging station
* @param connectorId - Connector id
+ * @param connectorStatus - Exact connector state when connector ids are EVSE-local.
* @returns Connector charging profiles array
*/
export const getConnectorChargingProfiles = (
chargingStation: ChargingStation,
- connectorId: number
+ connectorId: number,
+ connectorStatus?: ConnectorStatus
): ChargingProfile[] => {
- return (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? [])
+ const chargingProfiles =
+ connectorStatus != null
+ ? (connectorStatus.chargingProfiles ?? [])
+ : (chargingStation.getConnectorStatus(connectorId)?.chargingProfiles ?? [])
+ return chargingProfiles
.slice()
.sort((a, b) => {
if (
* @param chargingStation - Source charging station.
* @param connectorId - Target connector id.
* @returns Limit in watts, or `undefined` when no applicable profile is found.
+ * @param connectorStatus - Exact connector state when connector ids are EVSE-local.
*/
export const getConnectorChargingProfilesLimit = (
chargingStation: ChargingStation,
- connectorId: number
+ connectorId: number,
+ connectorStatus?: ConnectorStatus
): number | undefined => {
- const chargingProfiles = getConnectorChargingProfiles(chargingStation, connectorId)
+ const chargingProfiles = getConnectorChargingProfiles(
+ chargingStation,
+ connectorId,
+ connectorStatus
+ )
if (isNotEmptyArray(chargingProfiles)) {
const chargingProfilesLimit = getChargingProfilesLimit(
chargingStation,
connectorId,
- chargingProfiles
+ chargingProfiles,
+ connectorStatus
)
if (chargingProfilesLimit != null) {
const limit = buildChargingProfilesLimit(chargingStation, chargingProfilesLimit)
return limit
}
const connectorMaximumPower =
- chargingStation.getConnectorStatus(connectorId)?.maximumPower ??
- maximumPower / (chargingStation.powerDivider ?? 1)
+ connectorStatus != null
+ ? (connectorStatus.maximumPower ?? maximumPower / (chargingStation.powerDivider ?? 1))
+ : (chargingStation.getConnectorStatus(connectorId)?.maximumPower ??
+ maximumPower / (chargingStation.powerDivider ?? 1))
if (limit > connectorMaximumPower) {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.getConnectorChargingProfilesLimit: Charging profile id ${getChargingProfileId(chargingProfilesLimit.chargingProfile)} limit ${limit.toString()} is greater than connector ${connectorId.toString()} maximum ${connectorMaximumPower.toString()}: %j`,
* @param chargingStation - The charging station instance
* @param connectorId - The connector identifier
* @param chargingProfiles - Array of charging profiles
+ * @param connectorStatusOverride - Exact connector state when connector ids are EVSE-local.
* @returns Charging profiles limit or undefined if no valid limit found
*/
const getChargingProfilesLimit = (
chargingStation: ChargingStation,
connectorId: number,
- chargingProfiles: ChargingProfile[]
+ chargingProfiles: ChargingProfile[],
+ connectorStatusOverride?: ConnectorStatus
): ChargingProfilesLimit | undefined => {
const debugLogMsg = `${chargingStation.logPrefix()} ${moduleName}.getChargingProfilesLimit: Charging profiles limit found: %j`
const currentDate = new Date()
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = connectorStatusOverride ?? chargingStation.getConnectorStatus(connectorId)
let previousActiveChargingProfile: ChargingProfile | undefined
for (const chargingProfile of chargingProfiles) {
const chargingProfileId = getChargingProfileId(chargingProfile)
import type { ChargingStation } from './ChargingStation.js'
+import { BaseError } from '../exception/index.js'
import {
AvailabilityType,
ChargingProfilePurposeType,
type ConnectorStatus,
ConnectorStatusEnum,
+ OCPP20ComponentName,
+ OCPP20ReadingContextEnumType,
+ OCPP20RequiredVariableName,
+ OCPP20TransactionEventEnumType,
+ type OCPP20TransactionEventRequest,
+ type QueuedTransactionEvent,
} from '../types/index.js'
-import { clone, convertToDate, convertToInt, isNotEmptyArray, logger } from '../utils/index.js'
+import {
+ clone,
+ convertToDate,
+ convertToInt,
+ isEmpty,
+ isJsonObject,
+ isNotEmptyArray,
+ isNotEmptyString,
+ logger,
+} from '../utils/index.js'
+import { buildConfigKey } from './ConfigurationKeyUtils.js'
import { getSingleChargingSchedule } from './HelpersChargingProfile.js'
import { getMaxNumberOfConnectors } from './HelpersConfig.js'
+import { getRepresentedTransactionIntervalEnergyWh } from './meter-values/index.js'
+import {
+ boundTransactionEventQueue,
+ getMutableSignedMeterValue,
+ getRawSignedMeterValuePublicKey,
+} from './TransactionEventQueueUtils.js'
const moduleName = 'HelpersConnectorStatus'
defaultMaximumPower?: number
): void => {
for (const [connectorId, connectorStatus] of connectors) {
+ delete connectorStatus.transactionEnding
if (connectorId > 0 && connectorStatus.transactionStarted === true) {
if (
connectorStatus.transactionId == null ||
* Full connector reset: drops the transaction bookkeeping and the
* transaction-scoped energy counter, filters out non-station-scope
* charging profiles, and clears authorization + reservation state. The
- * station-scoped `energyActiveImportRegisterValue` is deliberately
- * preserved (persistent energy register per OCPP), and `availability` is
- * untouched. Safe to call on a `null` / `undefined` connector (no-op).
+ * physical `energyActiveImportRegisterValue` and
+ * `energyActiveImportIntervalBaselines` are deliberately preserved across
+ * transactions, and `availability` is untouched. Safe to call on a `null` /
+ * `undefined` connector (no-op).
* @param connectorStatus - Target connector status to reset in place, or `null` / `undefined` for a no-op.
*/
export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefined): void => {
connectorStatus.transactionPending = false
connectorStatus.transactionRemoteStarted = false
connectorStatus.transactionStarted = false
+ delete connectorStatus.transactionEnding
+ delete connectorStatus.transactionStarting
+ delete connectorStatus.transactionRestored
delete connectorStatus.transactionStart
delete connectorStatus.transactionId
delete connectorStatus.transactionIdTag
delete connectorStatus.transactionGroupIdToken
+ delete connectorStatus.remoteStartId
+ delete connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ delete connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
delete connectorStatus.transactionBeginMeterValue
delete connectorStatus.transactionEndedMeterValues
delete connectorStatus.transactionEndedMeterValuesSetInterval
}
delete connectorStatus.transactionSeqNo
+ delete connectorStatus.transactionStartedExhaustedTransactionId
delete connectorStatus.publicKeySentInTransaction
delete connectorStatus.transactionEvseSent
delete connectorStatus.transactionIdTokenSent
delete connectorStatus.transactionDeauthorizedEnergyWh
}
+const STATION_INTERVAL_BASELINE_PREFIX = 'station:'
+
+const sanitizeEnergyIntervalBaselines = (value: unknown): Record<string, number> =>
+ isJsonObject(value)
+ ? Object.fromEntries(
+ Object.entries(value).filter(
+ (entry): entry is [string, number] =>
+ isNotEmptyString(entry[0]) &&
+ typeof entry[1] === 'number' &&
+ Number.isFinite(entry[1]) &&
+ entry[1] >= 0
+ )
+ )
+ : {}
+
+const convertPersistedDate = (value: unknown): Date | undefined => {
+ if (!(value instanceof Date) && typeof value !== 'string' && typeof value !== 'number') {
+ return undefined
+ }
+ try {
+ return convertToDate(value)
+ } catch {
+ return undefined
+ }
+}
+
+const isValidTransactionSeqNo = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isSafeInteger(value) && value >= 0
+
+const getPersistedCandidateRawPublicKey = (candidate: unknown): string | undefined => {
+ if (!isJsonObject(candidate) || !isJsonObject(candidate.request)) return undefined
+ const meterValues: unknown = candidate.request.meterValue
+ if (!Array.isArray(meterValues)) return undefined
+ for (const meterValue of meterValues) {
+ if (!isJsonObject(meterValue) || !Array.isArray(meterValue.sampledValue)) continue
+ for (const sampledValue of meterValue.sampledValue) {
+ if (!isJsonObject(sampledValue) || !isJsonObject(sampledValue.signedMeterValue)) continue
+ const publicKey = sampledValue.signedMeterValue.publicKey
+ if (isNotEmptyString(publicKey)) return publicKey
+ }
+ }
+ return undefined
+}
+
+export type PersistedTransactionEventRequestValidator = (
+ request: OCPP20TransactionEventRequest
+) => boolean
+
+const prepareQueuedTransactionEvent = (
+ candidate: unknown,
+ validateRequest: PersistedTransactionEventRequestValidator,
+ ownerEvseId: number,
+ ownerConnectorId: number,
+ allowLegacyOwnerMigration: boolean
+): QueuedTransactionEvent | undefined => {
+ if (
+ !isJsonObject(candidate) ||
+ !isJsonObject(candidate.request) ||
+ !isJsonObject(candidate.request.transactionInfo) ||
+ !isValidTransactionSeqNo(candidate.seqNo) ||
+ !isValidTransactionSeqNo(candidate.request.seqNo) ||
+ typeof candidate.request.eventType !== 'string' ||
+ typeof candidate.request.triggerReason !== 'string' ||
+ typeof candidate.request.transactionInfo.transactionId !== 'string' ||
+ candidate.seqNo !== candidate.request.seqNo
+ ) {
+ return undefined
+ }
+ const hasOwnerEvseId = Object.hasOwn(candidate, 'ownerEvseId')
+ const hasOwnerConnectorId = Object.hasOwn(candidate, 'ownerConnectorId')
+ if (!hasOwnerEvseId && !hasOwnerConnectorId) {
+ if (!allowLegacyOwnerMigration) return undefined
+ candidate.ownerEvseId = ownerEvseId
+ candidate.ownerConnectorId = ownerConnectorId
+ } else if (
+ !hasOwnerEvseId ||
+ !hasOwnerConnectorId ||
+ candidate.ownerEvseId !== ownerEvseId ||
+ candidate.ownerConnectorId !== ownerConnectorId
+ ) {
+ return undefined
+ }
+ if (
+ isJsonObject(candidate.request.evse) &&
+ (candidate.request.evse.id !== ownerEvseId ||
+ (candidate.request.evse.connectorId != null &&
+ candidate.request.evse.connectorId !== ownerConnectorId))
+ ) {
+ return undefined
+ }
+ if (candidate.deliveryAttempted != null && typeof candidate.deliveryAttempted !== 'boolean') {
+ return undefined
+ }
+ const request = candidate.request as unknown as OCPP20TransactionEventRequest
+ if (!validateRequest(request)) return undefined
+ const queuedEvent = candidate as unknown as QueuedTransactionEvent
+ queuedEvent.deliveryAttempted ??= false
+ if (queuedEvent.transactionEnergyActiveImportIntervalBaselines != null) {
+ if (!isJsonObject(queuedEvent.transactionEnergyActiveImportIntervalBaselines)) {
+ delete queuedEvent.transactionEnergyActiveImportIntervalBaselines
+ } else {
+ queuedEvent.transactionEnergyActiveImportIntervalBaselines = Object.fromEntries(
+ Object.entries(queuedEvent.transactionEnergyActiveImportIntervalBaselines).filter(
+ (entry): entry is [string, number] =>
+ typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] >= 0
+ )
+ )
+ }
+ }
+ if (queuedEvent.transactionEnergyActiveImportIntervalConsumption != null) {
+ if (!isJsonObject(queuedEvent.transactionEnergyActiveImportIntervalConsumption)) {
+ delete queuedEvent.transactionEnergyActiveImportIntervalConsumption
+ } else {
+ queuedEvent.transactionEnergyActiveImportIntervalConsumption = Object.fromEntries(
+ Object.entries(queuedEvent.transactionEnergyActiveImportIntervalConsumption).filter(
+ (entry): entry is [string, number] =>
+ typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0
+ )
+ )
+ }
+ }
+ if (
+ queuedEvent.transactionEnergyActiveImportRegisterValue != null &&
+ (typeof queuedEvent.transactionEnergyActiveImportRegisterValue !== 'number' ||
+ !Number.isFinite(queuedEvent.transactionEnergyActiveImportRegisterValue) ||
+ queuedEvent.transactionEnergyActiveImportRegisterValue < 0)
+ ) {
+ delete queuedEvent.transactionEnergyActiveImportRegisterValue
+ }
+ const queuedTimestamp = convertPersistedDate(queuedEvent.timestamp)
+ const requestTimestamp = convertPersistedDate(queuedEvent.request.timestamp)
+ if (queuedTimestamp == null || requestTimestamp == null) return undefined
+ queuedEvent.timestamp = queuedTimestamp
+ queuedEvent.request.timestamp = requestTimestamp
+ if (queuedEvent.request.meterValue != null) {
+ if (!isNotEmptyArray(queuedEvent.request.meterValue)) return undefined
+ for (const meterValue of queuedEvent.request.meterValue) {
+ if (!isJsonObject(meterValue) || !isNotEmptyArray(meterValue.sampledValue)) return undefined
+ const meterValueTimestamp = convertPersistedDate(meterValue.timestamp)
+ if (
+ meterValueTimestamp == null ||
+ !meterValue.sampledValue.every(sampledValue => isJsonObject(sampledValue))
+ ) {
+ return undefined
+ }
+ meterValue.timestamp = meterValueTimestamp
+ }
+ }
+ return queuedEvent
+}
+
/**
- * Post-load rehydration hook: coerces the persisted reservation
- * `expiryDate` back into a `Date` instance (or drops the reservation
- * when the value cannot be parsed), and returns the same reference so
- * callers can chain.
+ * Rehydrates and sanitizes persisted connector state, migrates legacy
+ * station interval baselines, reconstructs queued event dates and active
+ * transaction ownership, and returns the same reference for chaining.
* @param connectorStatus - Target connector status to rehydrate in place.
+ * @param numberOfPhases - Physical phase count used to normalize legacy interval samples.
+ * @param inletToOutputEfficiency - DC inlet-to-output efficiency used for legacy interval samples.
+ * @param restorePersistedTransaction - Whether an active transaction is being cold-restored.
* @returns The same `connectorStatus` reference, after rehydration.
*/
-export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => {
+export const prepareConnectorStatus = (
+ connectorStatus: ConnectorStatus,
+ numberOfPhases = 3,
+ inletToOutputEfficiency = 1,
+ restorePersistedTransaction = true
+): ConnectorStatus => {
+ delete connectorStatus.transactionStarting
+ delete connectorStatus.transactionEnding
+ if (
+ typeof connectorStatus.transactionStartedExhaustedTransactionId !== 'string' ||
+ isEmpty(connectorStatus.transactionStartedExhaustedTransactionId) ||
+ connectorStatus.transactionStartedExhaustedTransactionId.length > 36
+ ) {
+ delete connectorStatus.transactionStartedExhaustedTransactionId
+ }
if (connectorStatus.reservation != null) {
const reservationExpiryDate = convertToDate(connectorStatus.reservation.expiryDate)
if (reservationExpiryDate != null) {
delete connectorStatus.reservation
}
}
+ const transactionStart = convertPersistedDate(connectorStatus.transactionStart)
+ if (transactionStart != null) {
+ connectorStatus.transactionStart = transactionStart
+ } else {
+ delete connectorStatus.transactionStart
+ }
+ const restoredTransactionEnergy = connectorStatus.transactionEnergyActiveImportRegisterValue
+ if (
+ restoredTransactionEnergy != null &&
+ (typeof restoredTransactionEnergy !== 'number' ||
+ !Number.isFinite(restoredTransactionEnergy) ||
+ restoredTransactionEnergy < 0)
+ ) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+ const intervalBaselines = sanitizeEnergyIntervalBaselines(
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ )
+ const physicalIntervalBaselines = {
+ ...Object.fromEntries(
+ Object.entries(intervalBaselines).filter(
+ ([key]) =>
+ key.startsWith(STATION_INTERVAL_BASELINE_PREFIX) &&
+ key.length > STATION_INTERVAL_BASELINE_PREFIX.length
+ )
+ ),
+ ...sanitizeEnergyIntervalBaselines(connectorStatus.energyActiveImportIntervalBaselines),
+ }
+ if (!isEmpty(physicalIntervalBaselines)) {
+ connectorStatus.energyActiveImportIntervalBaselines = physicalIntervalBaselines
+ } else {
+ delete connectorStatus.energyActiveImportIntervalBaselines
+ }
+ const transactionIntervalBaselines = Object.fromEntries(
+ Object.entries(intervalBaselines).filter(
+ ([key]) => !key.startsWith(STATION_INTERVAL_BASELINE_PREFIX)
+ )
+ )
+ if (!isEmpty(transactionIntervalBaselines)) {
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = transactionIntervalBaselines
+ } else {
+ delete connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ }
+ const intervalCarry = connectorStatus.transactionEnergyActiveImportIntervalCarry
+ if (intervalCarry != null) {
+ if (!isJsonObject(intervalCarry)) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = Object.fromEntries(
+ Object.entries(intervalCarry).filter(
+ (entry): entry is [string, number] =>
+ typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0
+ )
+ )
+ }
+ }
+ const transactionEnergyLastUpdatedAt = convertPersistedDate(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
+ )
+ if (transactionEnergyLastUpdatedAt != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt =
+ transactionEnergyLastUpdatedAt
+ } else {
+ delete connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
+ }
+ if (!isValidTransactionSeqNo(connectorStatus.transactionSeqNo)) {
+ delete connectorStatus.transactionSeqNo
+ }
+ if (
+ connectorStatus.transactionEventQueue != null &&
+ !Array.isArray(connectorStatus.transactionEventQueue)
+ ) {
+ delete connectorStatus.transactionEventQueue
+ connectorStatus.publicKeySentInTransaction = false
+ }
+ connectorStatus.transactionRestored =
+ restorePersistedTransaction &&
+ connectorStatus.transactionId != null &&
+ connectorStatus.transactionStarted === true
if (isNotEmptyArray(connectorStatus.chargingProfiles)) {
connectorStatus.chargingProfiles = connectorStatus.chargingProfiles
.filter(
return connectorStatus
}
+/**
+ * Validates and rehydrates a persisted OCPP 2.0 TransactionEvent queue before
+ * deriving sequence, signing, or lifecycle ownership from it.
+ * @param connectorStatus - Queue owner to finalize in place.
+ * @param validateRequest - Canonical TransactionEvent request validator; absent for non-OCPP 2.0.
+ * @param ownerEvseId - Canonical EVSE that owns the persisted queue.
+ * @param ownerConnectorId - Canonical connector that owns the persisted queue.
+ * @param numberOfPhases - Physical phase count for legacy interval migration.
+ * @param inletToOutputEfficiency - DC inlet-to-output efficiency for legacy interval migration.
+ * @returns The same connector status after queue finalization.
+ */
+export const preparePersistedTransactionEventQueue = (
+ connectorStatus: ConnectorStatus,
+ validateRequest: PersistedTransactionEventRequestValidator | undefined,
+ ownerEvseId: number,
+ ownerConnectorId: number,
+ numberOfPhases = 3,
+ inletToOutputEfficiency = 1
+): ConnectorStatus => {
+ delete connectorStatus.transactionStarting
+ delete connectorStatus.transactionEnding
+ if (validateRequest == null) {
+ delete connectorStatus.transactionEventQueue
+ connectorStatus.transactionRestored =
+ connectorStatus.transactionId != null && connectorStatus.transactionStarted === true
+ return connectorStatus
+ }
+ if (
+ connectorStatus.transactionEventQueue != null &&
+ !Array.isArray(connectorStatus.transactionEventQueue)
+ ) {
+ delete connectorStatus.transactionEventQueue
+ connectorStatus.publicKeySentInTransaction = false
+ } else if (isNotEmptyArray(connectorStatus.transactionEventQueue)) {
+ const transactionId = connectorStatus.transactionId?.toString()
+ const persistedQueue = connectorStatus.transactionEventQueue as unknown[]
+ const invalidOwnerCohorts = new Set<string>()
+ const legacyOwnerCohorts = new Set<string>()
+ const legacyOwnerAttestations = new Set<string>()
+ for (const candidate of persistedQueue) {
+ if (
+ !isJsonObject(candidate) ||
+ !isJsonObject(candidate.request) ||
+ !isJsonObject(candidate.request.transactionInfo) ||
+ typeof candidate.request.transactionInfo.transactionId !== 'string'
+ ) {
+ continue
+ }
+ const candidateTransactionId = candidate.request.transactionInfo.transactionId
+ const hasOwnerEvseId = Object.hasOwn(candidate, 'ownerEvseId')
+ const hasOwnerConnectorId = Object.hasOwn(candidate, 'ownerConnectorId')
+ if (!hasOwnerEvseId && !hasOwnerConnectorId) {
+ legacyOwnerCohorts.add(candidateTransactionId)
+ } else if (
+ !hasOwnerEvseId ||
+ !hasOwnerConnectorId ||
+ candidate.ownerEvseId !== ownerEvseId ||
+ candidate.ownerConnectorId !== ownerConnectorId
+ ) {
+ invalidOwnerCohorts.add(candidateTransactionId)
+ }
+ const requestEvse = candidate.request.evse
+ if (
+ isJsonObject(requestEvse) &&
+ (requestEvse.id !== ownerEvseId ||
+ (requestEvse.connectorId != null && requestEvse.connectorId !== ownerConnectorId))
+ ) {
+ invalidOwnerCohorts.add(candidateTransactionId)
+ }
+ if (
+ candidate.request.eventType === OCPP20TransactionEventEnumType.Started &&
+ validateRequest(candidate.request as unknown as OCPP20TransactionEventRequest) &&
+ isJsonObject(requestEvse) &&
+ requestEvse.id === ownerEvseId &&
+ (requestEvse.connectorId == null || requestEvse.connectorId === ownerConnectorId)
+ ) {
+ legacyOwnerAttestations.add(candidateTransactionId)
+ }
+ }
+ for (const legacyTransactionId of legacyOwnerCohorts) {
+ if (!legacyOwnerAttestations.has(legacyTransactionId)) {
+ invalidOwnerCohorts.add(legacyTransactionId)
+ }
+ }
+ let removedActivePublicKeyCarrier = false
+ const preparedQueue: QueuedTransactionEvent[] = []
+ const preparedEventIndexes = new Map<string, number>()
+ const duplicatePublicKeys = new Map<string, string>()
+ for (const candidate of connectorStatus.transactionEventQueue as unknown[]) {
+ const candidateTransactionId =
+ isJsonObject(candidate) &&
+ isJsonObject(candidate.request) &&
+ isJsonObject(candidate.request.transactionInfo) &&
+ typeof candidate.request.transactionInfo.transactionId === 'string'
+ ? candidate.request.transactionInfo.transactionId
+ : undefined
+ const candidatePublicKey = getPersistedCandidateRawPublicKey(candidate)
+ const queuedEvent =
+ candidateTransactionId == null || invalidOwnerCohorts.has(candidateTransactionId)
+ ? undefined
+ : prepareQueuedTransactionEvent(
+ candidate,
+ validateRequest,
+ ownerEvseId,
+ ownerConnectorId,
+ legacyOwnerCohorts.has(candidateTransactionId)
+ )
+ let replacementIndex: number | undefined
+ if (queuedEvent != null) {
+ const queuedTransactionId = queuedEvent.request.transactionInfo.transactionId
+ const eventKey = `${queuedTransactionId}\u0000${queuedEvent.seqNo.toString()}`
+ const existingIndex = preparedEventIndexes.get(eventKey)
+ if (existingIndex != null) {
+ const existingEvent = preparedQueue[existingIndex]
+ const replaceExisting =
+ queuedEvent.deliveryAttempted === true && existingEvent.deliveryAttempted !== true
+ const discardedEvent = replaceExisting ? existingEvent : queuedEvent
+ const discardedPublicKey = discardedEvent.request.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(getRawSignedMeterValuePublicKey)
+ .find(publicKey => isNotEmptyString(publicKey))
+ if (
+ queuedTransactionId === transactionId &&
+ discardedEvent.deliveryAttempted !== true &&
+ discardedPublicKey != null
+ ) {
+ removedActivePublicKeyCarrier = true
+ }
+ const retainedEvent = replaceExisting ? queuedEvent : existingEvent
+ if (
+ retainedEvent.deliveryAttempted !== true &&
+ discardedEvent.deliveryAttempted !== true &&
+ discardedPublicKey != null &&
+ !duplicatePublicKeys.has(queuedTransactionId)
+ ) {
+ duplicatePublicKeys.set(queuedTransactionId, discardedPublicKey)
+ }
+ if (!replaceExisting) continue
+ replacementIndex = existingIndex
+ } else {
+ preparedEventIndexes.set(eventKey, preparedQueue.length)
+ }
+ if (
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated &&
+ queuedEvent.transactionEnergyActiveImportIntervalConsumption == null &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId &&
+ queuedEvent.request.meterValue != null
+ ) {
+ const intervalConsumption: Record<string, number> = {}
+ for (const sampleClock of [true, false]) {
+ const baselineKey = buildConfigKey(
+ sampleClock
+ ? OCPP20ComponentName.AlignedDataCtrlr
+ : OCPP20ComponentName.SampledDataCtrlr,
+ sampleClock
+ ? OCPP20RequiredVariableName.Measurands
+ : OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+ for (const meterValue of queuedEvent.request.meterValue) {
+ const cadenceMeterValue = {
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.filter(
+ sampledValue =>
+ (sampledValue.context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK) ===
+ sampleClock
+ ),
+ }
+ if (isEmpty(cadenceMeterValue.sampledValue)) continue
+ const representedEnergyWh = getRepresentedTransactionIntervalEnergyWh(
+ cadenceMeterValue,
+ numberOfPhases,
+ inletToOutputEfficiency
+ )
+ if (representedEnergyWh > 0) {
+ intervalConsumption[baselineKey] =
+ (intervalConsumption[baselineKey] ?? 0) + representedEnergyWh
+ }
+ }
+ }
+ if (!isEmpty(intervalConsumption)) {
+ queuedEvent.transactionEnergyActiveImportIntervalConsumption = intervalConsumption
+ }
+ }
+ if (replacementIndex == null) preparedQueue.push(queuedEvent)
+ else preparedQueue[replacementIndex] = queuedEvent
+ } else if (
+ transactionId != null &&
+ candidateTransactionId === transactionId &&
+ (!isJsonObject(candidate) || candidate.deliveryAttempted !== true) &&
+ (candidatePublicKey != null || invalidOwnerCohorts.has(candidateTransactionId))
+ ) {
+ removedActivePublicKeyCarrier = true
+ }
+ }
+ const transactionsWithPublicKeys = new Set<string>()
+ const publicKeyReplacements = new Map<string, ReturnType<typeof getMutableSignedMeterValue>>()
+ for (const queuedEvent of preparedQueue) {
+ const queuedTransactionId = queuedEvent.request.transactionInfo.transactionId
+ for (const meterValue of queuedEvent.request.meterValue ?? []) {
+ for (const sampledValue of meterValue.sampledValue) {
+ const signedMeterValue = getMutableSignedMeterValue(sampledValue)
+ if (signedMeterValue == null) continue
+ if (isNotEmptyString(signedMeterValue.publicKey)) {
+ transactionsWithPublicKeys.add(queuedTransactionId)
+ } else if (
+ queuedEvent.deliveryAttempted !== true &&
+ !publicKeyReplacements.has(queuedTransactionId)
+ ) {
+ publicKeyReplacements.set(queuedTransactionId, signedMeterValue)
+ }
+ }
+ }
+ }
+ for (const [duplicateTransactionId, publicKey] of duplicatePublicKeys) {
+ if (transactionsWithPublicKeys.has(duplicateTransactionId)) continue
+ const replacement = publicKeyReplacements.get(duplicateTransactionId)
+ if (replacement != null) {
+ replacement.publicKey = publicKey
+ transactionsWithPublicKeys.add(duplicateTransactionId)
+ } else if (duplicateTransactionId === transactionId) {
+ removedActivePublicKeyCarrier = true
+ }
+ }
+ const activeTransactionMaxSeqNo =
+ transactionId != null
+ ? preparedQueue.reduce(
+ (maximumSeqNo, queuedEvent) =>
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ? Math.max(maximumSeqNo, queuedEvent.seqNo)
+ : maximumSeqNo,
+ connectorStatus.transactionSeqNo ?? -1
+ )
+ : -1
+ connectorStatus.transactionEventQueue = preparedQueue
+ const bounded = boundTransactionEventQueue(connectorStatus)
+
+ if (
+ transactionId != null &&
+ bounded.removedEvents.some(
+ queuedEvent =>
+ queuedEvent.deliveryAttempted !== true &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId &&
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ )
+ ) {
+ removedActivePublicKeyCarrier = true
+ }
+ if (activeTransactionMaxSeqNo >= 0) {
+ connectorStatus.transactionSeqNo = activeTransactionMaxSeqNo
+ }
+ if (
+ removedActivePublicKeyCarrier &&
+ connectorStatus.publicKeySentInTransaction === true &&
+ transactionId != null &&
+ preparedQueue.every(
+ queuedEvent =>
+ queuedEvent.request.transactionInfo.transactionId !== transactionId ||
+ queuedEvent.request.meterValue?.every(meterValue =>
+ meterValue.sampledValue.every(
+ sampledValue => !isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) !== false
+ )
+ ) {
+ connectorStatus.publicKeySentInTransaction = false
+ }
+ }
+ let transactionId = connectorStatus.transactionId?.toString()
+ if (transactionId != null && connectorStatus.transactionSeqNo === Number.MAX_SAFE_INTEGER) {
+ const hasTerminalEvent =
+ connectorStatus.transactionEventQueue?.some(
+ queuedEvent =>
+ queuedEvent.request.transactionInfo.transactionId === transactionId &&
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended
+ ) === true
+ if (!hasTerminalEvent) {
+ throw new BaseError(
+ `Persisted active transaction ${transactionId} cannot allocate a safe sequence number after ${Number.MAX_SAFE_INTEGER.toString()}`
+ )
+ }
+ }
+ const hasQueuedTransactionEvent =
+ transactionId != null &&
+ connectorStatus.transactionEventQueue?.some(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === transactionId
+ ) === true
+ if (
+ transactionId != null &&
+ connectorStatus.transactionStarted !== true &&
+ connectorStatus.transactionStartedExhaustedTransactionId === transactionId &&
+ !hasQueuedTransactionEvent
+ ) {
+ resetConnectorStatus(connectorStatus)
+ transactionId = undefined
+ }
+ const ownsExhaustedStartedTransaction =
+ transactionId != null &&
+ connectorStatus.transactionStartedExhaustedTransactionId === transactionId
+ if (
+ connectorStatus.transactionStartedExhaustedTransactionId != null &&
+ !ownsExhaustedStartedTransaction
+ ) {
+ delete connectorStatus.transactionStartedExhaustedTransactionId
+ }
+ const ownsQueuedStartedEvent =
+ connectorStatus.transactionStarted !== true &&
+ transactionId != null &&
+ connectorStatus.transactionEventQueue?.some(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Started &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ) === true
+ const ownsQueuedEndedEvent =
+ transactionId != null &&
+ connectorStatus.transactionEventQueue?.some(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ) === true
+ if (ownsQueuedEndedEvent) {
+ delete connectorStatus.transactionStarting
+ connectorStatus.transactionEnding = true
+ } else if (
+ connectorStatus.transactionStarted !== true &&
+ (ownsQueuedStartedEvent || ownsExhaustedStartedTransaction)
+ ) {
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ }
+ connectorStatus.transactionRestored =
+ transactionId != null &&
+ (connectorStatus.transactionStarted === true ||
+ ownsQueuedStartedEvent ||
+ ownsQueuedEndedEvent ||
+ ownsExhaustedStartedTransaction)
+ return connectorStatus
+}
+
const initializeConnectorStatus = (
connectorStatus: ConnectorStatus,
defaultMaximumPower?: number
* single error log at the end so a partial-failure batch is still
* observable.
* @param chargingStation - The charging station whose expired reservations should be cleared.
+ * @param isOperationCurrent - Optional operation-ownership guard for post-await mutation.
* @returns Resolves once every expiry sweep has settled; individual failures are logged, never rethrown.
*/
export const removeExpiredReservations = async (
- chargingStation: ChargingStation
+ chargingStation: ChargingStation,
+ isOperationCurrent?: () => boolean
): Promise<void> => {
const reservations: Reservation[] = []
for (const { connectorStatus } of chargingStation.iterateConnectors()) {
}
const results = await Promise.allSettled(
reservations.map(reservation =>
- chargingStation.removeReservation(reservation, ReservationTerminationReason.EXPIRED)
+ chargingStation.removeReservation(
+ reservation,
+ ReservationTerminationReason.EXPIRED,
+ isOperationCurrent
+ )
)
)
let failureCount = 0
--- /dev/null
+import {
+ type ConnectorStatus,
+ OCPP20LocationEnumType,
+ OCPP20MeasurandEnumType,
+ type OCPP20MeterValue,
+ OCPP20ReadingContextEnumType,
+ type OCPP20SampledValue,
+ type OCPP20SignedMeterValue,
+ OCPP20TransactionEventEnumType,
+ OCPP20UnitEnumType,
+ type QueuedTransactionEvent,
+} from '../types/index.js'
+import {
+ clone,
+ Constants,
+ isEmpty,
+ isJsonObject,
+ isNotEmptyArray,
+ isNotEmptyString,
+} from '../utils/index.js'
+import { canonicalizeCustomData } from './meter-values/index.js'
+
+export interface BoundedTransactionEventQueue {
+ bytes: number
+ changed: boolean
+ overLimit: boolean
+ removedEvents: QueuedTransactionEvent[]
+}
+
+export interface EnqueuedTransactionEventQueue extends BoundedTransactionEventQueue {
+ capacityRejected?: boolean
+ inserted: boolean
+}
+
+interface TransactionEventQueueAccounting {
+ bytes: number
+ endedEventCounts: Map<string, number>
+ eventBytes: Map<QueuedTransactionEvent, number>
+ eventKeys: Set<string>
+ first?: QueuedTransactionEvent
+ last?: QueuedTransactionEvent
+ queue: QueuedTransactionEvent[]
+ stagedEndedEventCounts: Map<string, number>
+}
+
+const TRANSACTION_EVENT_QUEUE_HEADROOM_RATIO = 0.75
+const MAX_UPDATED_TRANSACTION_EVENT_QUEUE_LENGTH = Math.floor(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH * TRANSACTION_EVENT_QUEUE_HEADROOM_RATIO
+)
+const MAX_UPDATED_TRANSACTION_EVENT_QUEUE_BYTES = Math.floor(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * TRANSACTION_EVENT_QUEUE_HEADROOM_RATIO
+)
+const inFlightTransactionEvents = new WeakMap<ConnectorStatus, QueuedTransactionEvent>()
+
+export const setTransactionEventQueueInFlight = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent
+): void => {
+ inFlightTransactionEvents.set(connectorStatus, queuedEvent)
+}
+
+export const clearTransactionEventQueueInFlight = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent
+): void => {
+ if (inFlightTransactionEvents.get(connectorStatus) === queuedEvent) {
+ inFlightTransactionEvents.delete(connectorStatus)
+ }
+}
+
+/**
+ * Clears process-local delivery ownership when a station lifecycle is sealed.
+ * @param connectorStatus - Connector state whose process-local delivery markers are cleared.
+ */
+export const resetTransactionEventQueueRuntimeState = (connectorStatus: ConnectorStatus): void => {
+ inFlightTransactionEvents.delete(connectorStatus)
+ for (const queuedEvent of connectorStatus.transactionEventQueue ?? []) {
+ blockedTransactionEventQueueEntries.delete(queuedEvent)
+ stagedTransactionEventQueueEntries.delete(queuedEvent)
+ }
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+}
+const blockedTransactionEventQueueEntries = new WeakSet<QueuedTransactionEvent>()
+const stagedTransactionEventQueueEntries = new WeakSet<QueuedTransactionEvent>()
+
+const isImmutableTransactionEvent = (
+ queuedEvent: QueuedTransactionEvent,
+ inFlightEvent?: QueuedTransactionEvent
+): boolean =>
+ queuedEvent.deliveryAttempted === true ||
+ blockedTransactionEventQueueEntries.has(queuedEvent) ||
+ stagedTransactionEventQueueEntries.has(queuedEvent) ||
+ queuedEvent === inFlightEvent
+const transactionEventQueueAccounting = new WeakMap<
+ ConnectorStatus,
+ TransactionEventQueueAccounting
+>()
+
+const getQueuedTransactionEventBytes = (queuedEvent: QueuedTransactionEvent): number =>
+ Buffer.byteLength(JSON.stringify(queuedEvent), 'utf8')
+
+const getQueuedTransactionEventKey = (transactionId: string, seqNo: number): string =>
+ `${transactionId}\u0000${seqNo.toString()}`
+
+export const getTransactionEventQueueBytes = (queue: QueuedTransactionEvent[]): number =>
+ queue.reduce(
+ (bytes, queuedEvent, index) =>
+ bytes + getQueuedTransactionEventBytes(queuedEvent) + (index === 0 ? 0 : 1),
+ 2
+ )
+
+const buildTransactionEventQueueAccounting = (
+ connectorStatus: ConnectorStatus,
+ queue: QueuedTransactionEvent[]
+): TransactionEventQueueAccounting => {
+ const endedEventCounts = new Map<string, number>()
+ const eventBytes = new Map<QueuedTransactionEvent, number>()
+ const stagedEndedEventCounts = new Map<string, number>()
+ const eventKeys = new Set<string>()
+ let bytes = 2
+ for (const [index, queuedEvent] of queue.entries()) {
+ const queuedEventBytes = getQueuedTransactionEventBytes(queuedEvent)
+ eventBytes.set(queuedEvent, queuedEventBytes)
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ eventKeys.add(getQueuedTransactionEventKey(transactionId, queuedEvent.seqNo))
+ if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ endedEventCounts.set(transactionId, (endedEventCounts.get(transactionId) ?? 0) + 1)
+ if (stagedTransactionEventQueueEntries.has(queuedEvent)) {
+ stagedEndedEventCounts.set(
+ transactionId,
+ (stagedEndedEventCounts.get(transactionId) ?? 0) + 1
+ )
+ }
+ }
+ bytes += queuedEventBytes + (index === 0 ? 0 : 1)
+ }
+ const accounting = {
+ bytes,
+ endedEventCounts,
+ eventBytes,
+ eventKeys,
+ first: queue[0],
+ last: queue.at(-1),
+ queue,
+ stagedEndedEventCounts,
+ }
+ transactionEventQueueAccounting.set(connectorStatus, accounting)
+ return accounting
+}
+
+const getTransactionEventQueueAccounting = (
+ connectorStatus: ConnectorStatus
+): TransactionEventQueueAccounting => {
+ const queue = connectorStatus.transactionEventQueue ?? []
+ const accounting = transactionEventQueueAccounting.get(connectorStatus)
+ if (
+ accounting?.queue !== queue ||
+ accounting.eventBytes.size !== queue.length ||
+ accounting.first !== queue[0] ||
+ accounting.last !== queue.at(-1)
+ ) {
+ return buildTransactionEventQueueAccounting(connectorStatus, queue)
+ }
+ return accounting
+}
+
+const refreshAccountingEdges = (accounting: TransactionEventQueueAccounting): void => {
+ accounting.first = accounting.queue[0]
+ accounting.last = accounting.queue.at(-1)
+}
+
+const refreshQueuedEventBytes = (
+ accounting: TransactionEventQueueAccounting,
+ queuedEvent: QueuedTransactionEvent
+): void => {
+ const previousBytes = accounting.eventBytes.get(queuedEvent) ?? 0
+ const currentBytes = getQueuedTransactionEventBytes(queuedEvent)
+ accounting.eventBytes.set(queuedEvent, currentBytes)
+ accounting.bytes += currentBytes - previousBytes
+}
+
+export const getMutableSignedMeterValue = (
+ sampledValue: OCPP20SampledValue
+): OCPP20SignedMeterValue | undefined => {
+ const signedMeterValue: unknown = sampledValue.signedMeterValue
+ if (
+ !isJsonObject(signedMeterValue) ||
+ typeof signedMeterValue.encodingMethod !== 'string' ||
+ typeof signedMeterValue.publicKey !== 'string' ||
+ typeof signedMeterValue.signedMeterData !== 'string' ||
+ typeof signedMeterValue.signingMethod !== 'string'
+ ) {
+ return undefined
+ }
+ const publicKeyDescriptor = Object.getOwnPropertyDescriptor(signedMeterValue, 'publicKey')
+ if (
+ publicKeyDescriptor == null ||
+ (publicKeyDescriptor.writable !== true && publicKeyDescriptor.set == null)
+ ) {
+ return undefined
+ }
+ return signedMeterValue as OCPP20SignedMeterValue
+}
+
+export const getRawSignedMeterValuePublicKey = (
+ sampledValue: OCPP20SampledValue
+): string | undefined => {
+ const signedMeterValue: unknown = sampledValue.signedMeterValue
+ return isJsonObject(signedMeterValue) && typeof signedMeterValue.publicKey === 'string'
+ ? signedMeterValue.publicKey
+ : undefined
+}
+
+export const queuedTransactionEventHasPublicKey = (
+ queuedEvent: QueuedTransactionEvent,
+ transactionId: string
+): boolean =>
+ queuedEvent.request.transactionInfo.transactionId === transactionId &&
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue => {
+ const publicKey = getMutableSignedMeterValue(sampledValue)?.publicKey
+ return isNotEmptyString(publicKey)
+ })
+ ) === true
+
+const findPublicKey = (queuedEvent: QueuedTransactionEvent): string | undefined =>
+ queuedEvent.request.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(sampledValue => getMutableSignedMeterValue(sampledValue)?.publicKey)
+ .find(publicKey => isNotEmptyString(publicKey))
+
+const findRawPublicKey = (queuedEvent: QueuedTransactionEvent): string | undefined =>
+ queuedEvent.request.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(getRawSignedMeterValuePublicKey)
+ .find(publicKey => isNotEmptyString(publicKey))
+
+const getEffectiveUnit = (sampledValue: OCPP20SampledValue): string | undefined => {
+ if (sampledValue.unitOfMeasure?.unit != null) return sampledValue.unitOfMeasure.unit
+ const measurand = sampledValue.measurand ?? OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ return measurand.startsWith('Energy.') ? OCPP20UnitEnumType.WATT_HOUR : undefined
+}
+
+const getSampledValueIdentity = (
+ sampledValue: OCPP20SampledValue,
+ includeSignedMetadata = true
+): string => {
+ const signedMeterValue = sampledValue.signedMeterValue
+ return JSON.stringify([
+ sampledValue.measurand ?? OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ sampledValue.context ?? OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ sampledValue.phase,
+ sampledValue.location ?? OCPP20LocationEnumType.Outlet,
+ getEffectiveUnit(sampledValue),
+ sampledValue.unitOfMeasure?.multiplier ?? 0,
+ canonicalizeCustomData(sampledValue.customData),
+ canonicalizeCustomData(sampledValue.unitOfMeasure?.customData),
+ !includeSignedMetadata || signedMeterValue == null
+ ? undefined
+ : [
+ signedMeterValue.encodingMethod,
+ signedMeterValue.signingMethod,
+ canonicalizeCustomData(signedMeterValue.customData),
+ isNotEmptyString(signedMeterValue.publicKey),
+ ],
+ ])
+}
+
+const hasSignedMeterValuePayload = (queuedEvent: QueuedTransactionEvent): boolean =>
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue => sampledValue.signedMeterValue != null)
+ ) === true
+
+const compactLifecycleMeterValueEndpoints = (
+ accounting: TransactionEventQueueAccounting,
+ queuedEvent: QueuedTransactionEvent
+): boolean => {
+ const meterValues = queuedEvent.request.meterValue
+ if (
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated ||
+ !isNotEmptyArray(meterValues)
+ ) {
+ return false
+ }
+
+ const meterValueEndpoints = new Map<string, { first: number; last: number }>()
+ const retainedMeterValueIndexes = new Set([0, meterValues.length - 1])
+ for (const [meterValueIndex, meterValue] of meterValues.entries()) {
+ const identities = new Set<string>()
+ for (const sampledValue of meterValue.sampledValue) {
+ if (sampledValue.signedMeterValue != null) {
+ retainedMeterValueIndexes.add(meterValueIndex)
+ } else {
+ identities.add(getSampledValueIdentity(sampledValue))
+ }
+ }
+ for (const identity of identities) {
+ const endpoints = meterValueEndpoints.get(identity)
+ if (endpoints == null) {
+ meterValueEndpoints.set(identity, { first: meterValueIndex, last: meterValueIndex })
+ } else {
+ endpoints.last = meterValueIndex
+ }
+ }
+ }
+ for (const endpoints of meterValueEndpoints.values()) {
+ retainedMeterValueIndexes.add(endpoints.first)
+ retainedMeterValueIndexes.add(endpoints.last)
+ }
+
+ const intervalTotals = new Map<string, { readonly sample: OCPP20SampledValue; total: number }>()
+ for (const meterValue of meterValues) {
+ for (const sampledValue of meterValue.sampledValue) {
+ if (
+ sampledValue.signedMeterValue != null ||
+ sampledValue.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL ||
+ typeof sampledValue.value !== 'number' ||
+ !Number.isFinite(sampledValue.value)
+ ) {
+ continue
+ }
+ const identity = getSampledValueIdentity(sampledValue, false)
+ const intervalTotal = intervalTotals.get(identity)
+ if (intervalTotal == null) {
+ intervalTotals.set(identity, { sample: sampledValue, total: sampledValue.value })
+ } else {
+ intervalTotal.total += sampledValue.value
+ }
+ }
+ }
+
+ let changed = retainedMeterValueIndexes.size !== meterValues.length
+ const retainedMeterValues = meterValues.filter((meterValue, meterValueIndex) => {
+ if (!retainedMeterValueIndexes.has(meterValueIndex)) return false
+
+ const sampleEndpoints = new Map<string, { first: number; last: number }>()
+ const retainedSampleIndexes = new Set<number>()
+ for (const [sampledValueIndex, sampledValue] of meterValue.sampledValue.entries()) {
+ if (sampledValue.signedMeterValue != null) {
+ retainedSampleIndexes.add(sampledValueIndex)
+ continue
+ }
+ const identity = getSampledValueIdentity(sampledValue)
+ const endpoints = sampleEndpoints.get(identity)
+ if (endpoints == null) {
+ sampleEndpoints.set(identity, { first: sampledValueIndex, last: sampledValueIndex })
+ } else {
+ endpoints.last = sampledValueIndex
+ }
+ }
+ for (const endpoints of sampleEndpoints.values()) {
+ retainedSampleIndexes.add(endpoints.first)
+ retainedSampleIndexes.add(endpoints.last)
+ }
+ if (isEmpty(retainedSampleIndexes) && isNotEmptyArray(meterValue.sampledValue)) {
+ retainedSampleIndexes.add(0)
+ retainedSampleIndexes.add(meterValue.sampledValue.length - 1)
+ }
+ if (retainedSampleIndexes.size !== meterValue.sampledValue.length) {
+ meterValue.sampledValue = meterValue.sampledValue.filter((_, sampledValueIndex) =>
+ retainedSampleIndexes.has(sampledValueIndex)
+ )
+ changed = true
+ }
+ return true
+ })
+
+ const retainedIntervalTotals = new Map<string, number>()
+ const retainedUnsignedIntervalSamples = new Map<string, OCPP20SampledValue>()
+ for (const meterValue of retainedMeterValues) {
+ for (const sampledValue of meterValue.sampledValue) {
+ if (
+ sampledValue.signedMeterValue != null ||
+ sampledValue.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL ||
+ typeof sampledValue.value !== 'number' ||
+ !Number.isFinite(sampledValue.value)
+ ) {
+ continue
+ }
+ const identity = getSampledValueIdentity(sampledValue, false)
+ retainedIntervalTotals.set(
+ identity,
+ (retainedIntervalTotals.get(identity) ?? 0) + sampledValue.value
+ )
+ retainedUnsignedIntervalSamples.set(identity, sampledValue)
+ }
+ }
+ for (const [identity, { sample, total }] of intervalTotals) {
+ const missingEnergy = total - (retainedIntervalTotals.get(identity) ?? 0)
+ if (missingEnergy === 0) continue
+ const retainedUnsignedSample = retainedUnsignedIntervalSamples.get(identity)
+ if (retainedUnsignedSample != null) {
+ retainedUnsignedSample.value += missingEnergy
+ changed = true
+ continue
+ }
+ const terminalMeterValue = retainedMeterValues.at(-1)
+ if (terminalMeterValue == null) continue
+ terminalMeterValue.sampledValue.push({
+ ...clone(sample),
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ value: missingEnergy,
+ })
+ changed = true
+ }
+
+ if (!changed) return false
+ meterValues.splice(0, meterValues.length, ...retainedMeterValues)
+ refreshQueuedEventBytes(accounting, queuedEvent)
+ return true
+}
+
+const compactOversizedLifecycleEvent = (
+ accounting: TransactionEventQueueAccounting,
+ queuedEvent: QueuedTransactionEvent
+): boolean =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ compactLifecycleMeterValueEndpoints(accounting, queuedEvent)
+
+const transferPublicKeys = (
+ accounting: TransactionEventQueueAccounting,
+ publicKeys: ReadonlyMap<string, string>,
+ inFlightEvent?: QueuedTransactionEvent,
+ protectedEvent?: QueuedTransactionEvent
+): void => {
+ const transactionsWithPublicKeys = new Set<string>()
+ const replacements = new Map<string, OCPP20SignedMeterValue>()
+ const replacementEvents = new Map<string, QueuedTransactionEvent>()
+ for (const queuedEvent of accounting.queue) {
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ for (const meterValue of queuedEvent.request.meterValue ?? []) {
+ for (const sampledValue of meterValue.sampledValue) {
+ const signedMeterValue = getMutableSignedMeterValue(sampledValue)
+ if (signedMeterValue == null) continue
+ if (isNotEmptyString(signedMeterValue.publicKey)) {
+ transactionsWithPublicKeys.add(transactionId)
+ } else if (
+ !isImmutableTransactionEvent(queuedEvent, inFlightEvent) &&
+ !replacements.has(transactionId)
+ ) {
+ replacements.set(transactionId, signedMeterValue)
+ replacementEvents.set(transactionId, queuedEvent)
+ }
+ }
+ }
+ }
+ for (const [transactionId, publicKey] of publicKeys) {
+ if (transactionsWithPublicKeys.has(transactionId)) continue
+ const replacement = replacements.get(transactionId)
+ if (replacement == null) continue
+ replacement.publicKey = publicKey
+ const replacementEvent = replacementEvents.get(transactionId)
+ if (replacementEvent != null) refreshQueuedEventBytes(accounting, replacementEvent)
+ }
+}
+
+const getOrCreateMeterValueAtTimestamp = (
+ queuedEvent: QueuedTransactionEvent,
+ timestamp: Date
+): OCPP20MeterValue => {
+ queuedEvent.request.meterValue ??= []
+ const existing = queuedEvent.request.meterValue.find(
+ meterValue => meterValue.timestamp.getTime() === timestamp.getTime()
+ )
+ if (existing != null) return existing
+ const meterValue: OCPP20MeterValue = { sampledValue: [], timestamp }
+ const insertionIndex = queuedEvent.request.meterValue.findIndex(
+ existingMeterValue => existingMeterValue.timestamp > timestamp
+ )
+ if (insertionIndex === -1) queuedEvent.request.meterValue.push(meterValue)
+ else queuedEvent.request.meterValue.splice(insertionIndex, 0, meterValue)
+ return meterValue
+}
+
+const transferRemovedIntervalEnergy = (
+ accounting: TransactionEventQueueAccounting,
+ connectorStatus: ConnectorStatus,
+ removedEvents: readonly QueuedTransactionEvent[],
+ inFlightEvent?: QueuedTransactionEvent,
+ protectedEvent?: QueuedTransactionEvent
+): void => {
+ const retainedEventsByTransaction = new Map<string, QueuedTransactionEvent[]>()
+ for (const queuedEvent of accounting.queue) {
+ if (isImmutableTransactionEvent(queuedEvent, inFlightEvent)) continue
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const retainedEvents = retainedEventsByTransaction.get(transactionId) ?? []
+ retainedEvents.push(queuedEvent)
+ retainedEventsByTransaction.set(transactionId, retainedEvents)
+ }
+ for (const retainedEvents of retainedEventsByTransaction.values()) {
+ retainedEvents.sort((left, right) => left.seqNo - right.seqNo)
+ }
+
+ const carryIntervalConsumption = (removedEvent: QueuedTransactionEvent): void => {
+ const transactionId = removedEvent.request.transactionInfo.transactionId
+ if (connectorStatus.transactionId?.toString() !== transactionId) return
+ const consumption = removedEvent.transactionEnergyActiveImportIntervalConsumption
+ if (consumption == null) return
+ connectorStatus.transactionEnergyActiveImportIntervalCarry ??= {}
+ for (const [key, value] of Object.entries(consumption)) {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[key] =
+ (connectorStatus.transactionEnergyActiveImportIntervalCarry[key] ?? 0) + value
+ }
+ }
+
+ for (const removedEvent of removedEvents) {
+ if (removedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated) continue
+ const transactionId = removedEvent.request.transactionInfo.transactionId
+ const retainedEvents = retainedEventsByTransaction.get(transactionId)
+ if (retainedEvents == null) {
+ carryIntervalConsumption(removedEvent)
+ continue
+ }
+ let left = 0
+ let right = retainedEvents.length
+ while (left < right) {
+ const middle = left + Math.floor((right - left) / 2)
+ if (retainedEvents[middle].seqNo <= removedEvent.seqNo) left = middle + 1
+ else right = middle
+ }
+ if (left >= retainedEvents.length) {
+ carryIntervalConsumption(removedEvent)
+ continue
+ }
+ const replacementEvent = retainedEvents[left]
+
+ let changed = false
+ for (const meterValue of removedEvent.request.meterValue ?? []) {
+ for (const sampledValue of meterValue.sampledValue) {
+ if (
+ sampledValue.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL ||
+ typeof sampledValue.value !== 'number' ||
+ !Number.isFinite(sampledValue.value)
+ ) {
+ continue
+ }
+ const targetTimestamp =
+ sampledValue.signedMeterValue == null
+ ? replacementEvent.request.timestamp
+ : meterValue.timestamp
+ const replacementMeterValue = getOrCreateMeterValueAtTimestamp(
+ replacementEvent,
+ targetTimestamp
+ )
+ const identity = getSampledValueIdentity(sampledValue)
+ const replacementSample = replacementMeterValue.sampledValue.find(
+ value => getSampledValueIdentity(value) === identity
+ )
+ if (replacementSample != null && sampledValue.signedMeterValue == null) {
+ replacementSample.value += sampledValue.value
+ } else {
+ replacementMeterValue.sampledValue.push(clone(sampledValue))
+ }
+ changed = true
+ }
+ }
+ const removedConsumption = removedEvent.transactionEnergyActiveImportIntervalConsumption
+ if (removedConsumption != null) {
+ replacementEvent.transactionEnergyActiveImportIntervalConsumption ??= {}
+ for (const [key, value] of Object.entries(removedConsumption)) {
+ replacementEvent.transactionEnergyActiveImportIntervalConsumption[key] =
+ (replacementEvent.transactionEnergyActiveImportIntervalConsumption[key] ?? 0) + value
+ }
+ changed = true
+ }
+ if (changed) refreshQueuedEventBytes(accounting, replacementEvent)
+ }
+}
+
+const rebuildAccountingAfterRemoval = (accounting: TransactionEventQueueAccounting): void => {
+ accounting.bytes = accounting.queue.reduce(
+ (bytes, queuedEvent, index) =>
+ bytes + (accounting.eventBytes.get(queuedEvent) ?? 0) + (index === 0 ? 0 : 1),
+ 2
+ )
+ refreshAccountingEdges(accounting)
+}
+
+const decrementEndedEventCounts = (
+ accounting: TransactionEventQueueAccounting,
+ queuedEvent: QueuedTransactionEvent
+): void => {
+ if (queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Ended) return
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const remainingEndedEvents = (accounting.endedEventCounts.get(transactionId) ?? 1) - 1
+ if (remainingEndedEvents === 0) {
+ accounting.endedEventCounts.delete(transactionId)
+ } else {
+ accounting.endedEventCounts.set(transactionId, remainingEndedEvents)
+ }
+ if (!stagedTransactionEventQueueEntries.has(queuedEvent)) return
+ const remainingStagedEndedEvents = (accounting.stagedEndedEventCounts.get(transactionId) ?? 1) - 1
+ if (remainingStagedEndedEvents === 0) {
+ accounting.stagedEndedEventCounts.delete(transactionId)
+ } else {
+ accounting.stagedEndedEventCounts.set(transactionId, remainingStagedEndedEvents)
+ }
+}
+
+/**
+ * Orders candidates breadth-first by interval midpoint so every selected prefix spans history.
+ * @param candidates - Chronologically ordered removal candidates.
+ * @returns Candidates in deterministic distributed-removal order.
+ */
+const distributeRemovalCandidates = <T>(candidates: readonly T[]): T[] => {
+ if (isEmpty(candidates)) return []
+ const orderedCandidates: T[] = []
+ const ranges: { end: number; start: number }[] = [{ end: candidates.length - 1, start: 0 }]
+ for (const { end, start } of ranges) {
+ const midpoint = Math.floor((start + end + 1) / 2)
+ orderedCandidates.push(candidates[midpoint])
+ if (start < midpoint) ranges.push({ end: midpoint - 1, start })
+ if (midpoint < end) ranges.push({ end, start: midpoint + 1 })
+ }
+ return orderedCandidates
+}
+
+const hasIntervalEnergyToTransfer = (queuedEvent: QueuedTransactionEvent): boolean =>
+ (queuedEvent.transactionEnergyActiveImportIntervalConsumption != null &&
+ !isEmpty(queuedEvent.transactionEnergyActiveImportIntervalConsumption)) ||
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL &&
+ typeof sampledValue.value === 'number' &&
+ Number.isFinite(sampledValue.value)
+ )
+ ) === true
+
+const retainUntransferableIntervalSources = (
+ queue: readonly QueuedTransactionEvent[],
+ candidates: readonly QueuedTransactionEvent[],
+ activeTransactionId: string | undefined,
+ inFlightEvent?: QueuedTransactionEvent
+): QueuedTransactionEvent[] => {
+ const removableCandidates = new Set(candidates)
+ const transactionsWithMutableSuccessor = new Set<string>()
+ for (let index = queue.length - 1; index >= 0; index--) {
+ const queuedEvent = queue[index]
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ if (!removableCandidates.has(queuedEvent)) {
+ if (!isImmutableTransactionEvent(queuedEvent, inFlightEvent)) {
+ transactionsWithMutableSuccessor.add(transactionId)
+ }
+ continue
+ }
+ if (
+ queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated ||
+ !hasIntervalEnergyToTransfer(queuedEvent)
+ ) {
+ continue
+ }
+ const canCarry =
+ transactionId === activeTransactionId &&
+ queuedEvent.transactionEnergyActiveImportIntervalConsumption != null
+ if (canCarry || transactionsWithMutableSuccessor.has(transactionId)) continue
+ removableCandidates.delete(queuedEvent)
+ transactionsWithMutableSuccessor.add(transactionId)
+ }
+ return candidates.filter(candidate => removableCandidates.has(candidate))
+}
+
+const getUpdatedRemovalCandidates = (
+ queue: readonly QueuedTransactionEvent[],
+ activeTransactionId: string | undefined,
+ inFlightEvent: QueuedTransactionEvent | undefined,
+ isProtected: (queuedEvent: QueuedTransactionEvent) => boolean
+): QueuedTransactionEvent[] => {
+ const updatesByTransaction = new Map<string, QueuedTransactionEvent[]>()
+ for (const queuedEvent of queue) {
+ if (queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated) continue
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const updates = updatesByTransaction.get(transactionId) ?? []
+ updates.push(queuedEvent)
+ updatesByTransaction.set(transactionId, updates)
+ }
+
+ const retainedEvents = new Set<QueuedTransactionEvent>()
+ for (const updates of updatesByTransaction.values()) {
+ if (updates.length <= 5) {
+ for (const queuedEvent of updates) retainedEvents.add(queuedEvent)
+ continue
+ }
+ const first = updates[0]
+ const last = updates.at(-1)
+ if (last == null) continue
+ retainedEvents.add(first)
+ retainedEvents.add(last)
+ const sequenceRange = last.seqNo - first.seqNo
+ for (const fraction of [0.25, 0.5, 0.75]) {
+ const targetSequence = first.seqNo + sequenceRange * fraction
+ let closest = first
+ for (const candidate of updates) {
+ if (Math.abs(candidate.seqNo - targetSequence) < Math.abs(closest.seqNo - targetSequence)) {
+ closest = candidate
+ }
+ }
+ retainedEvents.add(closest)
+ }
+ }
+ const candidates = queue.filter(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated &&
+ !retainedEvents.has(queuedEvent) &&
+ !isProtected(queuedEvent)
+ )
+ const transferableCandidates = retainUntransferableIntervalSources(
+ queue,
+ distributeRemovalCandidates(candidates),
+ activeTransactionId,
+ inFlightEvent
+ )
+ return transferableCandidates
+}
+
+const getDistributedUpdatedRemovalCandidates = (
+ queue: readonly QueuedTransactionEvent[],
+ activeTransactionId: string | undefined,
+ inFlightEvent: QueuedTransactionEvent | undefined,
+ isProtected: (queuedEvent: QueuedTransactionEvent) => boolean
+): QueuedTransactionEvent[] => {
+ const updatesByTransaction = new Map<string, QueuedTransactionEvent[]>()
+ let latestActiveUpdate: QueuedTransactionEvent | undefined
+ for (const queuedEvent of queue) {
+ if (queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated) continue
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const updates = updatesByTransaction.get(transactionId) ?? []
+ updates.push(queuedEvent)
+ updatesByTransaction.set(transactionId, updates)
+ if (transactionId === activeTransactionId) latestActiveUpdate = queuedEvent
+ }
+
+ const endpointEvents = new Set<QueuedTransactionEvent>()
+ for (const updates of updatesByTransaction.values()) {
+ endpointEvents.add(updates[0])
+ endpointEvents.add(updates.at(-1) ?? updates[0])
+ }
+ const interiorCandidates: QueuedTransactionEvent[] = []
+ const endpointCandidates: QueuedTransactionEvent[] = []
+ for (const queuedEvent of queue) {
+ if (
+ queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated ||
+ queuedEvent === latestActiveUpdate ||
+ isProtected(queuedEvent)
+ ) {
+ continue
+ }
+ if (endpointEvents.has(queuedEvent)) endpointCandidates.push(queuedEvent)
+ else interiorCandidates.push(queuedEvent)
+ }
+ const transferableCandidates = retainUntransferableIntervalSources(
+ queue,
+ [
+ ...distributeRemovalCandidates(interiorCandidates),
+ ...distributeRemovalCandidates(endpointCandidates),
+ ],
+ activeTransactionId,
+ inFlightEvent
+ )
+ return transferableCandidates
+}
+
+/**
+ * Compacts a durable TransactionEvent queue toward its configured targets.
+ * Only Updated events and intermediate meter data from Ended events are removed.
+ * Started/Ended cores and all other records remain queued even when
+ * those mandatory records alone exceed a target.
+ * @param connectorStatus - Connector whose durable queue is compacted in place.
+ * @param protectedEvent - Newly queued event, retained while older candidates exist.
+ * @param maxLength - Absolute queue event-count limit
+ * @param maxBytes - Absolute serialized queue byte limit
+ * @param targetLength - Preferred event count after compaction
+ * @param targetBytes - Preferred serialized byte count after compaction
+ * @returns Exact cached serialized bytes, over-limit state, and queue mutations.
+ */
+const boundTransactionEventQueueToLimits = (
+ connectorStatus: ConnectorStatus,
+ protectedEvent: QueuedTransactionEvent | undefined,
+ maxLength: number,
+ maxBytes: number,
+ targetLength: number,
+ targetBytes: number
+): BoundedTransactionEventQueue => {
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ const { queue } = accounting
+ const inFlightEvent = inFlightTransactionEvents.get(connectorStatus)
+ const isProtected = (candidate: QueuedTransactionEvent): boolean =>
+ candidate === protectedEvent ||
+ isImmutableTransactionEvent(candidate, inFlightEvent) ||
+ hasSignedMeterValuePayload(candidate)
+ const removedEvents: QueuedTransactionEvent[] = []
+ const removedPublicKeyTransactionIds = new Set<string>()
+ let changed = false
+
+ const isOverLimit = (): boolean => queue.length > maxLength || accounting.bytes > maxBytes
+ const compactLifecycleEvent = (queuedEvent: QueuedTransactionEvent): boolean => {
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const hadPublicKey = findRawPublicKey(queuedEvent) != null
+ const eventChanged = compactOversizedLifecycleEvent(accounting, queuedEvent)
+ if (hadPublicKey && !queuedTransactionEventHasPublicKey(queuedEvent, transactionId)) {
+ removedPublicKeyTransactionIds.add(transactionId)
+ }
+ return eventChanged
+ }
+ if (!isOverLimit()) {
+ return { bytes: accounting.bytes, changed, overLimit: false, removedEvents }
+ }
+
+ for (const queuedEvent of queue) {
+ if (!isOverLimit()) break
+ if (
+ isImmutableTransactionEvent(queuedEvent, inFlightEvent) ||
+ !compactLifecycleEvent(queuedEvent)
+ ) {
+ continue
+ }
+ changed = true
+ }
+
+ const remove = (
+ candidates: readonly QueuedTransactionEvent[],
+ transferWithinTransactions = true
+ ): void => {
+ if (!isNotEmptyArray(candidates)) return
+ const candidateSet = new Set(candidates)
+ const publicKeys = new Map<string, string>()
+ for (const candidate of candidates) {
+ const transactionId = candidate.request.transactionInfo.transactionId
+ const publicKey = findPublicKey(candidate)
+ if (publicKey != null) {
+ publicKeys.set(transactionId, publicKeys.get(transactionId) ?? publicKey)
+ }
+ if (findRawPublicKey(candidate) != null) {
+ removedPublicKeyTransactionIds.add(transactionId)
+ }
+ removedEvents.push(candidate)
+ accounting.eventBytes.delete(candidate)
+ accounting.eventKeys.delete(getQueuedTransactionEventKey(transactionId, candidate.seqNo))
+ if (candidate.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ const remainingEndedEvents = (accounting.endedEventCounts.get(transactionId) ?? 1) - 1
+ if (remainingEndedEvents === 0) {
+ accounting.endedEventCounts.delete(transactionId)
+ } else {
+ accounting.endedEventCounts.set(transactionId, remainingEndedEvents)
+ }
+ if (stagedTransactionEventQueueEntries.has(candidate)) {
+ const remainingStagedEndedEvents =
+ (accounting.stagedEndedEventCounts.get(transactionId) ?? 1) - 1
+ if (remainingStagedEndedEvents === 0) {
+ accounting.stagedEndedEventCounts.delete(transactionId)
+ } else {
+ accounting.stagedEndedEventCounts.set(transactionId, remainingStagedEndedEvents)
+ }
+ }
+ }
+ }
+ queue.splice(0, queue.length, ...queue.filter(candidate => !candidateSet.has(candidate)))
+ if (transferWithinTransactions) {
+ transferRemovedIntervalEnergy(
+ accounting,
+ connectorStatus,
+ candidates,
+ inFlightEvent,
+ protectedEvent
+ )
+ }
+ rebuildAccountingAfterRemoval(accounting)
+ if (transferWithinTransactions) {
+ transferPublicKeys(accounting, publicKeys, inFlightEvent, protectedEvent)
+ }
+ changed = true
+ }
+
+ const selectUntilTarget = (
+ candidates: readonly QueuedTransactionEvent[],
+ targetLength: number,
+ targetBytes: number
+ ): QueuedTransactionEvent[] => {
+ const selected: QueuedTransactionEvent[] = []
+ let projectedLength = queue.length
+ let projectedBytes = accounting.bytes
+ for (const candidate of candidates) {
+ if (projectedLength <= targetLength && projectedBytes <= targetBytes) break
+ selected.push(candidate)
+ projectedBytes -= (accounting.eventBytes.get(candidate) ?? 0) + (projectedLength > 1 ? 1 : 0)
+ projectedLength--
+ }
+ return selected
+ }
+
+ const activeTransactionId = connectorStatus.transactionId?.toString()
+ if (isOverLimit()) {
+ remove(
+ selectUntilTarget(
+ getUpdatedRemovalCandidates(queue, activeTransactionId, inFlightEvent, isProtected),
+ targetLength,
+ targetBytes
+ )
+ )
+ }
+
+ if (isOverLimit()) {
+ remove(
+ selectUntilTarget(
+ getDistributedUpdatedRemovalCandidates(
+ queue,
+ activeTransactionId,
+ inFlightEvent,
+ isProtected
+ ),
+ targetLength,
+ targetBytes
+ )
+ )
+ }
+
+ if (accounting.bytes > maxBytes) {
+ for (const queuedEvent of queue) {
+ if (accounting.bytes <= maxBytes) break
+ if (
+ isImmutableTransactionEvent(queuedEvent, inFlightEvent) ||
+ !compactLifecycleEvent(queuedEvent)
+ ) {
+ continue
+ }
+ changed = true
+ }
+ }
+
+ if (
+ activeTransactionId != null &&
+ removedPublicKeyTransactionIds.has(activeTransactionId) &&
+ !queue.some(
+ queuedEvent =>
+ queuedEvent.request.transactionInfo.transactionId === activeTransactionId &&
+ findRawPublicKey(queuedEvent) != null
+ )
+ ) {
+ connectorStatus.publicKeySentInTransaction = false
+ }
+
+ return {
+ bytes: accounting.bytes,
+ changed,
+ overLimit: isOverLimit(),
+ removedEvents,
+ }
+}
+
+export const boundTransactionEventQueue = (
+ connectorStatus: ConnectorStatus,
+ protectedEvent?: QueuedTransactionEvent
+): BoundedTransactionEventQueue =>
+ boundTransactionEventQueueToLimits(
+ connectorStatus,
+ protectedEvent,
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH,
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES,
+ MAX_UPDATED_TRANSACTION_EVENT_QUEUE_LENGTH,
+ MAX_UPDATED_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+
+/**
+ * Inserts one event in transaction sequence order and applies permitted queue compaction.
+ * Cached byte and identity accounting makes the common append path O(1).
+ * @param connectorStatus - Queue owner.
+ * @param queuedEvent - Event to insert.
+ * @returns Insertion and bounding result.
+ */
+export const enqueueBoundedTransactionEvent = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent
+): EnqueuedTransactionEventQueue => {
+ const hadQueue = connectorStatus.transactionEventQueue != null
+ const isLifecycleEvent = queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Updated
+ connectorStatus.transactionEventQueue ??= []
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ const { queue } = accounting
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const isUpdatedEvent = queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated
+ const maxLength = isUpdatedEvent
+ ? MAX_UPDATED_TRANSACTION_EVENT_QUEUE_LENGTH
+ : Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH
+ const maxBytes = isUpdatedEvent
+ ? MAX_UPDATED_TRANSACTION_EVENT_QUEUE_BYTES
+ : Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ const targetLength = isUpdatedEvent ? maxLength : MAX_UPDATED_TRANSACTION_EVENT_QUEUE_LENGTH
+ const targetBytes = isUpdatedEvent ? maxBytes : MAX_UPDATED_TRANSACTION_EVENT_QUEUE_BYTES
+ const eventKey = getQueuedTransactionEventKey(transactionId, queuedEvent.seqNo)
+ if (accounting.eventKeys.has(eventKey)) {
+ const bounded = boundTransactionEventQueue(connectorStatus)
+ return { ...bounded, inserted: false }
+ }
+
+ let insertionIndex = queue.length
+ const lastEvent = queue.at(-1)
+ if (
+ lastEvent != null &&
+ (lastEvent.request.transactionInfo.transactionId !== transactionId ||
+ lastEvent.seqNo >= queuedEvent.seqNo)
+ ) {
+ for (let index = queue.length - 1; index >= 0; index--) {
+ const existingEvent = queue[index]
+ if (existingEvent.request.transactionInfo.transactionId !== transactionId) continue
+ insertionIndex = existingEvent.seqNo < queuedEvent.seqNo ? index + 1 : index
+ if (existingEvent.seqNo < queuedEvent.seqNo) break
+ }
+ }
+
+ const queuedEventWithMetadata =
+ queuedEvent.deliveryAttempted == null
+ ? { ...queuedEvent, deliveryAttempted: false }
+ : queuedEvent
+ const queuedEventBytes = getQueuedTransactionEventBytes(queuedEventWithMetadata)
+ const candidateExceedsByteLimit = queuedEventBytes + 2 > maxBytes
+ const projectedBytes = accounting.bytes + queuedEventBytes + (isEmpty(queue) ? 0 : 1)
+ let rollbackQueueSnapshot:
+ readonly { queuedEvent: QueuedTransactionEvent; value: QueuedTransactionEvent }[] | undefined
+ let rollbackIntervalCarry: Record<string, number> | undefined
+ if (queue.length + 1 > maxLength || projectedBytes > maxBytes) {
+ const simulatedQueue = queue.map(event => clone(event))
+ const simulatedQueuedEvent = clone(queuedEventWithMetadata)
+ simulatedQueue.splice(insertionIndex, 0, simulatedQueuedEvent)
+ const simulatedConnectorStatus = {
+ ...connectorStatus,
+ ...(connectorStatus.transactionEnergyActiveImportIntervalCarry != null && {
+ transactionEnergyActiveImportIntervalCarry: {
+ ...connectorStatus.transactionEnergyActiveImportIntervalCarry,
+ },
+ }),
+ transactionEventQueue: simulatedQueue,
+ }
+ const simulatedEventBytes = new Map<QueuedTransactionEvent, number>()
+ for (const [index, simulatedEvent] of simulatedQueue.entries()) {
+ if (index === insertionIndex) {
+ simulatedEventBytes.set(simulatedEvent, queuedEventBytes)
+ } else {
+ const sourceIndex = index < insertionIndex ? index : index - 1
+ simulatedEventBytes.set(simulatedEvent, accounting.eventBytes.get(queue[sourceIndex]) ?? 0)
+ }
+ }
+ const simulatedEndedEventCounts = new Map(accounting.endedEventCounts)
+ if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ simulatedEndedEventCounts.set(
+ transactionId,
+ (simulatedEndedEventCounts.get(transactionId) ?? 0) + 1
+ )
+ }
+ transactionEventQueueAccounting.set(simulatedConnectorStatus, {
+ bytes: projectedBytes,
+ endedEventCounts: simulatedEndedEventCounts,
+ eventBytes: simulatedEventBytes,
+ eventKeys: new Set([...accounting.eventKeys, eventKey]),
+ first: simulatedQueue[0],
+ last: simulatedQueue.at(-1),
+ queue: simulatedQueue,
+ stagedEndedEventCounts: new Map(accounting.stagedEndedEventCounts),
+ })
+ const inFlightEvent = inFlightTransactionEvents.get(connectorStatus)
+ if (inFlightEvent != null) {
+ const inFlightIndex = queue.indexOf(inFlightEvent)
+ if (inFlightIndex >= 0) {
+ const simulatedIndex = inFlightIndex < insertionIndex ? inFlightIndex : inFlightIndex + 1
+ inFlightTransactionEvents.set(simulatedConnectorStatus, simulatedQueue[simulatedIndex])
+ }
+ }
+ for (const [index, event] of queue.entries()) {
+ if (!stagedTransactionEventQueueEntries.has(event)) continue
+ const simulatedIndex = index < insertionIndex ? index : index + 1
+ stagedTransactionEventQueueEntries.add(simulatedQueue[simulatedIndex])
+ }
+ const simulatedBound = boundTransactionEventQueueToLimits(
+ simulatedConnectorStatus,
+ simulatedQueuedEvent,
+ maxLength,
+ maxBytes,
+ targetLength,
+ targetBytes
+ )
+ if (simulatedBound.overLimit && (!isLifecycleEvent || candidateExceedsByteLimit)) {
+ if (!hadQueue) {
+ delete connectorStatus.transactionEventQueue
+ transactionEventQueueAccounting.delete(connectorStatus)
+ }
+ return {
+ bytes: accounting.bytes,
+ capacityRejected: true,
+ changed: false,
+ inserted: false,
+ overLimit:
+ queue.length > Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH ||
+ accounting.bytes > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES,
+ removedEvents: [],
+ }
+ }
+ // Rollback state is consumed only when the real insertion is rejected
+ // below; for a lifecycle core that requires the candidate to exceed the
+ // byte cap on its own. Skip the full deep clone otherwise so the saturated
+ // append path stays allocation-light.
+ if (!isLifecycleEvent || candidateExceedsByteLimit) {
+ rollbackQueueSnapshot = queue.map(existingEvent => ({
+ queuedEvent: existingEvent,
+ value: clone(existingEvent),
+ }))
+ rollbackIntervalCarry =
+ connectorStatus.transactionEnergyActiveImportIntervalCarry == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalCarry }
+ }
+ }
+ queuedEvent.deliveryAttempted ??= false
+ queue.splice(insertionIndex, 0, queuedEvent)
+ accounting.eventBytes.set(queuedEvent, queuedEventBytes)
+ accounting.eventKeys.add(eventKey)
+ if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ accounting.endedEventCounts.set(
+ transactionId,
+ (accounting.endedEventCounts.get(transactionId) ?? 0) + 1
+ )
+ }
+ accounting.bytes += queuedEventBytes + (queue.length === 1 ? 0 : 1)
+ refreshAccountingEdges(accounting)
+ const bounded = boundTransactionEventQueueToLimits(
+ connectorStatus,
+ queuedEvent,
+ maxLength,
+ maxBytes,
+ targetLength,
+ targetBytes
+ )
+ if (bounded.overLimit && (!isLifecycleEvent || candidateExceedsByteLimit)) {
+ if (rollbackQueueSnapshot != null) {
+ for (const { queuedEvent: existingEvent, value } of rollbackQueueSnapshot) {
+ for (const key of Object.keys(existingEvent)) Reflect.deleteProperty(existingEvent, key)
+ Object.assign(existingEvent, value)
+ }
+ queue.splice(
+ 0,
+ queue.length,
+ ...rollbackQueueSnapshot.map(({ queuedEvent: existingEvent }) => existingEvent)
+ )
+ if (rollbackIntervalCarry == null) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = rollbackIntervalCarry
+ }
+ } else {
+ const candidateIndex = queue.indexOf(queuedEvent)
+ if (candidateIndex >= 0) queue.splice(candidateIndex, 1)
+ }
+ const restoredAccounting = buildTransactionEventQueueAccounting(connectorStatus, queue)
+ if (!hadQueue && isEmpty(queue)) {
+ delete connectorStatus.transactionEventQueue
+ transactionEventQueueAccounting.delete(connectorStatus)
+ }
+ return {
+ bytes: restoredAccounting.bytes,
+ capacityRejected: true,
+ changed: false,
+ inserted: false,
+ overLimit:
+ queue.length > Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH ||
+ restoredAccounting.bytes > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES,
+ removedEvents: [],
+ }
+ }
+ return { ...bounded, changed: true, inserted: true }
+}
+
+/**
+ * Returns whether a transaction has a queued Ended lifecycle event not staged for direct delivery.
+ * @param connectorStatus - Queue owner.
+ * @param transactionId - Transaction whose lifecycle state is queried.
+ * @returns Whether the transaction has an Ended event in the queue.
+ */
+export const hasQueuedEndedTransactionEvent = (
+ connectorStatus: ConnectorStatus,
+ transactionId: string
+): boolean => {
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ return (
+ (accounting.endedEventCounts.get(transactionId) ?? 0) >
+ (accounting.stagedEndedEventCounts.get(transactionId) ?? 0)
+ )
+}
+
+/**
+ * Marks or clears a durable queue entry that is owned by a pending direct delivery.
+ * Staged entries remain persisted but are excluded from queued lifecycle and replay decisions.
+ * @param connectorStatus - Queue owner
+ * @param queuedEvent - Durable queue entry owned by the direct delivery
+ * @param staged - Whether the entry is currently staged
+ */
+export const setTransactionEventQueueStaged = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent,
+ staged: boolean
+): void => {
+ const wasStaged = stagedTransactionEventQueueEntries.has(queuedEvent)
+ if (wasStaged === staged) return
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ const isQueuedEnded =
+ accounting.eventBytes.has(queuedEvent) &&
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended
+ if (staged) {
+ stagedTransactionEventQueueEntries.add(queuedEvent)
+ } else {
+ stagedTransactionEventQueueEntries.delete(queuedEvent)
+ }
+ if (!isQueuedEnded) return
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const stagedCount =
+ (accounting.stagedEndedEventCounts.get(transactionId) ?? 0) + (staged ? 1 : -1)
+ if (stagedCount > 0) {
+ accounting.stagedEndedEventCounts.set(transactionId, stagedCount)
+ } else {
+ accounting.stagedEndedEventCounts.delete(transactionId)
+ }
+}
+
+/**
+ * Marks whether an unattempted event is waiting for a predecessor delivery outcome.
+ * @param queuedEvent - Queue entry whose runtime gate changes.
+ * @param blocked - Whether replay must wait.
+ */
+export const setTransactionEventQueueBlocked = (
+ queuedEvent: QueuedTransactionEvent,
+ blocked: boolean
+): void => {
+ if (blocked) {
+ blockedTransactionEventQueueEntries.add(queuedEvent)
+ } else {
+ blockedTransactionEventQueueEntries.delete(queuedEvent)
+ }
+}
+
+/**
+ * Returns whether an event must wait for a predecessor delivery outcome.
+ * @param queuedEvent - Queue entry to inspect.
+ * @returns Whether replay must wait.
+ */
+export const isTransactionEventQueueBlocked = (queuedEvent: QueuedTransactionEvent): boolean =>
+ blockedTransactionEventQueueEntries.has(queuedEvent)
+
+/**
+ * Returns whether a durable queue entry is temporarily owned by a direct delivery.
+ * @param queuedEvent - Queue entry to inspect
+ * @returns Whether the direct delivery owns the entry
+ */
+export const isTransactionEventQueueStaged = (queuedEvent: QueuedTransactionEvent): boolean =>
+ stagedTransactionEventQueueEntries.has(queuedEvent)
+
+/**
+ * Invalidates cached accounting after an externally mutated queued event payload.
+ * @param connectorStatus - Queue owner whose payload was mutated externally.
+ */
+export const invalidateTransactionEventQueueAccounting = (
+ connectorStatus: ConnectorStatus
+): void => {
+ transactionEventQueueAccounting.delete(connectorStatus)
+}
+
+/**
+ * Marks an exact queued request for durable write-ahead before a transport attempt.
+ * @param connectorStatus - Queue owner
+ * @param queuedEvent - Exact event whose request is about to be sent
+ * @returns Whether the marker changed and therefore requires persistence
+ */
+export const markTransactionEventQueueDeliveryAttempted = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent
+): boolean => {
+ if (queuedEvent.deliveryAttempted === true) return false
+ queuedEvent.deliveryAttempted = true
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ if (accounting.eventBytes.has(queuedEvent)) refreshQueuedEventBytes(accounting, queuedEvent)
+ return true
+}
+
+/**
+ * Restores rejected interval consumption directly to the active transaction carry.
+ * This path deliberately does not mutate any queued successor after capacity validation.
+ * @param connectorStatus - Queue owner and active transaction state.
+ * @param discardedEvent - Updated event rejected before durable admission.
+ */
+export const restoreRejectedTransactionEventIntervalCarry = (
+ connectorStatus: ConnectorStatus,
+ discardedEvent: QueuedTransactionEvent
+): void => {
+ if (
+ connectorStatus.transactionId?.toString() !==
+ discardedEvent.request.transactionInfo.transactionId
+ ) {
+ return
+ }
+ const consumption = discardedEvent.transactionEnergyActiveImportIntervalConsumption
+ if (consumption == null) return
+ connectorStatus.transactionEnergyActiveImportIntervalCarry ??= {}
+ for (const [baselineKey, energyWh] of Object.entries(consumption)) {
+ if (typeof energyWh !== 'number' || !Number.isFinite(energyWh) || energyWh <= 0) continue
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] =
+ (connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] ?? 0) + energyWh
+ }
+}
+
+/**
+ * Preserves interval energy from a discarded TransactionEvent by transferring it
+ * to the next event of the same transaction or to the active transaction carry.
+ * @param connectorStatus - Queue owner and active transaction state
+ * @param discardedEvent - Event being discarded outside the bounded queue
+ */
+export const transferDiscardedTransactionEventIntervalEnergy = (
+ connectorStatus: ConnectorStatus,
+ discardedEvent: QueuedTransactionEvent
+): void => {
+ transferRemovedIntervalEnergy(
+ getTransactionEventQueueAccounting(connectorStatus),
+ connectorStatus,
+ [discardedEvent],
+ inFlightTransactionEvents.get(connectorStatus)
+ )
+}
+
+/**
+ * Removes one exact queued event while keeping queue accounting exact.
+ * @param connectorStatus - Queue owner.
+ * @param queuedEvent - Exact event to remove.
+ * @returns Whether the event was present and removed.
+ */
+export const removeBoundedTransactionEvent = (
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent
+): boolean => {
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ const eventIndex = accounting.queue.indexOf(queuedEvent)
+ if (eventIndex < 0) return false
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ accounting.queue.splice(eventIndex, 1)
+ accounting.eventBytes.delete(queuedEvent)
+ accounting.eventKeys.delete(getQueuedTransactionEventKey(transactionId, queuedEvent.seqNo))
+ decrementEndedEventCounts(accounting, queuedEvent)
+ rebuildAccountingAfterRemoval(accounting)
+ return true
+}
+
+/**
+ * Removes and returns the oldest queued event while keeping byte accounting exact.
+ * @param connectorStatus - Queue owner.
+ * @param preserveIntervalEnergy - Whether unsigned interval energy is carried into the next event.
+ * @returns The removed oldest event, or undefined when the queue is empty.
+ */
+export const shiftBoundedTransactionEvent = (
+ connectorStatus: ConnectorStatus,
+ preserveIntervalEnergy = false
+): QueuedTransactionEvent | undefined => {
+ const accounting = getTransactionEventQueueAccounting(connectorStatus)
+ const queuedEvent = accounting.queue.at(0)
+ if (queuedEvent == null) return
+ if (preserveIntervalEnergy) {
+ transferRemovedIntervalEnergy(
+ accounting,
+ connectorStatus,
+ [queuedEvent],
+ inFlightTransactionEvents.get(connectorStatus)
+ )
+ }
+ accounting.queue.shift()
+ accounting.bytes -= accounting.eventBytes.get(queuedEvent) ?? 0
+ if (isNotEmptyArray(accounting.queue)) accounting.bytes--
+ accounting.eventBytes.delete(queuedEvent)
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ accounting.eventKeys.delete(getQueuedTransactionEventKey(transactionId, queuedEvent.seqNo))
+ decrementEndedEventCounts(accounting, queuedEvent)
+ refreshAccountingEdges(accounting)
+ return queuedEvent
+}
type OCPP16MeterValue,
OCPP20AuthorizationStatusEnumType,
type OCPP20AuthorizeResponse,
+ OCPP20ComponentName,
type OCPP20Get15118EVCertificateResponse,
type OCPP20GetCertificateStatusResponse,
+ type OCPP20MeterValue,
+ OCPP20ReadingContextEnumType,
+ OCPP20RequiredVariableName,
type OCPP20SignCertificateResponse,
type OCPP20TransactionEventResponse,
OCPPVersion,
isOCPP20x,
logger,
} from '../../utils/index.js'
-import { getConfigurationKey } from '../ConfigurationKeyUtils.js'
+import { buildConfigKey, getConfigurationKey } from '../ConfigurationKeyUtils.js'
+import {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ restoreTransactionIntervalState,
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+} from '../meter-values/index.js'
import {
buildMeterValue,
+ claimPublicKeyDelivery,
+ getOCPP16SignedMeterValuePublicKey,
OCPP16ServiceUtils,
OCPP20ServiceUtils,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
sendAndSetConnectorStatus,
} from '../ocpp/index.js'
+import { getRawSignedMeterValuePublicKey } from '../TransactionEventQueueUtils.js'
import { WorkerBroadcastChannel } from './WorkerBroadcastChannel.js'
const moduleName = 'ChargingStationWorkerBroadcastChannel'
this.chargingStation.start()
},
],
- [
- BroadcastChannelProcedureName.START_TRANSACTION,
- this.passthrough(RequestCommand.START_TRANSACTION),
- ],
+ [BroadcastChannelProcedureName.START_TRANSACTION, this.handleStartTransaction.bind(this)],
[BroadcastChannelProcedureName.STATUS_NOTIFICATION, this.handleStatusNotification.bind(this)],
[
BroadcastChannelProcedureName.STOP_AUTOMATIC_TRANSACTION_GENERATOR,
requestPayload?: BroadcastChannelRequestPayload
): Promise<MeterValuesResponse> {
const payloadEvseId = (requestPayload as undefined | { evseId?: number })?.evseId
- const connectorId =
- requestPayload?.connectorId ??
- (payloadEvseId != null
- ? this.chargingStation.getConnectorIdByEvseId(payloadEvseId)
- : undefined)
+ let connectorId = requestPayload?.connectorId
+ if (connectorId == null && payloadEvseId != null) {
+ const evseStatus = this.chargingStation.getEvseStatus(payloadEvseId)
+ for (const [candidateConnectorId, connectorStatus] of evseStatus?.connectors ?? []) {
+ if (connectorStatus.transactionStarted === true && connectorStatus.transactionId != null) {
+ connectorId = candidateConnectorId
+ break
+ }
+ }
+ connectorId ??= this.chargingStation.getConnectorIdByEvseId(payloadEvseId)
+ }
if (connectorId == null) {
throw new BaseError(
`${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Missing connectorId or evseId in request payload`
)
}
- const transactionId = this.chargingStation.getConnectorStatus(connectorId)?.transactionId
const isOcpp2 = isOCPP20x(this.chargingStation.stationInfo?.ocppVersion)
+ const evseId = (() => {
+ if (!isOcpp2) return payloadEvseId
+ if (payloadEvseId != null) {
+ if (this.chargingStation.getConnectorStatus(connectorId, payloadEvseId) == null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Connector ${connectorId.toString()} does not exist on EVSE ${payloadEvseId.toString()}`
+ )
+ }
+ return payloadEvseId
+ }
+ let resolvedEvseId: number | undefined
+ for (const candidate of this.chargingStation.iterateConnectors()) {
+ if (candidate.connectorId !== connectorId || candidate.evseId == null) continue
+ if (resolvedEvseId != null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: 'evseId' field is required because connector ${connectorId.toString()} exists on multiple EVSEs`
+ )
+ }
+ resolvedEvseId = candidate.evseId
+ }
+ if (resolvedEvseId == null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Connector ${connectorId.toString()} does not exist on any EVSE`
+ )
+ }
+ return resolvedEvseId
+ })()
+ const connectorStatus = this.chargingStation.getConnectorStatus(connectorId, evseId)
+ const transactionId = connectorStatus?.transactionId
const interval = isOcpp2
- ? OCPP20ServiceUtils.getAlignedDataInterval(this.chargingStation)
+ ? OCPP20ServiceUtils.getTxUpdatedInterval(this.chargingStation)
: (() => {
const key = getConfigurationKey(
this.chargingStation,
? secondsToMilliseconds(convertToInt(key.value))
: Constants.DEFAULT_METER_VALUES_INTERVAL_MS
})()
- const meterValue = buildMeterValue(this.chargingStation, transactionId, interval)
- // OCPP 1.6 Signed Meter Values whitepaper §3.3.6: mirror the periodic
- // loop (`OCPP16ServiceUtils.startUpdatedMeterValues`) and append a paired
- // SignedData SampledValue when signing is enabled for the connector.
- // Guarded on `!isOcpp2` because OCPP 2.0.x signing is applied inline
- // inside `buildMeterValue` (via the versioned dispatcher's signing hook);
- // post-hoc wrapping is the 1.6 pattern only. Guarded on `transactionId
- // != null` because signing a MeterValue outside an active transaction
- // has no defined semantics in the whitepaper.
- if (!isOcpp2 && transactionId != null) {
- OCPP16ServiceUtils.appendSignedUpdatedReadings(
- this.chargingStation,
- connectorId,
- convertToInt(transactionId),
- meterValue as OCPP16MeterValue
+ const requestedMeterValuesPayload: unknown = (
+ requestPayload as undefined | { meterValue?: unknown }
+ )?.meterValue
+ if (requestedMeterValuesPayload != null && !Array.isArray(requestedMeterValuesPayload)) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: meterValue must be an array`
)
}
- return await this.chargingStation.ocppRequestService.requestHandler<
- MeterValuesRequest,
- MeterValuesResponse
- >(
+ const requestedMeterValues = requestedMeterValuesPayload as
+ OCPP16MeterValue[] | OCPP20MeterValue[] | undefined
+ if (
+ requestedMeterValues?.some(
+ meterValue =>
+ !Array.isArray(
+ (meterValue as null | undefined | { sampledValue?: unknown })?.sampledValue
+ )
+ ) === true
+ ) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: meterValue.sampledValue must be an array`
+ )
+ }
+ const requestedPublicKeyIncluded =
+ requestedMeterValues?.some(meterValue =>
+ isOcpp2
+ ? (meterValue as OCPP20MeterValue).sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ : (meterValue as OCPP16MeterValue).sampledValue.some(sampledValue =>
+ isNotEmptyString(getOCPP16SignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ if (requestedMeterValues == null && connectorStatus?.transactionEnding === true) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Transaction is ending`
+ )
+ }
+ let transactionDelivery: TransactionMeterValueDelivery | undefined
+ if (
+ (requestedMeterValues == null || requestedPublicKeyIncluded) &&
+ connectorStatus != null &&
+ transactionId != null
+ ) {
+ transactionDelivery = TransactionMeterValueDeliveryBarrier.beginIfIdle(
+ connectorStatus,
+ transactionId
+ )
+ if (transactionDelivery == null) {
+ const currentConnectorStatus = this.chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ currentConnectorStatus === connectorStatus &&
+ currentConnectorStatus.transactionStarted === true &&
+ currentConnectorStatus.transactionEnding !== true &&
+ currentConnectorStatus.transactionId?.toString() === transactionId.toString()
+ ) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: MeterValues delivery is already in progress for this transaction`
+ )
+ }
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Transaction is no longer active`
+ )
+ }
+ const deliveryTurn = transactionDelivery.waitForTurn()
+ if (deliveryTurn != null) await deliveryTurn
+ const currentConnectorStatus = this.chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ currentConnectorStatus !== connectorStatus ||
+ currentConnectorStatus.transactionStarted !== true ||
+ currentConnectorStatus.transactionEnding === true ||
+ currentConnectorStatus.transactionId?.toString() !== transactionId.toString()
+ ) {
+ transactionDelivery.settle(true)
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleMeterValues: Transaction is no longer active`
+ )
+ }
+ }
+ const intervalBaselineKey = isOcpp2
+ ? buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+ : 'default'
+ const intervalState =
+ requestedMeterValues == null && connectorStatus != null && transactionId != null
+ ? captureTransactionIntervalState(connectorStatus)
+ : undefined
+ let publicKeyIncluded = requestedPublicKeyIncluded
+ let meterValues: (OCPP16MeterValue | OCPP20MeterValue)[]
+ try {
+ meterValues = (() => {
+ if (requestedMeterValues != null) return requestedMeterValues
+ const meterValue =
+ isOcpp2 && transactionId != null
+ ? OCPP20ServiceUtils.buildTransactionMeterValue(
+ this.chargingStation,
+ connectorId,
+ evseId,
+ transactionId,
+ interval,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ )
+ : buildMeterValue(this.chargingStation, transactionId, interval)
+ // OCPP 1.6 Signed Meter Values whitepaper §3.3.6: mirror the periodic
+ // loop (`OCPP16ServiceUtils.startUpdatedMeterValues`) and append a paired
+ // SignedData SampledValue when signing is enabled for the connector.
+ // OCPP 2.0.x signing is applied inline by the versioned dispatcher.
+ if (!isOcpp2 && transactionId != null) {
+ publicKeyIncluded = OCPP16ServiceUtils.appendSignedUpdatedReadings(
+ this.chargingStation,
+ connectorId,
+ convertToInt(transactionId),
+ meterValue as OCPP16MeterValue
+ )
+ } else if (isOcpp2) {
+ publicKeyIncluded = (meterValue as OCPP20MeterValue).sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ }
+ return [meterValue]
+ })()
+ if (intervalState != null && connectorStatus != null) {
+ completeTransactionIntervalState(intervalState, intervalBaselineKey, meterValues)
+ }
+ } catch (error) {
+ transactionDelivery?.settle(true)
+ throw error
+ }
+ const request = (
+ isOcpp2
+ ? {
+ ...(requestPayload?.customData != null && { customData: requestPayload.customData }),
+ evseId,
+ meterValue: meterValues,
+ }
+ : {
+ ...requestPayload,
+ connectorId,
+ meterValue: meterValues,
+ }
+ ) as MeterValuesRequest
+ const publicKeyDeliveryToken =
+ connectorStatus != null && transactionId != null
+ ? claimPublicKeyDelivery(connectorStatus, transactionId, request, publicKeyIncluded)
+ : undefined
+ const deliveryState = {
+ buffered: false,
+ callError: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ }
+ let deliverySettled = false
+ const markDeliverySettled = (definitivelyRejected = false): void => {
+ if (deliverySettled) return
+ deliverySettled = true
+ transactionDelivery?.settle(definitivelyRejected)
+ }
+ let restored = false
+ const restoreRejectedDelivery = (): void => {
+ if (restored) return
+ restored = true
+ if (intervalState != null && connectorStatus != null) {
+ restoreTransactionIntervalState(intervalState, connectorStatus, intervalBaselineKey)
+ }
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ const requestParams: RequestParams = {
+ ...this.requestParams,
+ ...(requestedMeterValues == null && {
+ bufferOnErrorDuringStationStop: true,
+ skipBufferingOnError: true,
+ }),
+ onError: (error, isCallError) => {
+ deliveryState.callError ||= isCallError
+ if (isCallError || deliveryState.buffered) {
+ const definitivelyRejected = !deliveryState.transportErrorAmbiguous
+ if (definitivelyRejected) {
+ restoreRejectedDelivery()
+ } else {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ markDeliverySettled(definitivelyRejected)
+ }
+ this.requestParams.onError?.(error, isCallError)
+ },
+ onMessageSent: () => {
+ deliveryState.sent = true
+ this.requestParams.onMessageSent?.()
+ },
+ onRequestBuffered: () => {
+ deliveryState.buffered = true
+ transactionDelivery?.markBuffered()
+ this.requestParams.onRequestBuffered?.()
+ },
+ onResponseReceived: () => {
+ deliveryState.responseReceived = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ this.requestParams.onResponseReceived?.()
+ },
+ onTransportError: (error, deliveryAmbiguous) => {
+ deliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ this.requestParams.onTransportError?.(error, deliveryAmbiguous)
+ },
+ }
+ try {
+ const response = await this.chargingStation.ocppRequestService.requestHandler<
+ MeterValuesRequest,
+ MeterValuesResponse
+ >(this.chargingStation, RequestCommand.METER_VALUES, request, requestParams)
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ return response
+ } catch (error) {
+ const definitelyUnsent =
+ !deliveryState.buffered &&
+ !deliveryState.callError &&
+ !deliveryState.responseReceived &&
+ !deliveryState.sent &&
+ !deliveryState.transportErrorAmbiguous
+ if (definitelyUnsent) {
+ restoreRejectedDelivery()
+ } else if (
+ !deliveryState.buffered &&
+ (deliveryState.sent ||
+ deliveryState.responseReceived ||
+ deliveryState.transportErrorAmbiguous)
+ ) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ if (!deliveryState.buffered) markDeliverySettled(definitelyUnsent)
+ throw error
+ }
+ }
+
+ private async handleStartTransaction (
+ requestPayload?: BroadcastChannelRequestPayload
+ ): Promise<StartTransactionResponse> {
+ if (this.chargingStation.stationInfo?.ocppVersion !== OCPPVersion.VERSION_16) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStartTransaction: StartTransaction is only supported with OCPP 1.6`
+ )
+ }
+ const { connectorId, idTag, ...requestOverrides } = requestPayload ?? {}
+ if (connectorId == null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStartTransaction: 'connectorId' field is required`
+ )
+ }
+ if (idTag != null && typeof idTag !== 'string') {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStartTransaction: 'idTag' field must be a string`
+ )
+ }
+ return await OCPP16ServiceUtils.startTransactionOnConnector(
this.chargingStation,
- RequestCommand.METER_VALUES,
- {
- ...(isOcpp2
- ? {
- evseId: payloadEvseId ?? this.chargingStation.getEvseIdByConnectorId(connectorId),
- }
- : { connectorId }),
- meterValue: [meterValue],
- ...requestPayload,
- } as MeterValuesRequest,
- this.requestParams
+ connectorId,
+ idTag ?? undefined,
+ this.requestParams,
+ requestOverrides
)
}
private async handleStopTransaction (
requestPayload?: BroadcastChannelRequestPayload
): Promise<StopTransactionResponse> {
- return await this.chargingStation.ocppRequestService.requestHandler<
- StopTransactionRequest,
- StopTransactionResponse
- >(
+ if (this.chargingStation.stationInfo?.ocppVersion !== OCPPVersion.VERSION_16) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStopTransaction: StopTransaction is only supported by OCPP 1.6 charging stations`
+ )
+ }
+ const transactionId = requestPayload?.transactionId
+ if (transactionId == null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStopTransaction: 'transactionId' field is required`
+ )
+ }
+ const connectorId = this.chargingStation.getConnectorIdByTransactionId(transactionId)
+ if (connectorId == null) {
+ throw new BaseError(
+ `${this.chargingStation.logPrefix()} ${moduleName}.handleStopTransaction: No active transaction found for transactionId '${transactionId.toString()}'`
+ )
+ }
+ const hasTimestamp = requestPayload != null && Object.hasOwn(requestPayload, 'timestamp')
+ const { idTag, reason, timestamp, transactionData } =
+ requestPayload as Partial<StopTransactionRequest>
+ return await OCPP16ServiceUtils.stopTransactionOnConnector(
this.chargingStation,
- RequestCommand.STOP_TRANSACTION,
+ connectorId,
+ reason,
{
- meterStop: this.chargingStation.getEnergyActiveImportRegisterByTransactionId(
- requestPayload?.transactionId,
- true
- ),
- ...requestPayload,
- } as StopTransactionRequest,
+ ...(idTag != null && { idTag }),
+ ...(hasTimestamp && { timestamp }),
+ ...(transactionData != null && { transactionData }),
+ },
this.requestParams
)
}
} from './Helpers.js'
export type { IBootstrap } from './IBootstrap.js'
export { IdTagsCache } from './IdTagsCache.js'
-export type { CoherentSession } from './meter-values/index.js'
export { SharedLRUCache } from './SharedLRUCache.js'
export { applyMigration, coerceVersion, CURRENT_SCHEMA_VERSION } from './TemplateMigrations.js'
export { TemplateSchema } from './TemplateSchema.js'
export { TemplateValidationError, validateTemplate } from './TemplateValidation.js'
+export {
+ boundTransactionEventQueue,
+ clearTransactionEventQueueInFlight,
+ enqueueBoundedTransactionEvent,
+ getMutableSignedMeterValue,
+ getRawSignedMeterValuePublicKey,
+ getTransactionEventQueueBytes,
+ hasQueuedEndedTransactionEvent,
+ invalidateTransactionEventQueueAccounting,
+ isTransactionEventQueueBlocked,
+ isTransactionEventQueueStaged,
+ markTransactionEventQueueDeliveryAttempted,
+ queuedTransactionEventHasPublicKey,
+ removeBoundedTransactionEvent,
+ resetTransactionEventQueueRuntimeState,
+ restoreRejectedTransactionEventIntervalCarry,
+ setTransactionEventQueueBlocked,
+ setTransactionEventQueueInFlight,
+ setTransactionEventQueueStaged,
+ shiftBoundedTransactionEvent,
+ transferDiscardedTransactionEventIntervalEnergy,
+} from './TransactionEventQueueUtils.js'
* - Within a measurand with multiple phase-qualified templates: no-phase
* first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`.
*
- * Unsupported `(measurand, phase)` combinations are logged and skipped.
- * Only measurands enabled by the caller-resolved allow-list are emitted.
+ * Unsupported physical `(measurand, phase)` combinations are logged and
+ * skipped; enabled non-physical templates with finite fixed values are emitted.
+ * Only measurands enabled by the caller-resolved allow-list are included.
* The energy register is advanced unconditionally by the caller through
- * {@link ./CoherentSampleComputer.advanceEnergyRegister} independent of
+ * {@link ./CoherentSampleComputer.advanceTransactionEnergyRegister} independent of
* whether the Energy measurand is emitted.
*/
-import type {
- MeterValue,
- MeterValueContext,
- SampledValue,
- SampledValueTemplate,
-} from '../../types/index.js'
+import type { MeterValue, SampledValue, SampledValueTemplate } from '../../types/index.js'
import type { CoherentSample, ComputeSampleOptions } from './CoherentSampleComputer.js'
import type { CoherentSession, ICoherentContext } from './types.js'
import {
type ConnectorStatus,
+ CurrentType,
+ MeterValueContext,
+ MeterValueLocation,
MeterValueMeasurand,
MeterValuePhase,
MeterValueUnit,
+ OCPP16MeterValueFormat,
+ OCPPVersion,
} from '../../types/index.js'
-import { Constants, isEmpty, isNotEmptyArray, logger, roundTo } from '../../utils/index.js'
import {
- advanceEnergyRegister,
- computeCoherentSample,
+ Constants,
+ getRandomFloatFluctuatedRounded,
+ isEmpty,
+ isNotEmptyArray,
+ isNotEmptyString,
+ logger,
+ roundTo,
+} from '../../utils/index.js'
+import {
+ advanceConnectorEnergyRegister,
+ advanceStationEnergyRegister,
+ advanceTransactionEnergyRegister,
+ computeCoherentSampleAtTime,
+ consumePendingSharedEnergy,
+ getCoherentSampleSnapshot,
+ recordPendingSharedEnergy,
ROUNDING_SCALE,
} from './CoherentSampleComputer.js'
+import {
+ buildSampledValueFamilyKey,
+ canonicalizeCustomData,
+ resolveLinePhaseIndex,
+ resolveMeterValueUnitDivider,
+} from './MeterValueUtils.js'
+import {
+ recordTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ truncateTransactionIntervalValue,
+} from './TransactionIntervalUtils.js'
const moduleName = 'CoherentMeterValueBuilder'
const phaseFamily = (
phase: MeterValuePhase | undefined
-): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' =>
- phase == null ? 'Aggregate' : PHASE_FAMILY[phase]
+): 'Aggregate' | 'LineToLine' | 'LineToNeutral' | 'Neutral' | 'Unsupported' => {
+ if (phase == null) return 'Aggregate'
+ const configuredFamily = (
+ PHASE_FAMILY as Partial<Record<string, 'LineToLine' | 'LineToNeutral' | 'Neutral'>>
+ )[phase]
+ return configuredFamily ?? 'Unsupported'
+}
/**
* Emit order across measurands, mirroring the `getSampledValueTemplate`
MeterValueMeasurand.VOLTAGE,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
MeterValueMeasurand.CURRENT_IMPORT,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
] as const satisfies readonly MeterValueMeasurand[]
+const PHYSICAL_MEASURANDS: ReadonlySet<MeterValueMeasurand> = new Set(MEASURAND_EMIT_ORDER)
+
/**
* Within-measurand phase order for deterministic per-phase emission:
* no-phase → L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N.
const isLineToNeutralTemplate = (t: SampledValueTemplate): boolean =>
phaseFamily(t.phase) === 'LineToNeutral'
-const templateFamilyKey = (t: SampledValueTemplate): string =>
- JSON.stringify([t.context ?? null, t.format ?? null, t.location ?? null, t.unit ?? null])
+const energyIntervalFallbackIdentity = (
+ template: SampledValueTemplate,
+ context: MeterValueContext | undefined,
+ ocppVersion: OCPPVersion | undefined
+): string =>
+ JSON.stringify([
+ template.phase,
+ template.location ?? MeterValueLocation.OUTLET,
+ (template.unit as MeterValueUnit | undefined) ?? MeterValueUnit.WATT_HOUR,
+ context ?? template.context ?? MeterValueContext.SAMPLE_PERIODIC,
+ ocppVersion === OCPPVersion.VERSION_16
+ ? (template.format ?? OCPP16MeterValueFormat.RAW)
+ : canonicalizeCustomData(template.customData),
+ ])
+
+const templateFamilyKey = (
+ template: SampledValueTemplate,
+ context: MeterValueContext | undefined
+): string =>
+ buildSampledValueFamilyKey({
+ context: context ?? template.context ?? MeterValueContext.SAMPLE_PERIODIC,
+ customData: template.customData,
+ location: template.location ?? MeterValueLocation.OUTLET,
+ measurand: template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: (template.unit as MeterValueUnit | undefined) ?? MeterValueUnit.WATT_HOUR,
+ })
/**
* Applies the OCPP 2.0.1 `SampledDataCtrlr.RegisterValuesWithoutPhases`
* suppression to the `Energy.Active.Import.Register` bucket in-place.
- * Groups templates into identity families keyed by
- * `(context, format, location, unit)`; within each family, per-phase
- * L-N templates are filtered out (avoiding "unsupported combination"
+ * Groups templates by effective emitted OCPP 2.0 identity: caller-resolved
+ * context, default-resolved location/unit, measurand, and canonical customData.
+ * Source-only `format` is omitted because OCPP 2.0 does not emit it. Within
+ * each family, per-phase L-N templates are filtered out (avoiding "unsupported combination"
* warnings for a configured skip). If a family has per-phase L-N
* templates but no aggregate template, an aggregate is synthesized
* from the first suppressed per-phase L-N of that family (phase
* by `PHASE_RANK` to preserve stable emit order. No-op when the
* measurand bucket is absent or has no per-phase L-N templates.
* @param groups - Grouped templates map (mutated in-place).
+ * @param context - Caller-selected context that overrides template context.
*/
const applyRegisterValuesWithoutPhases = (
- groups: Map<MeterValueMeasurand, SampledValueTemplate[]>
+ groups: Map<MeterValueMeasurand, SampledValueTemplate[]>,
+ context: MeterValueContext | undefined
): void => {
const bucket = groups.get(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
if (bucket == null) return
if (!bucket.some(isLineToNeutralTemplate)) return
const surviving: SampledValueTemplate[] = []
- for (const family of Map.groupBy(bucket, templateFamilyKey).values()) {
+ for (const family of Map.groupBy(bucket, template =>
+ templateFamilyKey(template, context)
+ ).values()) {
const perPhaseLN = family.filter(isLineToNeutralTemplate)
if (isEmpty(perPhaseLN)) {
surviving.push(...family)
* @param phase - Template `phase` field (may be `undefined`).
* @param sample - Coherent sample (source of aggregate values).
* @param numberOfPhases - Session phase count.
+ * @param currentType - Session current topology.
* @param connectorStatus - Connector status (for the energy register).
+ * @param energyRegisterWhOverride - Explicit register selected by caller semantics.
+ * @param energyIntervalWhOverride - Transaction interval energy selected by caller semantics.
* @returns Value to emit, or `undefined` if the combination is unsupported.
*
- * Supported measurands: `Current.Import`, `Energy.Active.Import.Register`,
- * `Power.Active.Import`, `SoC`, `Voltage`. Other OCPP-defined measurands
+ * Supported measurands: `Current.Import`, `Energy.Active.Import.Interval`,
+ * `Energy.Active.Import.Register`, `Power.Active.Import`, `SoC`, `Voltage`. Other OCPP-defined measurands
* (notably `Power.Factor`, `Power.Reactive.Import`, `Frequency`,
* `Temperature`) return `undefined` so the emission path logs and skips
* the template. `EvProfile.powerFactor` scales the AC current/power chain
phase: MeterValuePhase | undefined,
sample: CoherentSample,
numberOfPhases: number,
- connectorStatus: ConnectorStatus
+ currentType: CurrentType,
+ connectorStatus: ConnectorStatus,
+ energyRegisterWhOverride?: number,
+ energyIntervalWhOverride?: number
): number | undefined => {
const family = phaseFamily(phase)
+ if (family === 'Unsupported') return undefined
+ if (family === 'LineToNeutral') {
+ const linePhaseIndex = resolveLinePhaseIndex(phase)
+ if (
+ currentType !== CurrentType.AC ||
+ linePhaseIndex == null ||
+ linePhaseIndex > numberOfPhases
+ ) {
+ return undefined
+ }
+ }
+ if (family === 'Neutral' && currentType !== CurrentType.AC) return undefined
switch (measurand) {
case MeterValueMeasurand.CURRENT_IMPORT:
if (family === 'LineToLine') return undefined
if (family === 'Neutral') return 0
return sample.currentA
+ case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL:
+ if (family === 'LineToLine' || family === 'Neutral') return undefined
+ if (family === 'LineToNeutral') {
+ if (numberOfPhases <= 0) return undefined
+ return (energyIntervalWhOverride ?? sample.deltaEnergyWh) / numberOfPhases
+ }
+ return energyIntervalWhOverride ?? sample.deltaEnergyWh
case MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER: {
if (family === 'LineToLine' || family === 'Neutral') return undefined
- const register = Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0)
+ const register = Math.max(
+ 0,
+ energyRegisterWhOverride ?? connectorStatus.energyActiveImportRegisterValue ?? 0
+ )
if (family === 'LineToNeutral') {
if (numberOfPhases <= 0) return undefined
return register / numberOfPhases
case MeterValueMeasurand.VOLTAGE:
if (family === 'Neutral') return 0
if (family === 'LineToLine') {
- // V_LL = sqrt(3) * V_LN in a balanced 3-phase Y system (30-degree
- // phase separation). Defined only for numberOfPhases === 3;
- // 1-phase has no L-L pair, 2-phase is unsupported by contract.
if (numberOfPhases !== 3) return undefined
return Math.sqrt(3) * sample.voltageV
}
}
}
-/**
- * Measurand → matching kilo-prefixed unit lookup. Populated only for the
- * measurands whose `SampledValueTemplate.unit` may legitimately carry a
- * kilo-scaled value (kW / kWh). Any other `(measurand, unit)` pair
- * emits at unit scale (divider = 1).
- */
-const KILO_UNIT_BY_MEASURAND: ReadonlyMap<MeterValueMeasurand, MeterValueUnit> = new Map<
- MeterValueMeasurand,
- MeterValueUnit
->([
- [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueUnit.KILO_WATT_HOUR],
- [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueUnit.KILO_WATT],
-])
-
-/**
- * Returns the unit divider for a `(measurand, unit)` pair: the kilo divider
- * when the template's unit is the kilo-prefixed variant of the measurand's
- * base unit (kW for Power, kWh for Energy register), otherwise 1.
- * @param measurand - Target measurand.
- * @param unit - Template unit (may be `undefined`).
- * @returns `Constants.UNIT_DIVIDER_KILO` or `1`.
- */
-const resolveUnitDivider = (
- measurand: MeterValueMeasurand,
- unit: MeterValueUnit | undefined
-): number =>
- unit != null && KILO_UNIT_BY_MEASURAND.get(measurand) === unit ? Constants.UNIT_DIVIDER_KILO : 1
-
/**
* Resolves `MeterValues` templates for a connector. EVSE-level
* `MeterValues` (when defined and non-empty) override connector-level
* cross-connector aggregation is intentionally not replicated.
* @param context - Charging-station context.
* @param connectorId - Connector identifier.
+ * @param connectorStatusOverride - Exact connector state when connector ids are EVSE-local.
+ * @param evseIdOverride - Exact EVSE id when connector ids are EVSE-local.
* @returns Templates or `undefined`.
*/
const resolveTemplates = (
context: ICoherentContext,
- connectorId: number
+ connectorId: number,
+ connectorStatusOverride?: ConnectorStatus,
+ evseIdOverride?: number
): SampledValueTemplate[] | undefined => {
- const evseId = context.getEvseIdByConnectorId(connectorId)
+ const evseId = evseIdOverride ?? context.getEvseIdByConnectorId(connectorId)
if (evseId != null) {
const evseTemplates = context.getEvseStatus(evseId)?.MeterValues
- if (isNotEmptyArray(evseTemplates)) {
- return evseTemplates
- }
+ if (isNotEmptyArray(evseTemplates)) return evseTemplates
}
- return context.getConnectorStatus(connectorId)?.MeterValues
+ return (connectorStatusOverride ?? context.getConnectorStatus(connectorId))?.MeterValues
}
/**
- * Builds a complete OCPP {@link MeterValue} from a coherent sample.
- *
- * Emission order:
- * - Across measurands: `SoC → Voltage → Power → Current → Energy`.
- * - Within a measurand with multiple phase-qualified templates: no-phase
- * first, then `L1/L1-N → L2/L2-N → L3/L3-N → L1-L2 → L2-L3 → L3-L1 → N`.
- *
- * Per-phase resolution - see {@link resolvePhasedValue}. Unsupported
- * `(measurand, phase)` combinations are logged and skipped.
- *
- * Only measurands enabled by the caller-resolved allow-list are emitted.
- * The energy register is advanced unconditionally by
- * {@link advanceEnergyRegister} independent of whether the Energy
- * measurand is emitted.
+ * Projects an output-side physical value onto a configured meter location.
+ * DC Inlet samples include conversion loss; Outlet and default-location samples
+ * remain connector output-side. EVSE 0 already stores input-side values.
* @param context - Charging-station context.
- * @param session - Active coherent session for the transaction. Callers
- * look this up via
- * {@link ../CoherentMeterValuesManager.CoherentMeterValuesManager.getSession}
- * at the strategy gate and thread it through - the port no longer
- * exposes session lookup.
- * @param buildVersionedSampledValue - Versioned SampledValue builder from
- * the OCPP dispatcher in `OCPPServiceUtils.buildMeterValue`.
- * @param options - Per-sample parameters (interval, seed material, timestamp).
+ * @param currentType - Connector output current type.
+ * @param evseId - Effective EVSE identifier.
+ * @param template - Meter template being emitted.
+ * @param outputValue - Physical output-side value.
+ * @returns Value on the template's physical side.
+ */
+const projectDcOutputValue = (
+ context: ICoherentContext,
+ currentType: CurrentType,
+ evseId: number | undefined,
+ template: SampledValueTemplate,
+ outputValue: number
+): number => {
+ if (
+ currentType !== CurrentType.DC ||
+ evseId === 0 ||
+ template.location !== MeterValueLocation.INLET
+ ) {
+ return outputValue
+ }
+ const configuredEfficiency = context.stationInfo?.conversionEfficiency ?? 1
+ return outputValue / (configuredEfficiency > 0 ? configuredEfficiency : 1)
+}
+
+const projectEnergyRegisterWh = (
+ context: ICoherentContext,
+ currentType: CurrentType,
+ evseId: number | undefined,
+ template: SampledValueTemplate,
+ connectorStatus: ConnectorStatus,
+ energyRegisterWhOverride?: number
+): number =>
+ projectDcOutputValue(
+ context,
+ currentType,
+ evseId,
+ template,
+ Math.max(0, energyRegisterWhOverride ?? connectorStatus.energyActiveImportRegisterValue ?? 0)
+ )
+
+/**
+ * Projects a coherent physical sample onto every configured template.
+ * @param context - Charging-station context.
+ * @param connectorId - Source connector identifier.
+ * @param numberOfPhases - Physical phase count.
+ * @param currentType - Physical current topology.
+ * @param buildVersionedSampledValue - OCPP-version sampled-value builder.
+ * @param sample - Already computed physical sample.
* @param mvContext - Optional MeterValue reading context.
- * @param enabledMeasurands - Optional allow-list resolved from the
- * version-appropriate OCPP variable at the `buildMeterValue` boundary.
- * When `undefined`, all templates emit (default behavior). When defined,
- * only measurands in the set emit. Governs OCPP 2.0.1 J02.FR.11 /
- * E02.FR.09 / E06.FR.11 and OCPP 1.6 `MeterValuesSampledData`.
- * @param registerValuesWithoutPhases - Optional OCPP 2.0.1
- * `SampledDataCtrlr.RegisterValuesWithoutPhases` flag. When `true`,
- * `Energy.Active.Import.Register` templates are grouped into identity
- * families keyed by `(context, format, location, unit)`; within each
- * family, per-phase L-N templates are filtered out and, when a
- * family has no aggregate template configured, an aggregate is
- * synthesized from the first suppressed L-N of that family (phase
- * cleared, other identity fields preserved) so the spec requirement
- * "will only report the total energy over all phases" holds per
- * family. Defaults to `false` (or `undefined`) so OCPP 1.6 callers
- * preserve current behavior.
- * @returns MeterValue with sampled values and current timestamp.
+ * @param enabledMeasurands - Optional configured measurand allow-list.
+ * @param registerValuesWithoutPhases - Whether phased energy-register values are suppressed.
+ * @param timestamp - Shared MeterValue/signature timestamp.
+ * @param connectorStatusOverride - Exact connector state when connector ids are EVSE-local.
+ * @param evseIdOverride - Exact EVSE id when connector ids are EVSE-local.
+ * @param energyRegisterWhOverride - Explicit register selected by caller semantics.
+ * @param energyIntervalWhOverride - Transaction interval energy already committed by a peer.
+ * @returns MeterValue projected in stable measurand/template order.
*/
-export const buildCoherentMeterValue = (
+const serializeCoherentMeterValue = (
context: ICoherentContext,
- session: CoherentSession,
+ connectorId: number,
+ numberOfPhases: number,
+ currentType: CurrentType,
buildVersionedSampledValue: BuildVersionedSampledValue,
- options: ComputeSampleOptions,
+ sample: CoherentSample,
mvContext?: MeterValueContext,
enabledMeasurands?: ReadonlySet<MeterValueMeasurand>,
- registerValuesWithoutPhases?: boolean
+ registerValuesWithoutPhases?: boolean,
+ timestamp = new Date(),
+ connectorStatusOverride?: ConnectorStatus,
+ evseIdOverride?: number,
+ energyRegisterWhOverride?: number,
+ energyIntervalWhOverride?: number
): MeterValue => {
- const connectorStatus = context.getConnectorStatus(session.connectorId)
+ const connectorStatus = connectorStatusOverride ?? context.getConnectorStatus(connectorId)
if (connectorStatus == null) {
- logger.warn(
- `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing connector ${session.connectorId.toString()} for transaction ${String(session.transactionId)}`
- )
return { sampledValue: [], timestamp: new Date() }
}
-
- const sample = computeCoherentSample(context, connectorStatus, session, options)
- // Own the register update: happens once per sample, unconditionally, so
- // meterStop is correct even when Energy.Active.Import.Register is not in
- // the configured MeterValues.
- advanceEnergyRegister(connectorStatus, sample.deltaEnergyWh)
-
- const templates = resolveTemplates(context, session.connectorId)
+ const templates = resolveTemplates(context, connectorId, connectorStatus, evseIdOverride)
const groups = groupTemplatesByMeasurand(templates)
+ if (enabledMeasurands?.has(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) === true) {
+ const intervalTemplates = groups.get(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) ?? []
+ const intervalTemplateIdentities = new Set(
+ intervalTemplates.map(template =>
+ energyIntervalFallbackIdentity(template, mvContext, context.stationInfo?.ocppVersion)
+ )
+ )
+ const intervalFallbacks = (groups.get(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) ?? [])
+ .filter(template => {
+ const intervalTemplate = {
+ ...template,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ } as SampledValueTemplate
+ return !intervalTemplateIdentities.has(
+ energyIntervalFallbackIdentity(
+ intervalTemplate,
+ mvContext,
+ context.stationInfo?.ocppVersion
+ )
+ )
+ })
+ .map(
+ template =>
+ ({
+ ...template,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ }) as SampledValueTemplate
+ )
+ if (isNotEmptyArray(intervalFallbacks)) {
+ groups.set(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL, [
+ ...intervalTemplates,
+ ...intervalFallbacks,
+ ])
+ }
+ }
if (registerValuesWithoutPhases === true) {
- applyRegisterValuesWithoutPhases(groups)
+ applyRegisterValuesWithoutPhases(groups, mvContext)
}
const sampledValue: SampledValue[] = []
+ const effectiveEvseId = evseIdOverride ?? context.getEvseIdByConnectorId(connectorId)
const isEnabled = (measurand: MeterValueMeasurand): boolean =>
enabledMeasurands == null || enabledMeasurands.has(measurand)
- const numberOfPhases = session.numberOfPhases
for (const measurand of MEASURAND_EMIT_ORDER) {
if (!isEnabled(measurand)) continue
const bucket = groups.get(measurand)
if (bucket == null) continue
for (const template of bucket) {
+ if (
+ currentType === CurrentType.DC &&
+ effectiveEvseId !== 0 &&
+ template.location === MeterValueLocation.INLET &&
+ (measurand === MeterValueMeasurand.CURRENT_IMPORT ||
+ measurand === MeterValueMeasurand.VOLTAGE)
+ ) {
+ continue
+ }
+ const templateEnergyRegisterWh =
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ? projectEnergyRegisterWh(
+ context,
+ currentType,
+ effectiveEvseId,
+ template,
+ connectorStatus,
+ energyRegisterWhOverride
+ )
+ : undefined
const raw = resolvePhasedValue(
measurand,
template.phase,
sample,
numberOfPhases,
- connectorStatus
+ currentType,
+ connectorStatus,
+ templateEnergyRegisterWh,
+ energyIntervalWhOverride
)
if (raw == null) {
logger.warn(
- `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) - template skipped`
+ `${context.logPrefix()} ${moduleName}.serializeCoherentMeterValue: unsupported (${measurand}, phase=${String(template.phase)}) - template skipped`
)
continue
}
- // Narrow the OCPP 2.0.1 `SampledValueTemplate.unit` open-string branch
- // to the closed `MeterValueUnit` union for the Map lookup below; any
- // string outside the enum returns `undefined` from the Map and falls
- // through to divider = 1 (unit-scale emission).
- const unitDivider = resolveUnitDivider(measurand, template.unit as MeterValueUnit | undefined)
- const scaled = roundTo(raw / unitDivider, ROUNDING_SCALE)
+ const physicalValue =
+ measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT ||
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ? projectDcOutputValue(context, currentType, effectiveEvseId, template, raw)
+ : raw
+ const unitDivider = resolveMeterValueUnitDivider(
+ measurand,
+ template.unit as MeterValueUnit | undefined
+ )
+ const scaled =
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ? truncateTransactionIntervalValue(physicalValue / unitDivider)
+ : roundTo(physicalValue / unitDivider, ROUNDING_SCALE)
sampledValue.push(buildVersionedSampledValue(template, scaled, mvContext))
}
}
+ for (const [measurand, bucket] of groups) {
+ if (PHYSICAL_MEASURANDS.has(measurand) || !isEnabled(measurand)) continue
+ for (const template of bucket) {
+ if (!isNotEmptyString(template.value)) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.serializeCoherentMeterValue: unsupported dynamic (${measurand}, phase=${String(template.phase)}) - template skipped`
+ )
+ continue
+ }
+ const configuredValue = Number(template.value)
+ if (!Number.isFinite(configuredValue)) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.serializeCoherentMeterValue: non-finite fixed (${measurand}, phase=${String(template.phase)}) - template skipped`
+ )
+ continue
+ }
+ const value = getRandomFloatFluctuatedRounded(
+ configuredValue,
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ )
+ sampledValue.push(buildVersionedSampledValue(template, value, mvContext, template.phase))
+ }
+ }
+ return { sampledValue, timestamp } as MeterValue
+}
+
+/**
+ * Computes, commits, and serializes one coherent physical sample.
+ */
+
+export const buildCoherentMeterValue = (
+ context: ICoherentContext,
+ session: CoherentSession,
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ options: ComputeSampleOptions,
+ mvContext?: MeterValueContext,
+ enabledMeasurands?: ReadonlySet<MeterValueMeasurand>,
+ registerValuesWithoutPhases?: boolean,
+ timestamp = new Date(),
+ connectorStatusOverride?: ConnectorStatus,
+ evseIdOverride?: number,
+ useTransactionEnergyRegister = false,
+ explicitEnergyRegisterWhOverride?: number,
+ commitEnergyRegisters = true,
+ deferEnergyInterval = false,
+ intervalBaselineKey = 'default'
+): MeterValue => {
+ const connectorStatus =
+ connectorStatusOverride ?? context.getConnectorStatus(session.connectorId, session.evseId)
+ if (connectorStatus == null) {
+ logger.warn(
+ `${context.logPrefix()} ${moduleName}.buildCoherentMeterValue: missing connector ${session.connectorId.toString()} for transaction ${String(session.transactionId)}`
+ )
+ return { sampledValue: [], timestamp: new Date() }
+ }
+ const snapshotOnly = mvContext === MeterValueContext.TRANSACTION_BEGIN
+ const intervalBaseline =
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[intervalBaselineKey] ?? 0
+ const carriedIntervalEnergy =
+ connectorStatus.transactionEnergyActiveImportIntervalCarry?.[intervalBaselineKey] ?? 0
+ const sample = snapshotOnly
+ ? getCoherentSampleSnapshot(context, connectorStatus, session)
+ : computeCoherentSampleAtTime(context, connectorStatus, session, options, evseIdOverride)
+ let committedSharedEnergyWh = 0
+ if (!snapshotOnly) {
+ advanceTransactionEnergyRegister(connectorStatus, sample.deltaEnergyWh)
+ const previousEnergyUpdate = connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
+ if (previousEnergyUpdate == null || timestamp > previousEnergyUpdate) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = timestamp
+ }
+ if (commitEnergyRegisters) {
+ committedSharedEnergyWh = consumePendingSharedEnergy(session, sample.deltaEnergyWh)
+ advanceConnectorEnergyRegister(connectorStatus, committedSharedEnergyWh)
+ advanceStationEnergyRegister(
+ context,
+ evseIdOverride ?? session.evseId ?? context.getEvseIdByConnectorId(session.connectorId),
+ session.currentType,
+ MeterValueLocation.OUTLET,
+ committedSharedEnergyWh
+ )
+ } else {
+ recordPendingSharedEnergy(session, sample.deltaEnergyWh)
+ }
+ }
+ const energyRegisterWhOverride =
+ explicitEnergyRegisterWhOverride != null
+ ? explicitEnergyRegisterWhOverride + committedSharedEnergyWh
+ : useTransactionEnergyRegister
+ ? (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0)
+ : undefined
+ const intervalEnergyValue =
+ Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) - intervalBaseline
+ ) + carriedIntervalEnergy
+ const meterValue = serializeCoherentMeterValue(
+ context,
+ session.connectorId,
+ session.numberOfPhases,
+ session.currentType,
+ buildVersionedSampledValue,
+ sample,
+ mvContext,
+ enabledMeasurands,
+ registerValuesWithoutPhases,
+ timestamp,
+ connectorStatus,
+ evseIdOverride,
+ energyRegisterWhOverride,
+ intervalEnergyValue
+ )
+ if (!snapshotOnly && !deferEnergyInterval) {
+ const emitsIntervalEnergy = meterValue.sampledValue.some(
+ sampledValue => sampledValue.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ if (emitsIntervalEnergy) {
+ recordTransactionIntervalEmission(
+ connectorStatus,
+ meterValue,
+ intervalBaselineKey,
+ intervalEnergyValue,
+ session.numberOfPhases,
+ resolveInletToOutputEfficiency(
+ session.currentType,
+ context.stationInfo?.conversionEfficiency,
+ evseIdOverride ?? session.evseId ?? context.getEvseIdByConnectorId(session.connectorId)
+ )
+ )
+ }
+ }
+ return meterValue
+}
- // MeterValue = OCPP16MeterValue | OCPP20MeterValue is a discriminated
- // union that diverges on the SampledValue.context enum. Coherent path
- // produces version-appropriate SampledValues via the injected
- // buildVersionedSampledValue callback, but the compile-time union of
- // SampledValue[] cannot be narrowed here - a boundary cast is required.
- return { sampledValue, timestamp: new Date() } as MeterValue
+/**
+ * Serializes the last committed coherent state without consuming PRNG state,
+ * advancing SoC, or changing energy registers.
+ * @param context - Charging-station context.
+ * @param session - Active coherent session.
+ * @param buildVersionedSampledValue - OCPP-version sampled-value builder.
+ * @param mvContext - Optional MeterValue reading context.
+ * @param enabledMeasurands - Optional configured measurand allow-list.
+ * @param registerValuesWithoutPhases - Whether phased energy-register values are suppressed.
+ * @param timestamp - Shared MeterValue/signature timestamp.
+ * @param connectorStatusOverride - Exact connector state when connector ids are EVSE-local.
+ * @param evseIdOverride - Exact EVSE id when connector ids are EVSE-local.
+ * @param energyRegisterWhOverride - Explicit register selected by caller semantics.
+ * @param intervalBaselineKey - Configuration-scoped interval baseline key.
+ * @returns Snapshot MeterValue.
+ */
+export const buildCoherentMeterValueSnapshot = (
+ context: ICoherentContext,
+ session: CoherentSession,
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ mvContext?: MeterValueContext,
+ enabledMeasurands?: ReadonlySet<MeterValueMeasurand>,
+ registerValuesWithoutPhases?: boolean,
+ timestamp = new Date(),
+ connectorStatusOverride?: ConnectorStatus,
+ evseIdOverride?: number,
+ energyRegisterWhOverride?: number,
+ intervalBaselineKey = 'default'
+): MeterValue => {
+ const connectorStatus =
+ connectorStatusOverride ?? context.getConnectorStatus(session.connectorId, session.evseId)
+ if (connectorStatus == null) {
+ return { sampledValue: [], timestamp: new Date() }
+ }
+ return serializeCoherentMeterValue(
+ context,
+ session.connectorId,
+ session.numberOfPhases,
+ session.currentType,
+ buildVersionedSampledValue,
+ getCoherentSampleSnapshot(context, connectorStatus, session),
+ mvContext,
+ enabledMeasurands,
+ registerValuesWithoutPhases,
+ timestamp,
+ connectorStatus,
+ evseIdOverride,
+ energyRegisterWhOverride,
+ Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ (connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[intervalBaselineKey] ?? 0)
+ ) + (connectorStatus.transactionEnergyActiveImportIntervalCarry?.[intervalBaselineKey] ?? 0)
+ )
}
* - `P ≤ min(EVSE_max, EV_acceptance(SoC))`.
* - `SoC ≥ 100 ⇒ P = 0, I = 0, ΔE = 0`.
*
- * The energy register update lives in {@link advanceEnergyRegister}; the
+ * The energy register update lives in {@link advanceTransactionEnergyRegister}; the
* caller (`buildCoherentMeterValue` in the builder module) invokes it once
* per sample so `meterStop` stays correct even when
* `Energy.Active.Import.Register` is not in the configured MeterValues.
import type { ConnectorStatus } from '../../types/index.js'
import type { CoherentSession, ICoherentContext } from './types.js'
-import { CurrentType } from '../../types/index.js'
+import { CurrentType, MeterValueLocation } from '../../types/index.js'
import { Constants, roundTo } from '../../utils/index.js'
import { interpolateChargingCurve } from './EvProfiles.js'
import { createStreamPrng } from './PRNG.js'
* auto-collected when the session becomes unreachable.
*/
interface SessionRuntime {
+ lastComputedAtMs?: number
+ lastSample?: CoherentSample
+ pendingSharedEnergyWh?: number
voltagePrng?: () => number
}
return runtime
}
+/**
+ * Accumulates transaction energy not yet committed to a shared physical register.
+ * @param session - Session whose uncommitted physical energy is tracked.
+ * @param deltaEnergyWh - Additional energy to defer, in Wh.
+ */
+export const recordPendingSharedEnergy = (
+ session: CoherentSession,
+ deltaEnergyWh: number
+): void => {
+ const runtime = getSessionRuntime(session)
+ runtime.pendingSharedEnergyWh = (runtime.pendingSharedEnergyWh ?? 0) + deltaEnergyWh
+}
+
+/**
+ * Returns and clears transaction energy pending for the shared physical register.
+ * @param session - Session whose deferred energy is committed.
+ * @param deltaEnergyWh - Current sample energy to include, in Wh.
+ * @returns Total deferred and current energy to commit, in Wh.
+ */
+export const consumePendingSharedEnergy = (
+ session: CoherentSession,
+ deltaEnergyWh: number
+): number => {
+ const runtime = getSessionRuntime(session)
+ const committedEnergyWh = (runtime.pendingSharedEnergyWh ?? 0) + deltaEnergyWh
+ runtime.pendingSharedEnergyWh = 0
+ return committedEnergyWh
+}
+
/**
* Disposes runtime state for a session. Call from every session-teardown
* path (stop/reset/disconnect) to release cached PRNG state eagerly.
return sessionRuntimes.delete(session)
}
+/**
+ * Returns the last committed coherent sample without advancing physics or the
+ * PRNG. Before the first committed sample, returns a conservative zero-flow
+ * snapshot from the current session/register state.
+ * @param context - Charging-station context.
+ * @param connectorStatus - Connector state.
+ * @param session - Active coherent session.
+ * @returns Immutable-by-convention sample snapshot.
+ */
+export const getCoherentSampleSnapshot = (
+ context: ICoherentContext,
+ connectorStatus: ConnectorStatus,
+ session: CoherentSession
+): CoherentSample => {
+ const lastSample = sessionRuntimes.get(session)?.lastSample
+ if (lastSample != null) {
+ return {
+ ...lastSample,
+ deltaEnergyWh: 0,
+ energyRegisterWh: Math.max(
+ 0,
+ connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ ),
+ }
+ }
+ return buildZeroSample(
+ session.socPercent,
+ context.getVoltageOut(),
+ Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0)
+ )
+}
+
/**
* Decimal places for all physics-quantity rounding (V, A, W, Wh, SoC).
* The `roundTo` half-width bound is `0.5 × 10^-ROUNDING_SCALE = 0.005` on
deltaEnergyWh: number
/**
* Transaction-scoped projected register value AFTER the delta from
- * {@link advanceEnergyRegister} is applied by the caller: derived from
+ * {@link advanceTransactionEnergyRegister} is applied by the caller: derived from
* `connectorStatus.transactionEnergyActiveImportRegisterValue +
* deltaEnergyWh` at compute time. Exists to expose the transaction-scoped
* projection to tests and other in-process introspection paths.
}
/**
- * Unconditionally advances the connector energy registers by `deltaEnergyWh`.
- * The coherent path owns register updates so `meterStop` stays correct even
- * when the `Energy.Active.Import.Register` measurand is not configured.
- * Negative or nullish register starting values are clamped to zero before
- * accrual.
+ * Advances the transaction-local energy register by `deltaEnergyWh`.
+ * The coherent path owns this register so `meterStop` stays correct even when
+ * the `Energy.Active.Import.Register` measurand is not configured.
* @param connectorStatus - Target connector status (in-place update).
- * @param deltaEnergyWh - Energy delta (Wh).
+ * @param deltaEnergyWh - Energy delta, in Wh.
*/
-export const advanceEnergyRegister = (
+export const advanceTransactionEnergyRegister = (
connectorStatus: ConnectorStatus | undefined,
deltaEnergyWh: number
): void => {
- if (connectorStatus == null) {
- return
- }
- connectorStatus.energyActiveImportRegisterValue =
- Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh
+ if (connectorStatus == null) return
connectorStatus.transactionEnergyActiveImportRegisterValue =
Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) + deltaEnergyWh
}
+/**
+ * Advances the shared physical connector energy register.
+ * @param connectorStatus - Target connector status (in-place update).
+ * @param deltaEnergyWh - Energy delta, in Wh.
+ */
+export const advanceConnectorEnergyRegister = (
+ connectorStatus: ConnectorStatus | undefined,
+ deltaEnergyWh: number
+): void => {
+ if (connectorStatus == null) return
+ connectorStatus.energyActiveImportRegisterValue =
+ Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0) + deltaEnergyWh
+}
+
+/**
+ * Advances the OCPP 2.0 station main-meter register from committed physical
+ * energy. Explicit inlet energy is already grid-side; DC outlet energy is
+ * converted to its inlet equivalent exactly once to include conversion losses.
+ * Coherent callers pass `Outlet` because their computed connector delta is
+ * always output-side, independently of the templates selected for emission.
+ * @param context - Station context containing the EVSE 0 main meter.
+ * @param evseId - Physical EVSE that committed the energy delta.
+ * @param currentType - Connector output current type.
+ * @param sourceLocation - Physical side of the supplied energy delta.
+ * @param deltaEnergyWh - Committed energy delta in Wh.
+ */
+export const advanceStationEnergyRegister = (
+ context: ICoherentContext,
+ evseId: number | undefined,
+ currentType: CurrentType,
+ sourceLocation: MeterValueLocation | undefined,
+ deltaEnergyWh: number
+): void => {
+ if (evseId == null || evseId <= 0) return
+ const mainConnectorStatus = context.getEvseStatus(0)?.connectors.get(0)
+ if (mainConnectorStatus == null) return
+ const configuredEfficiency =
+ currentType === CurrentType.DC && sourceLocation !== MeterValueLocation.INLET
+ ? (context.stationInfo?.conversionEfficiency ?? 1)
+ : 1
+ const conversionEfficiency = configuredEfficiency > 0 ? configuredEfficiency : 1
+ const inputEnergyWh = deltaEnergyWh / conversionEfficiency
+ mainConnectorStatus.energyActiveImportRegisterValue =
+ Math.max(0, mainConnectorStatus.energyActiveImportRegisterValue ?? 0) + inputEnergyWh
+}
+
/**
* Computes a single coherent sample and mutates the caller-owned
* `session.socPercent`. The energy register is NOT advanced here; the
- * caller (`buildCoherentMeterValue`) invokes {@link advanceEnergyRegister}
+ * caller (`buildCoherentMeterValue`) invokes {@link advanceTransactionEnergyRegister}
* once per emitted sample so the semantics match the OCPP energy meter
* model.
*
* @param connectorStatus - Connector status.
* @param session - Active coherent session (resolved by caller).
* @param options - Per-sample parameters (interval, seed material, ...).
+ * @param evseId - Exact EVSE id when connector ids are EVSE-local.
* @returns The computed sample. `energyRegisterWh` reflects the projected
- * register value AFTER `advanceEnergyRegister` is applied by the caller.
+ * register value AFTER `advanceTransactionEnergyRegister` is applied by the caller.
*/
export const computeCoherentSample = (
context: ICoherentContext,
connectorStatus: ConnectorStatus,
session: CoherentSession,
- options: ComputeSampleOptions
+ options: ComputeSampleOptions,
+ evseId?: number
): CoherentSample => {
const transactionId = session.transactionId
const evAcceptanceW = acceptanceFraction * session.profile.maxPowerW
// EVSE cap (already includes hardware/charging-profile clamps via ChargingStation).
- const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId)
+ const evseLimitW = context.getConnectorMaximumAvailablePower(session.connectorId, evseId)
const socCap = session.socPercent >= 100 ? 0 : 1
const targetPowerW = rampFactor * Math.min(evseLimitW, evAcceptanceW) * socCap
// Energy accounting uses the clamped (pre-rounding) `powerW` so INV-3
// holds within floating-point ε and the capacity budget is respected
// exactly. `Math.max(0, ...)` on `preRegisterWh` mirrors the clamp
- // applied by `advanceEnergyRegister` so the reported `energyRegisterWh`
+ // applied by `advanceTransactionEnergyRegister` so the reported `energyRegisterWh`
// and the post-advance persisted state agree even if the persisted
// register is corrupted to a negative value.
const deltaEnergyWh = (powerW * options.intervalMs) / Constants.MS_PER_HOUR
const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100
session.socPercent = Math.min(100, session.socPercent + deltaSocPercent)
- return {
+ const sample: CoherentSample = {
currentA: roundedCurrent,
deltaEnergyWh,
energyRegisterWh: projectedRegisterWh,
socPercent: roundTo(session.socPercent, ROUNDING_SCALE),
voltageV: roundedV,
}
+ getSessionRuntime(session).lastSample = sample
+ getSessionRuntime(session).lastComputedAtMs = options.nowMs
+ return sample
+}
+/**
+ * Advances coherent physics to an absolute sample time without counting an
+ * interval twice when periodic and clock-aligned samplers interleave.
+ * The first sample integrates from the transaction start; later samples
+ * integrate only elapsed time since the last committed sample. Out-of-order
+ * or same-instant observations reuse the committed state without advancing it.
+ * @param context - Charging-station context.
+ * @param connectorStatus - Connector state.
+ * @param session - Active coherent session.
+ * @param options - Sample options whose `nowMs` is the observation time.
+ * @param evseId - Exact EVSE id when connector ids are EVSE-local.
+ * @returns The current coherent sample.
+ */
+export const computeCoherentSampleAtTime = (
+ context: ICoherentContext,
+ connectorStatus: ConnectorStatus,
+ session: CoherentSession,
+ options: ComputeSampleOptions,
+ evseId?: number
+): CoherentSample => {
+ const runtime = getSessionRuntime(session)
+ const previousSampleAtMs = runtime.lastComputedAtMs ?? session.sessionStartMs
+ const intervalMs = Math.max(0, options.nowMs - previousSampleAtMs)
+ if (intervalMs === 0) {
+ return getCoherentSampleSnapshot(context, connectorStatus, session)
+ }
+ return computeCoherentSample(
+ context,
+ connectorStatus,
+ session,
+ { ...options, intervalMs },
+ evseId
+ )
}
export interface CreateSessionOptions {
/** Target connector id. */
connectorId: number
+ /** EVSE containing the connector when connector ids are EVSE-local. */
+ evseId?: number
/** Session start timestamp in milliseconds. Defaults to `Date.now()`. */
now?: number
/** Non-empty EV profile pool; one profile is picked via seeded weighted random selection. */
return {
connectorId: options.connectorId,
currentType,
+ ...(options.evseId != null && { evseId: options.evseId }),
numberOfPhases: currentType === CurrentType.AC ? context.getNumberOfPhases() : 1,
profile,
rampUpDurationMs: options.rampUpDurationMs ?? Constants.DEFAULT_COHERENT_RAMP_UP_DURATION_MS,
--- /dev/null
+// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
+
+import {
+ type MeterValueContext,
+ type MeterValueLocation,
+ MeterValueMeasurand,
+ MeterValuePhase,
+ MeterValueUnit,
+} from '../../types/index.js'
+import { Constants } from '../../utils/index.js'
+
+/**
+ * Serializes custom data with recursively sorted object keys for stable meter-value identities.
+ * Array order remains significant.
+ * @param customData - JSON-compatible custom data to serialize
+ * @returns Canonical JSON, or `undefined` when custom data is absent
+ */
+export const canonicalizeCustomData = (customData: unknown): string | undefined =>
+ JSON.stringify(customData, (_key: string, value: unknown): unknown => {
+ if (value == null || typeof value !== 'object' || Array.isArray(value)) return value
+ const sortedValue: Record<string, unknown> = {}
+ for (const key of Object.keys(value).sort()) {
+ sortedValue[key] = (value as Record<string, unknown>)[key]
+ }
+ return sortedValue
+ })
+
+export interface MeterValueUnitFamily {
+ readonly baseUnit: string
+ readonly kiloUnit?: string
+}
+
+/**
+ * Resolves the additive unit family for a meter-value measurand.
+ * @param measurand - OCPP meter-value measurand.
+ * @param configuredUnit - Optional configured unit to validate against the family.
+ * @returns Base/kilo unit family, or `undefined` for non-additive or incompatible values.
+ */
+export const getMeterValueUnitFamily = (
+ measurand: MeterValueMeasurand | undefined,
+ configuredUnit?: string
+): MeterValueUnitFamily | undefined => {
+ let family: MeterValueUnitFamily | undefined
+ if (measurand?.startsWith('Current.') === true) {
+ family = { baseUnit: MeterValueUnit.AMP }
+ } else if (measurand?.startsWith('Energy.Active.') === true) {
+ family = { baseUnit: MeterValueUnit.WATT_HOUR, kiloUnit: MeterValueUnit.KILO_WATT_HOUR }
+ } else if (measurand?.startsWith('Energy.Reactive.') === true) {
+ family = { baseUnit: MeterValueUnit.VAR_HOUR, kiloUnit: MeterValueUnit.KILO_VAR_HOUR }
+ } else if (measurand?.startsWith('Energy.Apparent.') === true) {
+ family = { baseUnit: MeterValueUnit.VOLT_AMP_HOUR, kiloUnit: MeterValueUnit.KILO_VOLT_AMP_HOUR }
+ } else if (measurand?.startsWith('Power.Reactive.') === true) {
+ family = { baseUnit: MeterValueUnit.VAR, kiloUnit: MeterValueUnit.KILO_VAR }
+ } else if (measurand?.startsWith('Power.Apparent.') === true) {
+ family = { baseUnit: MeterValueUnit.VOLT_AMP, kiloUnit: MeterValueUnit.KILO_VOLT_AMP }
+ } else if (
+ measurand?.startsWith('Power.') === true &&
+ measurand !== MeterValueMeasurand.POWER_FACTOR
+ ) {
+ family = { baseUnit: MeterValueUnit.WATT, kiloUnit: MeterValueUnit.KILO_WATT }
+ }
+ if (
+ family != null &&
+ configuredUnit != null &&
+ configuredUnit !== family.baseUnit &&
+ configuredUnit !== family.kiloUnit
+ ) {
+ return undefined
+ }
+ return family
+}
+
+/**
+ * Tests whether two units belong to the same additive measurand family.
+ * @param measurand - OCPP meter-value measurand.
+ * @param sourceUnit - Source unit.
+ * @param targetUnit - Target unit.
+ * @returns Whether the units are identical or compatible base/kilo variants.
+ */
+export const areMeterValueUnitsCompatible = (
+ measurand: MeterValueMeasurand,
+ sourceUnit: string | undefined,
+ targetUnit: string | undefined
+): boolean => {
+ if (sourceUnit === targetUnit) return true
+ const family = getMeterValueUnitFamily(measurand)
+ return (
+ family != null &&
+ sourceUnit != null &&
+ targetUnit != null &&
+ (sourceUnit === family.baseUnit || sourceUnit === family.kiloUnit) &&
+ (targetUnit === family.baseUnit || targetUnit === family.kiloUnit)
+ )
+}
+
+/**
+ * Resolves the base-to-configured-unit divider for a meter-value measurand.
+ * @param measurand - OCPP meter-value measurand.
+ * @param unit - Configured output unit.
+ * @returns `Constants.UNIT_DIVIDER_KILO` for the family's kilo unit, otherwise `1`.
+ */
+export const resolveMeterValueUnitDivider = (
+ measurand: MeterValueMeasurand,
+ unit: string | undefined
+): number => {
+ const kiloUnit = getMeterValueUnitFamily(measurand)?.kiloUnit
+ return kiloUnit != null && kiloUnit === unit ? Constants.UNIT_DIVIDER_KILO : 1
+}
+
+/**
+ * Resolves an OCPP line or line-to-neutral phase to its one-based line index.
+ * @param phase - OCPP meter-value phase.
+ * @returns Line index, or `undefined` for aggregate, neutral, line-to-line, and unsupported phases.
+ */
+export const resolveLinePhaseIndex = (phase: string | undefined): number | undefined => {
+ switch (phase) {
+ case MeterValuePhase.L1:
+ case MeterValuePhase.L1_N:
+ return 1
+ case MeterValuePhase.L2:
+ case MeterValuePhase.L2_N:
+ return 2
+ case MeterValuePhase.L3:
+ case MeterValuePhase.L3_N:
+ return 3
+ default:
+ return undefined
+ }
+}
+
+/**
+ * Builds a stable phase-independent key from fields as they will be emitted.
+ * Callers must resolve source defaults and runtime overrides before invoking it.
+ * @param identity - Effective emitted sampled-value identity fields
+ * @param identity.context - Effective reading context
+ * @param identity.customData - Source custom data included in OCPP 2.0 output
+ * @param identity.location - Effective measurement location
+ * @param identity.measurand - Effective measurand
+ * @param identity.unit - Effective unit
+ * @returns Stable serialized family identity
+ */
+export const buildSampledValueFamilyKey = (identity: {
+ context: MeterValueContext
+ customData: unknown
+ location: MeterValueLocation | undefined
+ measurand: MeterValueMeasurand
+ unit: MeterValueUnit | undefined
+}): string =>
+ JSON.stringify([
+ identity.context,
+ identity.location,
+ identity.unit,
+ identity.measurand,
+ canonicalizeCustomData(identity.customData),
+ ])
--- /dev/null
+import { type ConnectorStatus, CurrentType, MeterValueMeasurand } from '../../types/index.js'
+import { isEmpty } from '../../utils/index.js'
+
+export interface TransactionIntervalState {
+ consumed: number
+ readonly transactionId?: string
+}
+
+const transactionIntervalConsumptions = new WeakMap<object, Record<string, number>>()
+
+/**
+ * Resolves the conversion factor used to translate an emitted inlet value back
+ * to the transaction's output-side energy accounting.
+ * @param currentType - Station output current type.
+ * @param configuredEfficiency - Configured inlet-to-output conversion efficiency.
+ * @param evseId - EVSE identity; EVSE zero represents the raw station inlet.
+ * @returns A validated efficiency for a DC output, otherwise one.
+ */
+export const resolveInletToOutputEfficiency = (
+ currentType: CurrentType | undefined,
+ configuredEfficiency: number | undefined,
+ evseId?: number
+): number =>
+ currentType === CurrentType.DC &&
+ evseId !== 0 &&
+ configuredEfficiency != null &&
+ Number.isFinite(configuredEfficiency) &&
+ configuredEfficiency > 0 &&
+ configuredEfficiency <= 1
+ ? configuredEfficiency
+ : 1
+
+export const recordTransactionIntervalConsumption = (
+ meterValue: object,
+ baselineKey: string,
+ energyWh: number
+): void => {
+ if (!Number.isFinite(energyWh) || energyWh <= 0) return
+ transactionIntervalConsumptions.set(meterValue, { [baselineKey]: energyWh })
+}
+
+export const getTransactionIntervalConsumptions = (
+ meterValues: readonly object[] | undefined
+): Record<string, number> | undefined => {
+ if (meterValues == null) return undefined
+ const consumptions: Record<string, number> = {}
+ for (const meterValue of meterValues) {
+ const meterValueConsumptions = transactionIntervalConsumptions.get(meterValue)
+ if (meterValueConsumptions == null) continue
+ for (const [key, value] of Object.entries(meterValueConsumptions)) {
+ consumptions[key] = (consumptions[key] ?? 0) + value
+ }
+ }
+ return isEmpty(consumptions) ? undefined : consumptions
+}
+
+export const captureTransactionIntervalState = (
+ connectorStatus: ConnectorStatus
+): TransactionIntervalState => ({
+ consumed: 0,
+ transactionId: connectorStatus.transactionId?.toString(),
+})
+
+export const completeTransactionIntervalState = (
+ state: TransactionIntervalState,
+ baselineKey: string,
+ meterValues: readonly object[]
+): void => {
+ state.consumed = getTransactionIntervalConsumptions(meterValues)?.[baselineKey] ?? 0
+}
+
+export const restoreTransactionIntervalState = (
+ state: TransactionIntervalState,
+ connectorStatus: ConnectorStatus,
+ baselineKey: string
+): void => {
+ if (state.consumed <= 0 || connectorStatus.transactionId?.toString() !== state.transactionId) {
+ return
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry ??= {}
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] =
+ (connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] ?? 0) + state.consumed
+}
+
+interface IntervalMeterValue {
+ sampledValue: readonly IntervalSampledValue[]
+}
+
+interface IntervalSampledValue {
+ location?: string
+ measurand?: string
+ phase?: string
+ unit?: string
+ unitOfMeasure?: { multiplier?: number; unit?: string }
+ value: number | string
+}
+
+export const truncateTransactionIntervalValue = (value: number): number =>
+ Math.floor(Math.max(0, value) * 100) / 100
+
+export const getRepresentedTransactionIntervalEnergyWh = (
+ meterValue: IntervalMeterValue,
+ numberOfPhases: number,
+ inletToOutputEfficiency = 1
+): number => {
+ let representedEnergyWh = 0
+ for (const sampledValue of meterValue.sampledValue) {
+ if (sampledValue.measurand !== MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) continue
+ const value =
+ typeof sampledValue.value === 'number'
+ ? sampledValue.value
+ : Number.parseFloat(sampledValue.value)
+ if (!Number.isFinite(value)) continue
+ const unit = sampledValue.unitOfMeasure?.unit ?? sampledValue.unit
+ const unitMultiplier = unit === 'kWh' ? 1000 : unit === 'MWh' ? 1_000_000 : 1
+ const decimalMultiplier = 10 ** (sampledValue.unitOfMeasure?.multiplier ?? 0)
+ const phaseMultiplier = /^L[123](?:-N)?$/.test(sampledValue.phase ?? '') ? numberOfPhases : 1
+ const locationMultiplier =
+ sampledValue.location === 'Inlet' && inletToOutputEfficiency > 0 ? inletToOutputEfficiency : 1
+ representedEnergyWh = Math.max(
+ representedEnergyWh,
+ value * unitMultiplier * decimalMultiplier * phaseMultiplier * locationMultiplier
+ )
+ }
+ return representedEnergyWh
+}
+
+/**
+ * Commits the transaction interval state represented by an emitted MeterValue.
+ *
+ * The baseline, response-restoration accounting, and fractional carry must be
+ * updated together so retries cannot lose or duplicate interval energy.
+ * @param connectorStatus - Connector transaction state to update.
+ * @param meterValue - Emitted MeterValue containing interval samples.
+ * @param baselineKey - Configuration-scoped interval baseline key.
+ * @param intervalEnergyWh - Physical interval energy available before serialization.
+ * @param numberOfPhases - Station phase count used to expand per-phase samples.
+ * @param inletToOutputEfficiency - Efficiency for inlet samples represented at the output.
+ */
+export const recordTransactionIntervalEmission = (
+ connectorStatus: ConnectorStatus,
+ meterValue: IntervalMeterValue,
+ baselineKey: string,
+ intervalEnergyWh: number,
+ numberOfPhases: number,
+ inletToOutputEfficiency = 1
+): void => {
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines ??= {}
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines[baselineKey] =
+ connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ const representedEnergyWh = getRepresentedTransactionIntervalEnergyWh(
+ meterValue,
+ numberOfPhases,
+ inletToOutputEfficiency
+ )
+ recordTransactionIntervalConsumption(meterValue, baselineKey, representedEnergyWh)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry ??= {}
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] = Math.max(
+ 0,
+ intervalEnergyWh - representedEnergyWh
+ )
+}
+
+/**
+ * Commits a previously frozen interval snapshot against the current transaction state.
+ *
+ * Delivery may wait behind an earlier request whose definitive rejection restores energy,
+ * while the physical register may also continue advancing. Rebase the frozen snapshot's
+ * represented energy onto that current state so neither source of newly available energy is lost.
+ * @param connectorStatus - Connector transaction state to update.
+ * @param meterValue - Frozen MeterValue snapshot being emitted.
+ * @param baselineKey - Configuration-scoped interval baseline key.
+ * @param numberOfPhases - Station phase count used to expand per-phase samples.
+ * @param inletToOutputEfficiency - Efficiency for inlet samples represented at the output.
+ */
+export const recordFrozenTransactionIntervalEmission = (
+ connectorStatus: ConnectorStatus,
+ meterValue: IntervalMeterValue,
+ baselineKey: string,
+ numberOfPhases: number,
+ inletToOutputEfficiency = 1
+): void => {
+ const intervalEnergyWh =
+ Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ (connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[baselineKey] ?? 0)
+ ) + (connectorStatus.transactionEnergyActiveImportIntervalCarry?.[baselineKey] ?? 0)
+ recordTransactionIntervalEmission(
+ connectorStatus,
+ meterValue,
+ baselineKey,
+ intervalEnergyWh,
+ numberOfPhases,
+ inletToOutputEfficiency
+ )
+}
--- /dev/null
+import type { ConnectorStatus } from '../../types/index.js'
+
+import { isEmpty } from '../../utils/index.js'
+
+export interface TransactionMeterValueDelivery {
+ readonly markBuffered: () => void
+ readonly settle: (definitivelyRejected?: boolean) => void
+ readonly waitForSettlement: () => Promise<boolean>
+ readonly waitForTurn: () => Promise<void> | undefined
+}
+
+export interface TransactionMeterValueDependency {
+ readonly onDefinitiveRejection: (callback: () => void) => void
+ readonly settled: boolean
+ readonly waitForSettlement: () => Promise<boolean>
+}
+
+interface PendingTransactionMeterValueDelivery {
+ readonly dependency: TransactionMeterValueDependency
+ ready: boolean
+ readonly readyPromise: Promise<void>
+ rejected: boolean
+ readonly rejectionCallbacks: Set<() => void>
+ readonly resolveReady: () => void
+ settled: boolean
+ readonly turnPromise?: Promise<void>
+}
+
+/**
+ * Tracks transaction-scoped MeterValues work whose delivery outcome changes
+ * interval-energy or public-key state consumed by a terminal payload.
+ */
+export class TransactionMeterValueDeliveryBarrier {
+ private static readonly barriers = new WeakMap<
+ ConnectorStatus,
+ TransactionMeterValueDeliveryBarrier
+ >()
+
+ private readonly pendingByTransaction = new Map<
+ string,
+ Set<PendingTransactionMeterValueDelivery>
+ >()
+
+ public static begin (
+ connectorStatus: ConnectorStatus,
+ transactionId: number | string
+ ): TransactionMeterValueDelivery | undefined {
+ return TransactionMeterValueDeliveryBarrier.beginInternal(connectorStatus, transactionId, false)
+ }
+
+ public static beginIfIdle (
+ connectorStatus: ConnectorStatus,
+ transactionId: number | string
+ ): TransactionMeterValueDelivery | undefined {
+ return TransactionMeterValueDeliveryBarrier.beginInternal(connectorStatus, transactionId, true)
+ }
+
+ public static async wait (
+ connectorStatus: ConnectorStatus,
+ transactionId: number | string
+ ): Promise<readonly TransactionMeterValueDependency[]> {
+ const barrier = TransactionMeterValueDeliveryBarrier.barriers.get(connectorStatus)
+ if (barrier == null) return []
+ const dependencies = await barrier.wait(transactionId.toString())
+ if (isEmpty(barrier.pendingByTransaction)) {
+ TransactionMeterValueDeliveryBarrier.barriers.delete(connectorStatus)
+ }
+ return dependencies
+ }
+
+ private static beginInternal (
+ connectorStatus: ConnectorStatus,
+ transactionId: number | string,
+ requireIdle: boolean
+ ): TransactionMeterValueDelivery | undefined {
+ const normalizedTransactionId = transactionId.toString()
+ if (connectorStatus.transactionId?.toString() !== normalizedTransactionId) return
+
+ let barrier = TransactionMeterValueDeliveryBarrier.barriers.get(connectorStatus)
+ if (barrier == null) {
+ barrier = new TransactionMeterValueDeliveryBarrier()
+ TransactionMeterValueDeliveryBarrier.barriers.set(connectorStatus, barrier)
+ } else if (
+ requireIdle &&
+ (barrier.pendingByTransaction.get(normalizedTransactionId)?.size ?? 0) > 0
+ ) {
+ return
+ }
+ return barrier.begin(normalizedTransactionId)
+ }
+
+ private begin (transactionId: string): TransactionMeterValueDelivery {
+ const ready = Promise.withResolvers<undefined>()
+ const settlement = Promise.withResolvers<boolean>()
+ const rejectionCallbacks = new Set<() => void>()
+ let pending = this.pendingByTransaction.get(transactionId)
+ pending ??= new Set<PendingTransactionMeterValueDelivery>()
+ const predecessor = [...pending].at(-1)
+ const pendingDelivery: PendingTransactionMeterValueDelivery = {
+ dependency: {
+ onDefinitiveRejection: callback => {
+ if (pendingDelivery.rejected) {
+ callback()
+ } else if (!pendingDelivery.settled) {
+ rejectionCallbacks.add(callback)
+ }
+ },
+ get settled () {
+ return pendingDelivery.settled
+ },
+ waitForSettlement: () => settlement.promise,
+ },
+ ready: false,
+ readyPromise: ready.promise,
+ rejected: false,
+ rejectionCallbacks,
+ resolveReady: () => {
+ ready.resolve(undefined)
+ },
+ settled: false,
+ turnPromise: predecessor?.readyPromise,
+ }
+ pending.add(pendingDelivery)
+ this.pendingByTransaction.set(transactionId, pending)
+
+ const markReady = (): void => {
+ if (pendingDelivery.ready) return
+ pendingDelivery.ready = true
+ pendingDelivery.resolveReady()
+ }
+ return {
+ markBuffered: markReady,
+ settle: (definitivelyRejected = false) => {
+ if (pendingDelivery.settled) return
+ pendingDelivery.settled = true
+ pendingDelivery.rejected = definitivelyRejected
+ if (definitivelyRejected) {
+ for (const callback of rejectionCallbacks) callback()
+ }
+ rejectionCallbacks.clear()
+ settlement.resolve(definitivelyRejected)
+ pending.delete(pendingDelivery)
+ if (isEmpty(pending)) this.pendingByTransaction.delete(transactionId)
+ markReady()
+ },
+ waitForSettlement: () => settlement.promise,
+ waitForTurn: () => pendingDelivery.turnPromise,
+ }
+ }
+
+ private async wait (transactionId: string): Promise<readonly TransactionMeterValueDependency[]> {
+ const dependencies = new Set<TransactionMeterValueDependency>()
+ for (;;) {
+ const pending = this.pendingByTransaction.get(transactionId)
+ if (pending == null || isEmpty(pending)) return [...dependencies]
+ const snapshot = [...pending]
+ await Promise.all(snapshot.map(delivery => delivery.readyPromise))
+ for (const delivery of snapshot) {
+ if (!delivery.settled) dependencies.add(delivery.dependency)
+ }
+ const current = this.pendingByTransaction.get(transactionId)
+ if (current == null || [...current].every(delivery => delivery.ready)) {
+ return [...dependencies]
+ }
+ }
+ }
+}
// Copyright Jerome Benoit. 2021-2025. All Rights Reserved.
/**
- * @file Coherent MeterValues module barrel.
- * @description Physics-based coherent MeterValues generation for the
- * OCPP simulator. Opt-in via the template flag `coherentMeterValues`;
+ * @file MeterValues component barrel — public API surface.
+ * @description Physics-based coherent MeterValues generation plus the shared
+ * transaction MeterValue delivery/interval-accounting helpers for the OCPP
+ * simulator. This barrel is the single entry point for the `meter-values`
+ * component: cross-component callers (`ocpp/`, `broadcast-channel/`, the
+ * `charging-station/` root) import from here, never from the internal
+ * sub-modules.
+ *
+ * Coherent generation is opt-in via the template flag `coherentMeterValues`;
* when disabled or absent, the random/fixed measurand generation is used
* unchanged (see `../ocpp/OCPPServiceUtils.ts`).
*
- * Internal helpers are intentionally not re-exported; tests and internal
- * callers should import them directly from the owning sub-module.
- *
* Module layout:
* - {@link ./CoherentSession} — session lifecycle
- * (`createCoherentSession`, `CreateSessionOptions`), the strategy-gate
- * type guard `isCoherentModeActive`, and the root-seed resolver
- * `resolveRootSeed`.
+ * (`createCoherentSession`), the strategy-gate type guard
+ * `isCoherentModeActive`, and the root-seed resolver `resolveRootSeed`.
* - {@link ./CoherentSampleComputer} — physics chain V→P→I→ΔE→SoC
- * (INV-1/2/3 by construction), energy-register advance, and the
- * module-scope runtime WeakMap (`disposeCoherentSessionRuntime`).
+ * (INV-1/2/3 by construction), energy-register advance, shared-energy
+ * proration, and the module-scope runtime WeakMap teardown
+ * (`disposeCoherentSessionRuntime`).
* - {@link ./CoherentMeterValueBuilder} — emit order, phase families,
* unit conversion, OCPP MeterValue assembly.
- * - {@link ./PRNG} - PRNG primitives (`mulberry32`, `hashLabel`,
+ * - {@link ./MeterValueUtils} — measurand unit families, unit-divider and
+ * line-phase resolution, custom-data canonicalization.
+ * - {@link ./TransactionIntervalUtils} — transaction interval energy
+ * accounting (baseline/carry/represented) and its capture/restore state.
+ * - {@link ./TransactionMeterValueDeliveryBarrier} — per-connector
+ * transaction MeterValue delivery barrier and predecessor dependencies.
+ * - {@link ./EvProfiles} — EV charging profile loading.
+ * - {@link ./PRNG} — PRNG primitives (`mulberry32`, `hashLabel`,
* `deriveSeed`, `createStreamPrng`).
*/
-export { buildCoherentMeterValue } from './CoherentMeterValueBuilder.js'
+export {
+ buildCoherentMeterValue,
+ buildCoherentMeterValueSnapshot,
+} from './CoherentMeterValueBuilder.js'
export type { BuildVersionedSampledValue } from './CoherentMeterValueBuilder.js'
-export { disposeCoherentSessionRuntime } from './CoherentSampleComputer.js'
+export {
+ advanceConnectorEnergyRegister,
+ advanceStationEnergyRegister,
+ advanceTransactionEnergyRegister,
+ computeCoherentSampleAtTime,
+ consumePendingSharedEnergy,
+ disposeCoherentSessionRuntime,
+ recordPendingSharedEnergy,
+} from './CoherentSampleComputer.js'
export { createCoherentSession, isCoherentModeActive, resolveRootSeed } from './CoherentSession.js'
export { loadEvProfilesFile } from './EvProfiles.js'
+export {
+ areMeterValueUnitsCompatible,
+ buildSampledValueFamilyKey,
+ canonicalizeCustomData,
+ getMeterValueUnitFamily,
+ type MeterValueUnitFamily,
+ resolveLinePhaseIndex,
+ resolveMeterValueUnitDivider,
+} from './MeterValueUtils.js'
+export {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ getRepresentedTransactionIntervalEnergyWh,
+ getTransactionIntervalConsumptions,
+ recordFrozenTransactionIntervalEmission,
+ recordTransactionIntervalConsumption,
+ recordTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ restoreTransactionIntervalState,
+ truncateTransactionIntervalValue,
+} from './TransactionIntervalUtils.js'
+export {
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+ type TransactionMeterValueDependency,
+} from './TransactionMeterValueDeliveryBarrier.js'
export type { CoherentSession, EvProfile, EvProfilesFile, ICoherentContext } from './types.js'
export interface CoherentSession {
readonly connectorId: number
readonly currentType: CurrentType
+ readonly evseId?: number
readonly numberOfPhases: number
readonly profile: EvProfile
readonly rampUpDurationMs: number
* the physics chain needs to query about the station itself.
*/
export interface ICoherentContext {
- getConnectorMaximumAvailablePower: (connectorId: number) => number
- getConnectorStatus: (connectorId: number) => ConnectorStatus | undefined
+ getConnectorMaximumAvailablePower: (connectorId: number, evseId?: number) => number
+ getConnectorStatus: (connectorId: number, evseId?: number) => ConnectorStatus | undefined
getEvseIdByConnectorId: (connectorId: number) => number | undefined
getEvseStatus: (evseId: number) => EvseStatus | undefined
getNumberOfPhases: () => number
resetAuthorizeConnectorStatus,
setConfigurationKeyValue,
} from '../../../charging-station/index.js'
+import {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ recordFrozenTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ restoreTransactionIntervalState,
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+} from '../../../charging-station/meter-values/index.js'
import { OCPPError } from '../../../exception/index.js'
import {
type ChangeConfigurationRequest,
type OCPP16SendLocalListRequest,
type OCPP16SendLocalListResponse,
OCPP16StandardParametersKey,
- type OCPP16StartTransactionRequest,
- type OCPP16StartTransactionResponse,
type OCPP16StatusNotificationResponse,
OCPP16StopTransactionReason,
OCPP16SupportedFeatureProfiles,
isIncomingRequestCommandSupported,
isMessageTriggerSupported,
} from '../OCPPServiceUtils.js'
+import {
+ claimPublicKeyDelivery,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
+} from '../OCPPSignedMeterValueUtils.js'
import { OCPP16Constants } from './OCPP16Constants.js'
import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js'
* set from OCPP 1.6 code directly.
*/
stopped?: boolean
+ triggeredMeterValueTargets?: Set<TriggeredMeterValueTarget>
+}
+
+interface TriggeredMeterValueTarget {
+ cancelled?: boolean
+ readonly connectorStatus: ConnectorStatus
+ readonly delivery?: TransactionMeterValueDelivery
+ readonly intervalState?: ReturnType<typeof captureTransactionIntervalState>
+ readonly publicKeyIncluded: boolean
+ readonly request: OCPP16MeterValuesRequest
+ reservationReleased?: boolean
+ readonly transactionId?: number
+ readonly triggerRequest: OCPP16TriggerMessageRequest
}
/**
OCPP16IncomingRequestCommand.REMOTE_STOP_TRANSACTION,
]
+ private readonly pendingResetActions = new WeakMap<
+ ResetRequest,
+ (lifecycleSignal: AbortSignal) => Promise<void>
+ >()
+
+ private readonly pendingTriggeredMeterValues = new WeakMap<
+ ConnectorStatus,
+ Set<number | undefined>
+ >()
+
+ private readonly triggerMeterValuesReservations = new WeakMap<
+ OCPP16TriggerMessageRequest,
+ readonly TriggeredMeterValueTarget[]
+ >()
+
/**
* Constructs an OCPP 1.6 Incoming Request Service with request handlers, validators, and event listeners.
*/
this.ajv
)
// Handle incoming request events
+ this.on(
+ OCPP16IncomingRequestCommand.RESET,
+ (chargingStation: ChargingStation, request: ResetRequest, response: GenericResponse) => {
+ const action = this.pendingResetActions.get(request)
+ this.pendingResetActions.delete(request)
+ if (response.status !== GenericStatus.Accepted || action == null) return
+ this.runOutsideIncomingRequestContext(() => {
+ const lifecycleSignal = chargingStation.lifecycleAbortSignal
+ action(lifecycleSignal).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: Reset error:`,
+ error
+ )
+ })
+ })
+ }
+ )
this.on(
OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
(
response: GenericResponse
) => {
if (response.status === GenericStatus.Accepted) {
- const { connectorId, idTag } = request
- if (connectorId != null) {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus != null) {
- connectorStatus.transactionRemoteStarted = true
- }
+ const { chargingProfile, connectorId, idTag } = request
+ if (connectorId == null) return
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ connectorStatus.transactionRemoteStarted = true
}
- chargingStation.ocppRequestService
- .requestHandler<Partial<OCPP16StartTransactionRequest>, OCPP16StartTransactionResponse>(
- chargingStation,
- OCPP16RequestCommand.START_TRANSACTION,
- {
- connectorId,
- idTag,
- }
+ if (chargingProfile != null) {
+ OCPP16ServiceUtils.setChargingProfile(chargingStation, connectorId, chargingProfile)
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: Charging profile(s) set at delivered remote start transaction on ${
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ chargingStation.stationInfo?.chargingStationId
+ }#${connectorId.toString()}`,
+ chargingProfile
)
+ }
+ OCPP16ServiceUtils.startTransactionOnConnector(chargingStation, connectorId, idTag)
.then(response => {
if (response.idTagInfo.status === OCPP16AuthorizationStatus.ACCEPTED) {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.constructor: Remote start transaction ACCEPTED on ${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
chargingStation.stationInfo?.chargingStationId
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- }#${connectorId?.toString()} for idTag '${truncateId(idTag)}'`
+ }#${connectorId.toString()} for idTag '${truncateId(idTag)}'`
)
} else {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.constructor: Remote start transaction REJECTED on ${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
chargingStation.stationInfo?.chargingStationId
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- }#${connectorId?.toString()} for idTag '${truncateId(idTag)}'`
+ }#${connectorId.toString()} for idTag '${truncateId(idTag)}'`
)
}
return undefined
// after an exception unwind of the FTP path) does not
// leak to the CSMS.
const diagnosticsInProgress =
- this.stationsState.get(chargingStation)?.diagnosticsUploadInProgress === true
+ this.getStationState(chargingStation)?.diagnosticsUploadInProgress === true
const diagnosticsStatus = chargingStation.stationInfo?.diagnosticsStatus
const diagnosticsTriggerStatus =
diagnosticsInProgress && diagnosticsStatus === OCPP16DiagnosticsStatus.Uploading
// 'Downloading' after an exception unwind of
// updateFirmwareSimulation) does not leak to the CSMS.
const firmwareInProgress =
- this.stationsState.get(chargingStation)?.firmwareUpdateInProgress === true
+ this.getStationState(chargingStation)?.firmwareUpdateInProgress === true
const firmwareStatus = chargingStation.stationInfo?.firmwareStatus
const firmwareTriggerStatus =
firmwareInProgress &&
)
.catch(errorHandler)
break
- case OCPP16MessageTrigger.MeterValues:
- if (connectorId != null) {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (
- connectorStatus?.transactionStarted === true &&
- connectorStatus.transactionId != null
- ) {
- const transactionId = convertToInt(connectorStatus.transactionId)
- const meterValue = buildMeterValue(
- chargingStation,
- transactionId,
- 0
- ) as OCPP16MeterValue
- OCPP16ServiceUtils.appendSignedUpdatedReadings(
- chargingStation,
- connectorId,
- transactionId,
- meterValue,
- OCPP16MeterValueContext.TRIGGER
- )
+ case OCPP16MessageTrigger.MeterValues: {
+ const targets = this.triggerMeterValuesReservations.get(request)
+ this.triggerMeterValuesReservations.delete(request)
+ for (const target of targets ?? []) {
+ const { connectorStatus, delivery, intervalState, publicKeyIncluded } = target
+ const { transactionId } = target.request
+ ;(async () => {
+ const deliveryTurn = delivery?.waitForTurn()
+ if (deliveryTurn != null) await deliveryTurn
+ if (target.cancelled === true) return
+ if (
+ intervalState != null &&
+ connectorStatus.transactionId?.toString() === intervalState.transactionId
+ ) {
+ recordFrozenTransactionIntervalEmission(
+ connectorStatus,
+ target.request.meterValue[0],
+ 'default',
+ chargingStation.getNumberOfPhases(),
+ resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency
+ )
+ )
+ completeTransactionIntervalState(
+ intervalState,
+ 'default',
+ target.request.meterValue
+ )
+ }
+ const publicKeyDeliveryToken =
+ transactionId != null
+ ? claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ target.request,
+ publicKeyIncluded
+ )
+ : undefined
+ const deliveryState = {
+ buffered: false,
+ callError: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ }
+ let deliverySettled = false
+ const markDeliverySettled = (definitivelyRejected = false): void => {
+ if (deliverySettled) return
+ deliverySettled = true
+ this.releaseTriggeredMeterValueTarget(
+ chargingStation,
+ target,
+ definitivelyRejected
+ )
+ }
+ let restored = false
+ const restoreRejectedDelivery = (): void => {
+ if (restored) return
+ restored = true
+ if (intervalState != null) {
+ restoreTransactionIntervalState(intervalState, connectorStatus, 'default')
+ }
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
chargingStation.ocppRequestService
.requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
chargingStation,
OCPP16RequestCommand.METER_VALUES,
+ target.request,
{
- connectorId,
- meterValue: [meterValue],
- transactionId,
- },
- {
+ onError: (_error, isCallError) => {
+ deliveryState.callError ||= isCallError
+ if (isCallError) {
+ if (!deliveryState.transportErrorAmbiguous) restoreRejectedDelivery()
+ markDeliverySettled(!deliveryState.transportErrorAmbiguous)
+ }
+ },
+ onMessageSent: () => {
+ deliveryState.sent = true
+ },
+ onRequestBuffered: () => {
+ deliveryState.buffered = true
+ delivery?.markBuffered()
+ },
+ onResponseReceived: () => {
+ deliveryState.responseReceived = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ },
+ onTransportError: (_error, deliveryAmbiguous) => {
+ deliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ if (deliveryAmbiguous) retainPublicKeyDelivery(publicKeyDeliveryToken)
+ },
triggerMessage: true,
}
)
- .catch(errorHandler)
- }
- } else {
- for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(
- true
- )) {
- if (
- connectorStatus.transactionStarted === true &&
- connectorStatus.transactionId != null
- ) {
- const transactionId = convertToInt(connectorStatus.transactionId)
- const meterValue = buildMeterValue(
- chargingStation,
- transactionId,
- 0
- ) as OCPP16MeterValue
- OCPP16ServiceUtils.appendSignedUpdatedReadings(
- chargingStation,
- connectorId,
- transactionId,
- meterValue,
- OCPP16MeterValueContext.TRIGGER
- )
- chargingStation.ocppRequestService
- .requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
- chargingStation,
- OCPP16RequestCommand.METER_VALUES,
- {
- connectorId,
- meterValue: [meterValue],
- transactionId,
- },
- {
- triggerMessage: true,
- }
- )
- .catch(errorHandler)
- }
- }
+ .then(() => {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ return undefined
+ })
+ .catch((error: unknown) => {
+ if (
+ !deliveryState.buffered &&
+ !deliveryState.callError &&
+ !deliveryState.responseReceived &&
+ !deliveryState.sent &&
+ !deliveryState.transportErrorAmbiguous
+ ) {
+ restoreRejectedDelivery()
+ } else if (
+ deliveryState.sent ||
+ deliveryState.responseReceived ||
+ deliveryState.transportErrorAmbiguous
+ ) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ if (!deliveryState.buffered) markDeliverySettled()
+ errorHandler(error)
+ })
+ })().catch((error: unknown) => {
+ this.releaseTriggeredMeterValueTarget(chargingStation, target, true)
+ errorHandler(error)
+ })
}
break
+ }
case OCPP16MessageTrigger.StatusNotification:
if (connectorId != null) {
chargingStation.ocppRequestService
return {}
}
+ /**
+ * Measures exact MeterValues snapshots retained by TriggerMessage delivery callbacks.
+ * @param commandName - Incoming command associated with the response.
+ * @param commandPayload - Exact incoming request payload owning the reservation.
+ * @returns Serialized bytes retained by the reservation.
+ */
+ protected override getResponseDeliveryRetainedBytes (
+ commandName: IncomingRequestCommand,
+ commandPayload: JsonType
+ ): number {
+ if (commandName !== OCPP16IncomingRequestCommand.TRIGGER_MESSAGE) return 0
+ const targets = this.triggerMeterValuesReservations.get(
+ commandPayload as OCPP16TriggerMessageRequest
+ )
+ return targets == null
+ ? 0
+ : Buffer.byteLength(JSON.stringify(targets.map(({ request }) => request)), 'utf8')
+ }
+
/**
* Checks whether the given incoming request command is supported by the charging station.
* @param chargingStation - Target charging station
return isIncomingRequestCommandSupported(chargingStation, commandName)
}
+ protected override onResponseSendError (
+ chargingStation: ChargingStation,
+ commandName: IncomingRequestCommand,
+ commandPayload: JsonType
+ ): void {
+ if (commandName === OCPP16IncomingRequestCommand.RESET) {
+ this.pendingResetActions.delete(commandPayload as ResetRequest)
+ return
+ }
+ if (commandName !== OCPP16IncomingRequestCommand.TRIGGER_MESSAGE) return
+ const request = commandPayload as OCPP16TriggerMessageRequest
+ const targets = this.triggerMeterValuesReservations.get(request)
+ if (targets == null) return
+ this.triggerMeterValuesReservations.delete(request)
+ for (const target of targets) {
+ this.releaseTriggeredMeterValueTarget(chargingStation, target, true)
+ }
+ }
+
/**
* Release resources held by the per-station state. Called by the
* inherited `stop()` template BEFORE the base marks the WeakMap
protected override resetStationState (stationState: OCPP16StationState): void {
stationState.activeDiagnosticsAbortController?.abort()
this.cancelDeferredFirmwareUpdate(stationState)
+ for (const target of stationState.triggeredMeterValueTargets ?? []) {
+ this.triggerMeterValuesReservations.delete(target.triggerRequest)
+ target.cancelled = true
+ this.releaseTriggeredMeterValueTarget(undefined, target, true, stationState)
+ }
}
/**
}
await chargingStation.removeReservation(
reservation,
- ReservationTerminationReason.RESERVATION_CANCELED
+ ReservationTerminationReason.RESERVATION_CANCELED,
+ () => this.isIncomingRequestDeliveryCurrent(chargingStation)
)
return OCPP16Constants.OCPP_CANCEL_RESERVATION_RESPONSE_ACCEPTED
} catch (error) {
.map(({ connectorId }) => connectorId)
.toArray(),
chargePointStatus,
- type
+ type,
+ () => this.isIncomingRequestDeliveryCurrent(chargingStation)
)
return response
} else if (
if (connectorStatus != null) {
connectorStatus.availability = type
}
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: chargePointStatus,
- })
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ { connectorId, status: chargePointStatus },
+ {
+ isOperationCurrent: () => this.isIncomingRequestDeliveryCurrent(chargingStation),
+ send: true,
+ }
+ )
return OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_ACCEPTED
}
return OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_REJECTED
...(isNotEmptyString(uri.username) && { user: uri.username }),
...(isNotEmptyString(uri.password) && { password: uri.password }),
})
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ ftpClient.close()
+ return OCPP16Constants.OCPP_RESPONSE_EMPTY
+ }
let uploadResponse: FTPResponse | undefined
if (accessResponse.code === 220) {
ftpClient.trackProgress(info => {
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) return
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestGetDiagnostics: ${(
info.bytes / 1024
join(resolve(dirname(fileURLToPath(import.meta.url)), '../'), diagnosticsArchive),
`${uri.pathname}${diagnosticsArchive}`
)
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ ftpClient.close()
+ return OCPP16Constants.OCPP_RESPONSE_EMPTY
+ }
if (uploadResponse.code === 226) {
await chargingStation.ocppRequestService.requestHandler<
OCPP16DiagnosticsStatusNotificationRequest,
OCPP16IncomingRequestCommand.GET_DIAGNOSTICS
)
} catch (error) {
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ ftpClient?.close()
+ return OCPP16Constants.OCPP_RESPONSE_EMPTY
+ }
await chargingStation.ocppRequestService.requestHandler<
OCPP16DiagnosticsStatusNotificationRequest,
OCPP16DiagnosticsStatusNotificationResponse
>(chargingStation, OCPP16RequestCommand.DIAGNOSTICS_STATUS_NOTIFICATION, {
status: OCPP16DiagnosticsStatus.UploadFailed,
})
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESPONSE_EMPTY
+ }
if (chargingStation.stationInfo != null) {
chargingStation.stationInfo.diagnosticsStatus = OCPP16DiagnosticsStatus.UploadFailed
}
idTag
)
}
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESPONSE_REJECTED
+ }
if (
chargingProfile != null &&
- !this.setRemoteStartTransactionChargingProfile(
- chargingStation,
- transactionConnectorId,
- chargingProfile
- )
+ !this.isRemoteStartTransactionChargingProfileValid(chargingStation, chargingProfile)
) {
return this.notifyRemoteStartTransactionRejected(
chargingStation,
if (!(await isIdTagAuthorized(chargingStation, connectorId, idTag, AuthContext.RESERVATION))) {
return OCPP16Constants.OCPP_RESERVATION_RESPONSE_REJECTED
}
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESERVATION_RESPONSE_REJECTED
+ }
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
if (connectorStatus == null) {
return OCPP16Constants.OCPP_RESERVATION_RESPONSE_REJECTED
resetAuthorizeConnectorStatus(connectorStatus)
let response: OCPP16ReserveNowResponse
try {
- await removeExpiredReservations(chargingStation)
+ await removeExpiredReservations(chargingStation, () =>
+ this.isIncomingRequestDeliveryCurrent(chargingStation)
+ )
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESERVATION_RESPONSE_REJECTED
+ }
switch (connectorStatus.status) {
case OCPP16ChargePointStatus.Charging:
case OCPP16ChargePointStatus.Finishing:
response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_OCCUPIED
break
}
- await chargingStation.addReservation({
- id: commandPayload.reservationId,
- ...commandPayload,
- })
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESERVATION_RESPONSE_REJECTED
+ }
+ await chargingStation.addReservation(
+ {
+ id: commandPayload.reservationId,
+ ...commandPayload,
+ },
+ () => this.isIncomingRequestDeliveryCurrent(chargingStation)
+ )
response = OCPP16Constants.OCPP_RESERVATION_RESPONSE_ACCEPTED
break
}
const { type } = commandPayload
const reason = `${type}Reset` as OCPP16StopTransactionReason
const graceful = reason === OCPP16StopTransactionReason.SOFT_RESET
- chargingStation.reset(reason, graceful).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Reset error:`,
- error
- )
+ this.pendingResetActions.set(commandPayload, async lifecycleSignal => {
+ if (chargingStation.lifecycleAbortSignal !== lifecycleSignal || lifecycleSignal.aborted) {
+ return
+ }
+ await chargingStation.reset(reason, graceful)
})
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: ${type} reset request received, simulating it. The station will be back online in ${formatDurationMilliSeconds(
case OCPP16MessageTrigger.DiagnosticsStatusNotification:
case OCPP16MessageTrigger.FirmwareStatusNotification:
case OCPP16MessageTrigger.Heartbeat:
- case OCPP16MessageTrigger.MeterValues:
case OCPP16MessageTrigger.StatusNotification:
return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_ACCEPTED
+ case OCPP16MessageTrigger.MeterValues: {
+ const candidates =
+ connectorId != null
+ ? [{ connectorId, connectorStatus: chargingStation.getConnectorStatus(connectorId) }]
+ : [...chargingStation.iterateConnectors(true)]
+ if (isEmpty(candidates)) return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+
+ const plannedTransactions = new Map<ConnectorStatus, Set<number | undefined>>()
+ const resolvedCandidates: {
+ readonly connectorId: number
+ readonly connectorStatus: ConnectorStatus
+ readonly transactionId?: number
+ }[] = []
+ try {
+ for (const target of candidates) {
+ if (
+ target.connectorStatus == null ||
+ target.connectorStatus.transactionEnding === true
+ ) {
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ const transactionId =
+ target.connectorStatus.transactionStarted === true &&
+ target.connectorStatus.transactionId != null
+ ? convertToInt(target.connectorStatus.transactionId)
+ : undefined
+ const plannedForConnector = plannedTransactions.get(target.connectorStatus) ?? new Set()
+ if (
+ this.pendingTriggeredMeterValues.get(target.connectorStatus)?.has(transactionId) ===
+ true ||
+ plannedForConnector.has(transactionId)
+ ) {
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ plannedForConnector.add(transactionId)
+ plannedTransactions.set(target.connectorStatus, plannedForConnector)
+ resolvedCandidates.push({
+ connectorId: target.connectorId,
+ connectorStatus: target.connectorStatus,
+ transactionId,
+ })
+ }
+ } catch {
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+
+ const targets: TriggeredMeterValueTarget[] = []
+ for (const target of resolvedCandidates) {
+ const delivery =
+ target.transactionId != null
+ ? TransactionMeterValueDeliveryBarrier.begin(
+ target.connectorStatus,
+ target.transactionId
+ )
+ : undefined
+ if (target.transactionId != null && delivery == null) {
+ for (const reservedTarget of targets) reservedTarget.delivery?.settle(true)
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ try {
+ const intervalState =
+ target.transactionId != null
+ ? captureTransactionIntervalState(target.connectorStatus)
+ : undefined
+ const meterValue = buildMeterValue(
+ chargingStation,
+ target.transactionId,
+ 0,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueContext.TRIGGER,
+ false,
+ { advanceEnergy: true, connectorId: target.connectorId, snapshot: true }
+ ) as OCPP16MeterValue
+ if (!isNotEmptyArray(meterValue.sampledValue)) {
+ delivery?.settle(true)
+ for (const reservedTarget of targets) reservedTarget.delivery?.settle(true)
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ const publicKeyIncluded =
+ target.transactionId != null &&
+ OCPP16ServiceUtils.appendSignedUpdatedReadings(
+ chargingStation,
+ target.connectorId,
+ target.transactionId,
+ meterValue,
+ OCPP16MeterValueContext.TRIGGER,
+ false
+ )
+ const request: OCPP16MeterValuesRequest = {
+ connectorId: target.connectorId,
+ meterValue: [meterValue],
+ ...(target.transactionId != null && { transactionId: target.transactionId }),
+ }
+ targets.push({
+ connectorStatus: target.connectorStatus,
+ delivery,
+ intervalState,
+ publicKeyIncluded,
+ request,
+ transactionId: target.transactionId,
+ triggerRequest: commandPayload,
+ })
+ } catch {
+ delivery?.settle(true)
+ for (const reservedTarget of targets) reservedTarget.delivery?.settle(true)
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ }
+
+ const stationState = this.getOrCreateStationState(chargingStation)
+ if (stationState.stopped === true) {
+ for (const target of targets) target.delivery?.settle(true)
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_REJECTED
+ }
+ stationState.triggeredMeterValueTargets ??= new Set()
+ for (const target of targets) {
+ const pendingTransactionIds =
+ this.pendingTriggeredMeterValues.get(target.connectorStatus) ?? new Set()
+ pendingTransactionIds.add(target.transactionId)
+ this.pendingTriggeredMeterValues.set(target.connectorStatus, pendingTransactionIds)
+ stationState.triggeredMeterValueTargets.add(target)
+ }
+ this.triggerMeterValuesReservations.set(commandPayload, targets)
+ return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_ACCEPTED
+ }
default:
return OCPP16Constants.OCPP_TRIGGER_MESSAGE_RESPONSE_NOT_IMPLEMENTED
}
return OCPP16Constants.OCPP_RESPONSE_UNLOCK_NOT_SUPPORTED
}
if (chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true) {
- const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
- chargingStation,
- connectorId,
- OCPP16StopTransactionReason.UNLOCK_COMMAND
- )
- if (stopResponse.idTagInfo?.status === OCPP16AuthorizationStatus.ACCEPTED) {
- return OCPP16Constants.OCPP_RESPONSE_UNLOCKED
+ try {
+ const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
+ chargingStation,
+ connectorId,
+ OCPP16StopTransactionReason.UNLOCK_COMMAND
+ )
+ if (stopResponse.idTagInfo?.status === OCPP16AuthorizationStatus.ACCEPTED) {
+ return OCPP16Constants.OCPP_RESPONSE_UNLOCKED
+ }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Error while stopping transaction on connector ${connectorId.toString()}:`,
+ error
+ )
}
return OCPP16Constants.OCPP_RESPONSE_UNLOCK_FAILED
}
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: OCPP16ChargePointStatus.Available,
- })
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ { connectorId, status: OCPP16ChargePointStatus.Available },
+ {
+ isOperationCurrent: () => this.isIncomingRequestDeliveryCurrent(chargingStation),
+ send: true,
+ }
+ )
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return OCPP16Constants.OCPP_RESPONSE_UNLOCK_FAILED
+ }
chargingStation.unlockConnector(connectorId)
return OCPP16Constants.OCPP_RESPONSE_UNLOCKED
}
return OCPP16Constants.OCPP_RESPONSE_EMPTY
}
commandPayload.retrieveDate = convertToDate(commandPayload.retrieveDate) ?? new Date()
- if (this.stationsState.get(chargingStation)?.firmwareUpdateInProgress === true) {
+ if (this.getStationState(chargingStation)?.firmwareUpdateInProgress === true) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestUpdateFirmware: Cannot simulate firmware update: firmware update is already in progress`
)
return OCPP16Constants.OCPP_RESPONSE_EMPTY
}
+ private isRemoteStartTransactionChargingProfileValid (
+ chargingStation: ChargingStation,
+ chargingProfile: OCPP16ChargingProfile
+ ): boolean {
+ const valid =
+ chargingProfile.chargingProfilePurpose === OCPP16ChargingProfilePurposeType.TX_PROFILE &&
+ chargingProfile.transactionId == null
+ if (!valid) {
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.isRemoteStartTransactionChargingProfileValid: Not allowed to set ${
+ chargingProfile.chargingProfilePurpose
+ } charging profile(s)${chargingProfile.transactionId != null ? ' with transactionId set' : ''} at remote start transaction`
+ )
+ }
+ return valid
+ }
+
private notifyRemoteStartTransactionRejected (
chargingStation: ChargingStation,
connectorId: number,
return OCPP16Constants.OCPP_RESPONSE_REJECTED
}
- private setRemoteStartTransactionChargingProfile (
- chargingStation: ChargingStation,
- connectorId: number,
- chargingProfile: OCPP16ChargingProfile
- ): boolean {
- if (
- chargingProfile.chargingProfilePurpose === OCPP16ChargingProfilePurposeType.TX_PROFILE &&
- chargingProfile.transactionId == null
- ) {
- OCPP16ServiceUtils.setChargingProfile(chargingStation, connectorId, chargingProfile)
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.setRemoteStartTransactionChargingProfile: Charging profile(s) set at remote start transaction on ${
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- chargingStation.stationInfo?.chargingStationId
- }#${connectorId.toString()}`,
- chargingProfile
- )
- return true
+ private releaseTriggeredMeterValueTarget (
+ chargingStation: ChargingStation | undefined,
+ target: TriggeredMeterValueTarget,
+ definitivelyRejected: boolean,
+ stationState = chargingStation == null ? undefined : this.getStationState(chargingStation)
+ ): void {
+ if (target.reservationReleased === true) return
+ target.reservationReleased = true
+ target.delivery?.settle(definitivelyRejected)
+ const pendingTransactionIds = this.pendingTriggeredMeterValues.get(target.connectorStatus)
+ pendingTransactionIds?.delete(target.transactionId)
+ if (isEmpty(pendingTransactionIds)) {
+ this.pendingTriggeredMeterValues.delete(target.connectorStatus)
+ }
+ stationState?.triggeredMeterValueTargets?.delete(target)
+ if (stationState?.triggeredMeterValueTargets?.size === 0) {
+ delete stationState.triggeredMeterValueTargets
}
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.setRemoteStartTransactionChargingProfile: Not allowed to set ${
- chargingProfile.chargingProfilePurpose
- } charging profile(s)${chargingProfile.transactionId != null ? ' with transactionId set' : ''} at remote start transaction`
- )
- return false
}
private async updateFirmwareSimulation (
type OCPP16StatusNotificationRequest,
OCPPVersion,
} from '../../../types/index.js'
-import { assertIsJsonObject, logger } from '../../../utils/index.js'
+import { assertIsJsonObject, Constants, logger } from '../../../utils/index.js'
import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js'
import { OCPPRequestService } from '../OCPPRequestService.js'
import { createPayloadValidatorMap } from '../OCPPServiceUtils.js'
commandName: OCPP16RequestCommand,
commandParams?: JsonType
): JsonType {
- let connectorId: number | undefined
- let energyActiveImportRegister: number
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Building '${commandName}' payload`
)
errorCode: ChargePointErrorCode.NO_ERROR,
...params,
} as OCPP16StatusNotificationRequest)
- case OCPP16RequestCommand.STOP_TRANSACTION:
- ;(chargingStation.stationInfo?.transactionDataMeterValues === true ||
- OCPP16ServiceUtils.isSigningEnabled(chargingStation)) &&
- (connectorId = chargingStation.getConnectorIdByTransactionId(
- params.transactionId as number
- ))
- energyActiveImportRegister = chargingStation.getEnergyActiveImportRegisterByTransactionId(
- params.transactionId as number,
- true
- )
- {
- let transactionData: OCPP16MeterValue[] | undefined
- const transactionDataExplicit =
- chargingStation.stationInfo?.transactionDataMeterValues === true
- const signingForcesTransactionData = OCPP16ServiceUtils.isSigningEnabled(chargingStation)
- if ((transactionDataExplicit || signingForcesTransactionData) && connectorId != null) {
- if (transactionDataExplicit) {
+ case OCPP16RequestCommand.STOP_TRANSACTION: {
+ const transactionId = params.transactionId as number
+ const hasIdTag = Object.hasOwn(params, 'idTag')
+ const hasMeterStop = Object.hasOwn(params, 'meterStop')
+ const hasTimestamp = Object.hasOwn(params, 'timestamp')
+ const hasTransactionData = Object.hasOwn(params, 'transactionData')
+ const transactionDataEnabled =
+ chargingStation.stationInfo?.transactionDataMeterValues === true
+ const signingForcesTransactionData = OCPP16ServiceUtils.isSigningEnabled(chargingStation)
+ const buildTransactionData =
+ !hasTransactionData && (transactionDataEnabled || signingForcesTransactionData)
+ const connectorId = buildTransactionData
+ ? chargingStation.getConnectorIdByTransactionId(transactionId)
+ : undefined
+ const meterStop = hasMeterStop
+ ? (params.meterStop as number)
+ : chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId, true)
+ let transactionData: OCPP16MeterValue[] | undefined
+
+ if (buildTransactionData && connectorId != null) {
+ if (transactionDataEnabled) {
+ transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+ chargingStation.getConnectorStatus(connectorId)
+ ?.transactionBeginMeterValue as OCPP16MeterValue,
+ OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ connectorId,
+ meterStop
+ )
+ )
+ } else {
+ try {
transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
chargingStation.getConnectorStatus(connectorId)
?.transactionBeginMeterValue as OCPP16MeterValue,
OCPP16ServiceUtils.buildTransactionEndMeterValue(
chargingStation,
connectorId,
- energyActiveImportRegister
+ meterStop
)
)
- } else {
- try {
- transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
- chargingStation.getConnectorStatus(connectorId)
- ?.transactionBeginMeterValue as OCPP16MeterValue,
- OCPP16ServiceUtils.buildTransactionEndMeterValue(
- chargingStation,
- connectorId,
- energyActiveImportRegister
- )
- )
- } catch (error) {
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Failed to build signed transaction data meter values for StopTransaction:`,
- error
- )
- }
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Failed to build signed transaction data meter values for StopTransaction:`,
+ error
+ )
}
}
- return {
- idTag: chargingStation.getTransactionIdTag(params.transactionId as number),
- meterStop: energyActiveImportRegister,
- timestamp: new Date(),
- ...(transactionData != null && { transactionData }),
- ...params,
- }
}
+ return {
+ ...(!hasIdTag && { idTag: chargingStation.getTransactionIdTag(transactionId) }),
+ ...(!hasMeterStop && { meterStop }),
+ ...(!hasTimestamp && { timestamp: new Date() }),
+ ...(transactionData != null && { transactionData }),
+ ...params,
+ }
+ }
default: {
// OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
const errorMsg = `Unsupported OCPP command ${commandName as string} for payload building`
}
}
+ protected getDefaultResponseTimeoutMs (_chargingStation: ChargingStation): number {
+ return Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS * 1000
+ }
+
/**
* Runs the OCPP 1.6 pre-request actions hook: for StartTransaction it sends
* and sets the connector status to Preparing before the request is sent.
resetConnectorStatus,
} from '../../../charging-station/index.js'
import {
+ AvailabilityType,
ChargingStationEvents,
type ConnectorStatus,
type JsonType,
import {
Constants,
convertToInt,
+ interruptibleSleep,
isNotEmptyArray,
logger,
sleep,
chargingStation: ChargingStation,
connectorStatus: ConnectorStatus | undefined,
requestPayload: OCPP16StopTransactionRequest
-): string | undefined => {
- const transactionIdTag = requestPayload.idTag ?? connectorStatus?.transactionIdTag
+): void => {
// resetConnectorStatus deletes transactionId (Helpers.ts:508). Destroy the
// coherent session using requestPayload.transactionId, which is always
// present in the StopTransaction request and unaffected by the reset.
if (connectorStatus != null) {
connectorStatus.locked = false
}
- return transactionIdTag
}
/**
payload: OCPP16StopTransactionResponse,
requestPayload: OCPP16StopTransactionRequest
): Promise<void> {
+ const lifecycleAbortSignal = (chargingStation as { lifecycleAbortSignal?: AbortSignal })
+ .lifecycleAbortSignal
const transactionConnectorId = chargingStation.getConnectorIdByTransactionId(
requestPayload.transactionId
)
+ const transactionConnectorStatus =
+ transactionConnectorId != null
+ ? chargingStation.getConnectorStatus(transactionConnectorId)
+ : undefined
+ const transactionIdTag = requestPayload.idTag ?? transactionConnectorStatus?.transactionIdTag
+ if (payload.idTagInfo != null && transactionIdTag != null) {
+ OCPP16ServiceUtils.updateAuthorizationCache(
+ chargingStation,
+ transactionIdTag,
+ payload.idTagInfo
+ )
+ }
if (transactionConnectorId == null) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Trying to stop a non-existent transaction with id ${requestPayload.transactionId.toString()}`
}
}
const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0
- let transactionIdTag: string | undefined
+ if (chargingStation.isStopping()) {
+ decrementPowerDivider(chargingStation)
+ OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, transactionConnectorId)
+ finalizeTransactionConnectorStatus(
+ chargingStation,
+ transactionConnectorStatus,
+ requestPayload
+ )
+ if (transactionConnectorStatus != null) {
+ transactionConnectorStatus.status =
+ chargingStation.isChargingStationAvailable() &&
+ transactionConnectorStatus.availability === AvailabilityType.Operative
+ ? OCPP16ChargePointStatus.Available
+ : OCPP16ChargePointStatus.Unavailable
+ }
+ return
+ }
if (postTransactionDelay > 0) {
decrementPowerDivider(chargingStation)
// Send Finishing status if not already set (idempotency guard)
- const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId)
if (transactionConnectorStatus?.status !== OCPP16ChargePointStatus.Finishing) {
await sendAndSetConnectorStatus(chargingStation, {
connectorId: transactionConnectorId,
// subsequent call from `finalizeTransactionConnectorStatus` post-sleep
// is a no-op.
chargingStation.destroyCoherentSession(requestPayload.transactionId)
- await sleep(secondsToMilliseconds(postTransactionDelay))
- if (!chargingStation.started) {
- return
+ if (!chargingStation.isStopping()) {
+ if (lifecycleAbortSignal == null) {
+ await sleep(secondsToMilliseconds(postTransactionDelay))
+ } else {
+ await interruptibleSleep(
+ secondsToMilliseconds(postTransactionDelay),
+ lifecycleAbortSignal
+ )
+ }
}
- transactionIdTag = finalizeTransactionConnectorStatus(
+ finalizeTransactionConnectorStatus(
chargingStation,
transactionConnectorStatus,
requestPayload
)
+ if (
+ chargingStation.isStopping() ||
+ lifecycleAbortSignal?.aborted === true ||
+ !chargingStation.started
+ ) {
+ if (transactionConnectorStatus != null) {
+ transactionConnectorStatus.status =
+ chargingStation.isChargingStationAvailable() &&
+ transactionConnectorStatus.availability === AvailabilityType.Operative
+ ? OCPP16ChargePointStatus.Available
+ : OCPP16ChargePointStatus.Unavailable
+ }
+ return
+ }
await sendPostTransactionStatus(chargingStation, transactionConnectorId)
} else {
await sendPostTransactionStatus(chargingStation, transactionConnectorId)
decrementPowerDivider(chargingStation)
- const transactionConnectorStatus = chargingStation.getConnectorStatus(transactionConnectorId)
- transactionIdTag = finalizeTransactionConnectorStatus(
+ finalizeTransactionConnectorStatus(
chargingStation,
transactionConnectorStatus,
requestPayload
} else {
logger.warn(logMsg)
}
- if (payload.idTagInfo != null && transactionIdTag != null) {
- OCPP16ServiceUtils.updateAuthorizationCache(
- chargingStation,
- transactionIdTag,
- payload.idTagInfo
- )
- }
}
private async resetConnectorOnStartTransactionError (
hasFeatureProfile,
hasReservationExpired,
} from '../../../charging-station/index.js'
-import { BaseError } from '../../../exception/index.js'
+import {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ isCoherentModeActive,
+ recordTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ restoreTransactionIntervalState,
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+} from '../../../charging-station/meter-values/index.js'
+import { BaseError, OCPPError } from '../../../exception/index.js'
import {
type ConfigurationKey,
+ type ConnectorStatus,
+ ErrorType,
type GenericResponse,
type MeterValuesRequest,
type MeterValuesResponse,
OCPP16IncomingRequestCommand,
type OCPP16MeterValue,
OCPP16MeterValueContext,
+ OCPP16MeterValueFormat,
OCPP16MeterValueMeasurand,
OCPP16MeterValueUnit,
OCPP16RequestCommand,
type OCPP16SampledValue,
+ type OCPP16SignedMeterValue,
OCPP16StandardParametersKey,
type OCPP16StatusNotificationRequest,
OCPP16StopTransactionReason,
type OCPP16SupportedFeatureProfiles,
OCPP16VendorParametersKey,
OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
RequestCommand,
+ type RequestParams,
type StartTransactionRequest,
type StartTransactionResponse,
type StopTransactionReason,
} from '../../../types/index.js'
import {
clampToSafeTimerValue,
+ clone,
Constants,
convertToBoolean,
convertToDate,
convertToInt,
+ ensureError,
isNotEmptyArray,
+ isNotEmptyString,
logger,
roundTo,
truncateId,
} from '../../../utils/index.js'
-import { isCoherentModeActive } from '../../meter-values/index.js'
import { mapOCPP16Status, OCPPAuthServiceFactory } from '../auth/index.js'
import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js'
import {
createPayloadConfigs,
getSampledValueTemplate,
PayloadValidatorOptions,
+ projectSnapshotDcOutputValue,
} from '../OCPPServiceUtils.js'
import { generateSignedMeterData } from '../OCPPSignedMeterDataGenerator.js'
import {
+ claimPublicKeyDelivery,
parsePublicKeyWithSignedMeterValue,
+ type PublicKeyDeliveryToken,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
shouldIncludePublicKey,
type SignedSampledValueResult,
type SigningConfig,
+ transferPublicKeyDelivery,
validateSigningPrerequisites,
} from '../OCPPSignedMeterValueUtils.js'
import { OCPP16Constants } from './OCPP16Constants.js'
import { buildOCPP16SampledValue, buildSignedOCPP16SampledValue } from './OCPP16RequestBuilders.js'
const moduleName = 'OCPP16ServiceUtils'
+const RFC3339_TIMESTAMP_PATTERN =
+ /^(\d{4})-(\d{2})-(\d{2})[Tt](\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?([Zz]|([+-])(\d{2}):(\d{2}))$/
+const DAYS_PER_MONTH = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] as const
+
+const isLeapYear = (year: number): boolean =>
+ year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0)
+
+const hasOcppTimestampYear = (timestamp: Date): boolean => {
+ const year = timestamp.getUTCFullYear()
+ return year >= 0 && year <= 9999 && /^\d{4}-/.test(timestamp.toISOString())
+}
+
+const parseStopTransactionTimestamp = (timestamp: string): Date | undefined => {
+ const match = RFC3339_TIMESTAMP_PATTERN.exec(timestamp)
+ if (match == null) return undefined
+
+ const year = Number(match[1])
+ const month = Number(match[2])
+ const day = Number(match[3])
+ const hour = Number(match[4])
+ const minute = Number(match[5])
+ const second = Number(match[6])
+ const fraction = match.at(7)
+ const offsetHourMatch = match.at(10)
+ const offsetMinuteMatch = match.at(11)
+ const offsetHour = offsetHourMatch == null ? 0 : Number(offsetHourMatch)
+ const offsetMinute = offsetMinuteMatch == null ? 0 : Number(offsetMinuteMatch)
+
+ if (
+ month < 1 ||
+ month > 12 ||
+ hour > 23 ||
+ minute > 59 ||
+ second > 60 ||
+ offsetHour > 23 ||
+ offsetMinute > 59
+ ) {
+ return undefined
+ }
+ const daysInMonth = month === 2 && isLeapYear(year) ? 29 : DAYS_PER_MONTH[month - 1]
+ if (day < 1 || day > daysInMonth) return undefined
+
+ const millisecond = fraction == null ? 0 : Number(fraction.slice(0, 3).padEnd(3, '0'))
+ const localTimestamp = new Date(0)
+ localTimestamp.setUTCFullYear(year, month - 1, day)
+ localTimestamp.setUTCHours(hour, minute, Math.min(second, 59), millisecond)
+ const signedOffsetMinutes =
+ match[8].toUpperCase() === 'Z'
+ ? 0
+ : (match[9] === '+' ? 1 : -1) * (offsetHour * 60 + offsetMinute)
+ const precedingInstant = localTimestamp.getTime() - signedOffsetMinutes * 60_000
+ if (!Number.isFinite(precedingInstant)) return undefined
+
+ if (second === 60) {
+ const precedingSecond = new Date(precedingInstant)
+ const isLeapSecondBoundary =
+ precedingSecond.getUTCHours() === 23 &&
+ precedingSecond.getUTCMinutes() === 59 &&
+ precedingSecond.getUTCSeconds() === 59 &&
+ ((precedingSecond.getUTCMonth() === 5 && precedingSecond.getUTCDate() === 30) ||
+ (precedingSecond.getUTCMonth() === 11 && precedingSecond.getUTCDate() === 31))
+ if (!isLeapSecondBoundary) return undefined
+ }
+
+ const normalizedTimestamp = new Date(precedingInstant + (second === 60 ? 1000 : 0))
+ return hasOcppTimestampYear(normalizedTimestamp) ? normalizedTimestamp : undefined
+}
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class OCPP16ServiceUtils {
[OCPP16RequestCommand.STOP_TRANSACTION, 'StopTransaction'],
]
+ private static readonly periodicMeterValuesIntervals = new WeakMap<ConnectorStatus, number>()
+
+ private static readonly startTransactionOperations = new WeakMap<
+ ConnectorStatus,
+ Promise<StartTransactionResponse>
+ >()
+
+ private static readonly stopTransactionOperations = new WeakMap<
+ ConnectorStatus,
+ { promise: Promise<StopTransactionResponse>; transactionId: number | string }
+ >()
+
/**
* Post-hoc signing wrapper for OCPP 1.6 Signed Meter Values whitepaper
* §3.3.6 (`SampledDataSignUpdatedReadings`). When
* `MeterValue`. Idempotent no-op when signing is disabled or the
* signing prerequisites are absent.
*
- * Mutates `meterValue.sampledValue` in place and updates
- * `connectorStatus.publicKeySentInTransaction` so the public-key
- * payload is emitted at most once per transaction (per
- * `PublicKeyWithSignedMeterValue = OncePerTransaction`).
+ * Mutates `meterValue.sampledValue` in place. By default, records an included
+ * public key for the active transaction; callers building a delivery may
+ * defer that state transition until the request owns delivery.
* @param chargingStation - Target charging station.
* @param connectorId - Connector identifier owning the transaction.
* @param transactionId - Active transaction identifier.
* @param meterValue - MeterValue to mutate (in place).
* @param context - Reading context for the emitted `SignedData` sampled value (defaults to `Sample.Periodic`); pass `Trigger` for TriggerMessage-originated emissions per OCPP 1.6 Core Table 30.
+ * @param commitState - Whether inclusion immediately commits the transaction public-key flag.
+ * @returns Whether the appended signed value carries the transaction public key.
*/
public static appendSignedUpdatedReadings (
chargingStation: ChargingStation,
connectorId: number,
transactionId: number,
meterValue: OCPP16MeterValue,
- context: OCPP16MeterValueContext = OCPP16MeterValueContext.SAMPLE_PERIODIC
- ): void {
+ context: OCPP16MeterValueContext = OCPP16MeterValueContext.SAMPLE_PERIODIC,
+ commitState = true
+ ): boolean {
if (
!OCPP16ServiceUtils.isSigningEnabled(chargingStation) ||
!OCPP16ServiceUtils.isSigningUpdatedReadingsEnabled(chargingStation)
) {
- return
+ return false
}
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
if (connectorStatus?.transactionStarted !== true) {
- return
+ return false
}
const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
chargingStation,
connectorId
)
if (signingCfg == null) {
- return
+ return false
}
const energyWh = chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId)
const publicKeySentInTransaction = connectorStatus.publicKeySentInTransaction ?? false
meterValue.timestamp
)
meterValue.sampledValue.push(signedResult.sampledValue)
- if (signedResult.publicKeyIncluded) {
+ if (signedResult.publicKeyIncluded && commitState) {
connectorStatus.publicKeySentInTransaction = true
}
+ return signedResult.publicKeyIncluded
}
/**
)
}
const meterValue = buildEmptyMeterValue() as OCPP16MeterValue
+ const meterStartOutputWh = meterStart ?? 0
// Energy.Active.Import.Register measurand (default)
const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
if (sampledValueTemplate != null) {
+ const projectedMeterStartWh = projectSnapshotDcOutputValue(
+ chargingStation,
+ connectorId,
+ undefined,
+ sampledValueTemplate.location,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ meterStartOutputWh,
+ false
+ )
const unitDivider =
sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR
? Constants.UNIT_DIVIDER_KILO
meterValue.sampledValue.push(
buildOCPP16SampledValue(
sampledValueTemplate,
- roundTo((meterStart ?? 0) / unitDivider, 4),
+ roundTo(projectedMeterStartWh / unitDivider, 4),
OCPP16MeterValueContext.TRANSACTION_BEGIN
)
)
if (signingCfg != null) {
const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
signingCfg,
- meterStart ?? 0,
+ meterStartOutputWh,
OCPP16MeterValueContext.TRANSACTION_BEGIN,
transactionId,
publicKeySentInTransaction,
* @param chargingStation - Target charging station
* @param connectorId - Connector ID associated with the transaction
* @param meterStop - Final meter reading in Wh at transaction end
+ * @param timestamp - Timestamp shared with the StopTransaction snapshot
* @returns MeterValue containing the transaction end energy reading
*/
public static buildTransactionEndMeterValue (
chargingStation: ChargingStation,
connectorId: number,
- meterStop: number | undefined
+ meterStop: number | undefined,
+ timestamp = new Date()
): OCPP16MeterValue {
const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId)
if (sampledValueTemplate == null) {
`Missing MeterValues for default measurand '${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}`
)
}
+ const meterStopOutputWh = meterStop ?? 0
+ const projectedMeterStopWh = projectSnapshotDcOutputValue(
+ chargingStation,
+ connectorId,
+ undefined,
+ sampledValueTemplate.location,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ meterStopOutputWh,
+ false
+ )
const unitDivider =
sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR
? Constants.UNIT_DIVIDER_KILO
: 1
- const meterValue = buildEmptyMeterValue() as OCPP16MeterValue
+ const meterValue = { sampledValue: [], timestamp } as OCPP16MeterValue
meterValue.sampledValue.push(
buildOCPP16SampledValue(
sampledValueTemplate,
- roundTo((meterStop ?? 0) / unitDivider, 4),
+ roundTo(projectedMeterStopWh / unitDivider, 4),
OCPP16MeterValueContext.TRANSACTION_END
)
)
if (signingCfg != null) {
const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
signingCfg,
- meterStop ?? 0,
+ meterStopOutputWh,
OCPP16MeterValueContext.TRANSACTION_END,
transactionId,
publicKeySentInTransaction,
meterValue.timestamp
)
meterValue.sampledValue.push(signedResult.sampledValue)
- if (signedResult.publicKeyIncluded && connectorStatus != null) {
- connectorStatus.publicKeySentInTransaction = true
- }
}
}
return meterValue
* @param connectorIds - Array of connector identifiers to update
* @param chargePointStatus - New charge point status to set
* @param availabilityType - Operative or inoperative availability type
+ * @param isOperationCurrent - Optional operation-ownership guard for post-await mutation
* @returns Accepted or scheduled availability change response
*/
public static changeAvailability = async (
chargingStation: ChargingStation,
connectorIds: number[],
chargePointStatus: OCPP16ChargePointStatus,
- availabilityType: OCPP16AvailabilityType
+ availabilityType: OCPP16AvailabilityType,
+ isOperationCurrent?: () => boolean
): Promise<OCPP16ChangeAvailabilityResponse> => {
const responses: OCPP16ChangeAvailabilityResponse[] = []
for (const connectorId of connectorIds) {
}
connectorStatus.availability = availabilityType
if (response === OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_ACCEPTED) {
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: chargePointStatus,
- })
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ { connectorId, status: chargePointStatus },
+ { isOperationCurrent, send: true }
+ )
+ if (isOperationCurrent?.() === false) {
+ return OCPP16Constants.OCPP_AVAILABILITY_RESPONSE_REJECTED
+ }
}
responses.push(response)
}
{ schemaPath: string }
][] => createPayloadConfigs(OCPP16ServiceUtils.outgoingRequestSchemaNames, 'Response.json')
+ /**
+ * Gets the OCPP 1.6 StartTransaction currently in flight on a connector.
+ * Shutdown awaits it before deciding whether the accepted transaction needs a StopTransaction.
+ * @param connectorStatus - Connector whose pending start is queried
+ * @returns The pending start operation, or undefined when no start is in flight
+ */
+ public static getPendingStartTransaction (
+ connectorStatus: ConnectorStatus
+ ): Promise<StartTransactionResponse> | undefined {
+ return OCPP16ServiceUtils.startTransactionOperations.get(connectorStatus)
+ }
+
/**
* Checks whether a connector or the charging station has a valid reservation for the given idTag.
* @param chargingStation - Target charging station
chargingStation: ChargingStation,
connectorId: number
): Promise<GenericResponse> => {
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: OCPP16ChargePointStatus.Finishing,
- })
const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
chargingStation,
connectorId,
/**
* Sends a StartTransaction request to the Central System for the given connector.
+ * A graceful station stop waits for this request to settle before deciding whether
+ * the newly accepted transaction needs an immediate StopTransaction.
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier to start the transaction on
* @param idTag - Optional RFID tag for the transaction
+ * @param requestParams - Optional request transport behavior
+ * @param requestOverrides - Optional protocol fields supplied by an external caller
* @returns Start transaction response from the Central System
*/
public static async startTransactionOnConnector (
chargingStation: ChargingStation,
connectorId: number,
- idTag?: string
+ idTag?: string,
+ requestParams?: RequestParams,
+ requestOverrides: Omit<Partial<StartTransactionRequest>, 'connectorId' | 'idTag'> = {}
): Promise<StartTransactionResponse> {
- return chargingStation.ocppRequestService.requestHandler<
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) connectorStatus.transactionStarting = true
+ const operation = chargingStation.ocppRequestService.requestHandler<
Partial<StartTransactionRequest>,
StartTransactionResponse
- >(chargingStation, RequestCommand.START_TRANSACTION, {
- connectorId,
- ...(idTag != null && { idTag }),
- })
+ >(
+ chargingStation,
+ RequestCommand.START_TRANSACTION,
+ {
+ ...requestOverrides,
+ connectorId,
+ ...(idTag != null && { idTag }),
+ },
+ { ...requestParams, waitForResponseOnStationStop: true }
+ )
+ if (connectorStatus != null) {
+ OCPP16ServiceUtils.startTransactionOperations.set(connectorStatus, operation)
+ }
+ try {
+ return await operation
+ } finally {
+ if (
+ connectorStatus != null &&
+ OCPP16ServiceUtils.startTransactionOperations.get(connectorStatus) === operation
+ ) {
+ OCPP16ServiceUtils.startTransactionOperations.delete(connectorStatus)
+ delete connectorStatus.transactionStarting
+ }
+ }
}
/**
)
return
}
+ const rawTransactionId = connectorStatus.transactionId
+ OCPP16ServiceUtils.periodicMeterValuesIntervals.set(connectorStatus, interval)
+ let lastSampleAt = Date.now()
+ let elapsedIntervalCarry = 0
connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(() => {
- const transactionId = convertToInt(connectorStatus.transactionId)
- const meterValue = buildMeterValue(
- chargingStation,
- transactionId,
- interval
- ) as OCPP16MeterValue
- OCPP16ServiceUtils.appendSignedUpdatedReadings(
- chargingStation,
- connectorId,
- transactionId,
- meterValue
- )
- chargingStation.ocppRequestService
- .requestHandler<MeterValuesRequest, MeterValuesResponse>(
+ let delivery: TransactionMeterValueDelivery | undefined
+ ;(async () => {
+ if (
+ connectorStatus.transactionStarted !== true ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionId !== rawTransactionId ||
+ OCPP16ServiceUtils.stopTransactionOperations.get(connectorStatus)?.transactionId ===
+ rawTransactionId
+ ) {
+ return
+ }
+ elapsedIntervalCarry += interval
+ delivery = TransactionMeterValueDeliveryBarrier.beginIfIdle(
+ connectorStatus,
+ rawTransactionId
+ )
+ if (delivery == null) return
+ const deliveryTurn = delivery.waitForTurn()
+ if (deliveryTurn != null) await deliveryTurn
+ if (
+ chargingStation.getConnectorStatus(connectorId)?.transactionStarted !== true ||
+ Boolean(connectorStatus.transactionEnding) ||
+ connectorStatus.transactionId !== rawTransactionId ||
+ OCPP16ServiceUtils.stopTransactionOperations.get(connectorStatus)?.transactionId ===
+ rawTransactionId
+ ) {
+ delivery.settle(true)
+ return
+ }
+ const transactionId = convertToInt(rawTransactionId)
+ const sampleAt = Date.now()
+ const elapsedInterval = Math.max(elapsedIntervalCarry, sampleAt - lastSampleAt)
+ const intervalState = captureTransactionIntervalState(connectorStatus)
+ const meterValue = buildMeterValue(
chargingStation,
- RequestCommand.METER_VALUES,
- {
- connectorId,
- meterValue: [meterValue],
- transactionId,
- }
+ transactionId,
+ elapsedInterval
+ ) as OCPP16MeterValue
+ lastSampleAt = sampleAt
+ elapsedIntervalCarry = 0
+ completeTransactionIntervalState(intervalState, 'default', [meterValue])
+ const publicKeyIncluded = OCPP16ServiceUtils.appendSignedUpdatedReadings(
+ chargingStation,
+ connectorId,
+ transactionId,
+ meterValue
+ )
+ const request: MeterValuesRequest = {
+ connectorId,
+ meterValue: [meterValue],
+ transactionId,
+ }
+ const publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ request,
+ publicKeyIncluded
)
- .catch((error: unknown) => {
+ let deliverySettled = false
+ const markDeliverySettled = (definitivelyRejected = false): void => {
+ if (deliverySettled) return
+ deliverySettled = true
+ delivery?.settle(definitivelyRejected)
+ }
+
+ const deliveryState = {
+ buffered: false,
+ callError: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ }
+ let rejectedDeliveryRestored = false
+ const restoreRejectedDelivery = (): void => {
+ if (rejectedDeliveryRestored) return
+ rejectedDeliveryRestored = true
+ restoreTransactionIntervalState(intervalState, connectorStatus, 'default')
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ const handleDeliveryFailure = (error: unknown): void => {
+ const definitelyRejected =
+ !deliveryState.transportErrorAmbiguous &&
+ (deliveryState.callError || (!deliveryState.responseReceived && !deliveryState.sent))
+ if (!deliveryState.buffered && definitelyRejected) {
+ restoreRejectedDelivery()
+ } else if (
+ !deliveryState.buffered &&
+ (deliveryState.sent ||
+ deliveryState.responseReceived ||
+ deliveryState.transportErrorAmbiguous)
+ ) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ if (!deliveryState.buffered) markDeliverySettled(definitelyRejected)
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error while sending '${RequestCommand.METER_VALUES}':`,
error
)
- })
+ }
+ try {
+ chargingStation.ocppRequestService
+ .requestHandler<MeterValuesRequest, MeterValuesResponse>(
+ chargingStation,
+ RequestCommand.METER_VALUES,
+ request,
+ {
+ onError: (_error, isCallError) => {
+ deliveryState.callError ||= isCallError
+ if (isCallError || deliveryState.buffered) {
+ const definitivelyRejected = !deliveryState.transportErrorAmbiguous
+ if (definitivelyRejected) {
+ restoreRejectedDelivery()
+ } else {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ markDeliverySettled(definitivelyRejected)
+ }
+ },
+ onMessageSent: () => {
+ deliveryState.sent = true
+ },
+ onRequestBuffered: () => {
+ deliveryState.buffered = true
+ delivery?.markBuffered()
+ },
+ onResponseReceived: () => {
+ deliveryState.responseReceived = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ },
+ onTransportError: (_error, deliveryAmbiguous) => {
+ deliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ },
+ throwError: true,
+ }
+ )
+ .then(() => {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markDeliverySettled()
+ return undefined
+ })
+ .catch(handleDeliveryFailure)
+ } catch (error: unknown) {
+ handleDeliveryFailure(error)
+ }
+ })().catch((error: unknown) => {
+ delivery?.settle(true)
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error while preparing '${RequestCommand.METER_VALUES}':`,
+ error
+ )
+ })
}, clampToSafeTimerValue(interval))
}
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier with the active transaction
* @param reason - Optional stop transaction reason
+ * @param requestOverrides - Optional StopTransaction payload overrides
+ * @param requestParams - Optional transport behavior overrides
* @returns Stop transaction response from the Central System
*/
- public static async stopTransactionOnConnector (
+ public static stopTransactionOnConnector (
chargingStation: ChargingStation,
connectorId: number,
- reason?: StopTransactionReason
+ reason?: StopTransactionReason,
+ requestOverrides: Partial<StopTransactionRequest> = {},
+ requestParams?: RequestParams
): Promise<StopTransactionResponse> {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus?.status !== OCPP16ChargePointStatus.Finishing) {
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- status: OCPP16ChargePointStatus.Finishing,
- })
+ let connectorStatus: ConnectorStatus | undefined
+ try {
+ connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ } catch (error) {
+ return Promise.reject(ensureError(error))
}
- const rawTransactionId = chargingStation.getConnectorStatus(connectorId)?.transactionId
- const transactionId = rawTransactionId != null ? convertToInt(rawTransactionId) : undefined
- if (
- chargingStation.stationInfo?.beginEndMeterValues === true &&
- chargingStation.stationInfo.ocppStrictCompliance === true &&
- chargingStation.stationInfo.outOfOrderEndMeterValues === false
- ) {
- const transactionEndMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(
- chargingStation,
- connectorId,
- chargingStation.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
+ const rawTransactionId = connectorStatus?.transactionId
+ const lifecycleAbortSignal = chargingStation.lifecycleAbortSignal
+
+ let normalizedTimestamp: Date | undefined
+ if (Object.hasOwn(requestOverrides, 'timestamp')) {
+ try {
+ normalizedTimestamp = OCPP16ServiceUtils.normalizeStopTransactionTimestamp(
+ requestOverrides.timestamp
+ )
+ } catch (error) {
+ return Promise.reject(ensureError(error))
+ }
+ }
+
+ const activeStopTransaction =
+ connectorStatus != null
+ ? OCPP16ServiceUtils.stopTransactionOperations.get(connectorStatus)
+ : undefined
+ if (activeStopTransaction != null) {
+ if (
+ activeStopTransaction.transactionId === rawTransactionId ||
+ (connectorStatus?.transactionEnding === true && rawTransactionId == null)
+ ) {
+ return activeStopTransaction.promise
+ }
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.stopTransactionOnConnector: Stop transaction ${activeStopTransaction.transactionId.toString()} is still in progress on connector ${connectorId.toString()}; cannot stop replacement transaction ${rawTransactionId?.toString() ?? 'unknown'}`,
+ RequestCommand.STOP_TRANSACTION
+ )
)
- await chargingStation.ocppRequestService.requestHandler<
- MeterValuesRequest,
- MeterValuesResponse
- >(chargingStation, RequestCommand.METER_VALUES, {
- connectorId,
- meterValue: [transactionEndMeterValue],
- transactionId,
- })
}
- return await chargingStation.ocppRequestService.requestHandler<
- Partial<StopTransactionRequest>,
- StopTransactionResponse
- >(chargingStation, RequestCommand.STOP_TRANSACTION, {
- meterStop: chargingStation.getEnergyActiveImportRegisterByTransactionId(
- rawTransactionId,
- true
- ),
- transactionId,
- ...(reason != null && { reason: reason as StopTransactionRequest['reason'] }),
- })
+ if (connectorStatus?.transactionEnding === true) {
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.stopTransactionOnConnector: Transaction is pending terminal response processing on connector ${connectorId.toString()}`,
+ RequestCommand.STOP_TRANSACTION
+ )
+ )
+ }
+ if (connectorStatus?.transactionStarted !== true || rawTransactionId == null) {
+ return Promise.reject(
+ new BaseError(
+ `${chargingStation.logPrefix()} ${moduleName}.stopTransactionOnConnector: No active transaction on connector ${connectorId.toString()}`
+ )
+ )
+ }
+
+ let transactionEndingOwned = true
+ const periodicMeterValuesWasRunning =
+ connectorStatus.transactionUpdatedMeterValuesSetInterval != null
+ const periodicMeterValuesInterval =
+ OCPP16ServiceUtils.periodicMeterValuesIntervals.get(connectorStatus)
+ connectorStatus.transactionEnding = true
+ OCPP16ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
+
+ let publicKeyDeliveryToken: PublicKeyDeliveryToken | undefined
+ let terminalMeterValuesHasPublicKey = false
+ let terminalMeterValuesRequest: MeterValuesRequest | undefined
+ let terminalMeterValuesDeliveryStarted = false
+ let stopTransactionHasPublicKey = false
+ let stopTransactionSnapshot: Readonly<StopTransactionRequest> | undefined
+ let stopTransactionFallbackSnapshot: Readonly<StopTransactionRequest> | undefined
+ let bufferStopTransactionWithoutSending = false
+ const stopDeliveryState = {
+ buffered: false,
+ callError: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ }
+ let stopTransactionRequestSettled = false
+ const clearTransactionEnding = (restorePeriodicMeterValues = false): void => {
+ if (!transactionEndingOwned) return
+ if (connectorStatus.transactionId === rawTransactionId) {
+ delete connectorStatus.transactionEnding
+ if (
+ restorePeriodicMeterValues &&
+ periodicMeterValuesWasRunning &&
+ periodicMeterValuesInterval != null &&
+ connectorStatus.transactionStarted === true &&
+ connectorStatus.transactionUpdatedMeterValuesSetInterval == null
+ ) {
+ OCPP16ServiceUtils.startUpdatedMeterValues(
+ chargingStation,
+ connectorId,
+ periodicMeterValuesInterval
+ )
+ }
+ }
+ transactionEndingOwned = false
+ }
+
+ const stopTransactionCarriesPublicKey = (): boolean => stopTransactionHasPublicKey
+ const stopTransactionPromise = (async (): Promise<StopTransactionResponse> => {
+ let stopTransactionIntervalState:
+ ReturnType<typeof captureTransactionIntervalState> | undefined
+ let stopTransactionIntervalRestored = false
+ const restoreStopTransactionInterval = (): void => {
+ if (stopTransactionIntervalRestored || stopTransactionIntervalState == null) return
+ stopTransactionIntervalRestored = true
+ restoreTransactionIntervalState(stopTransactionIntervalState, connectorStatus, 'default')
+ }
+ try {
+ const meterValueDependencies = await TransactionMeterValueDeliveryBarrier.wait(
+ connectorStatus,
+ rawTransactionId
+ )
+ bufferStopTransactionWithoutSending = isNotEmptyArray(meterValueDependencies)
+ const timestamp = new Date((normalizedTimestamp ?? new Date()).getTime())
+ const transactionId = convertToInt(rawTransactionId)
+ let strictEndMeterValueIsSolePublicKeyCarrier = false
+ const materializeStopTransaction = (): void => {
+ stopTransactionIntervalState = undefined
+ stopTransactionIntervalRestored = false
+ const finalEnergy =
+ chargingStation.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
+ const meterStop = Math.round(finalEnergy)
+ const idTag = requestOverrides.idTag ?? connectorStatus.transactionIdTag
+ const transactionDataEnabled =
+ chargingStation.stationInfo?.transactionDataMeterValues === true
+ const signingForcesTransactionData = OCPP16ServiceUtils.isSigningEnabled(chargingStation)
+ const strictEndMeterValueEnabled =
+ chargingStation.stationInfo?.beginEndMeterValues === true &&
+ chargingStation.stationInfo.ocppStrictCompliance === true &&
+ chargingStation.stationInfo.outOfOrderEndMeterValues === false
+ const hasTransactionDataOverride = Object.hasOwn(requestOverrides, 'transactionData')
+ const snapshotOverrides = clone(requestOverrides)
+ let transactionData = snapshotOverrides.transactionData
+ let transactionEndMeterValue: OCPP16MeterValue | undefined
+
+ if (
+ strictEndMeterValueEnabled ||
+ (!hasTransactionDataOverride &&
+ (transactionDataEnabled || signingForcesTransactionData))
+ ) {
+ if (strictEndMeterValueEnabled || transactionDataEnabled) {
+ transactionEndMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ connectorId,
+ meterStop,
+ timestamp
+ )
+ } else {
+ try {
+ transactionEndMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ connectorId,
+ meterStop,
+ timestamp
+ )
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.stopTransactionOnConnector: Failed to build signed transaction data meter values for StopTransaction:`,
+ error
+ )
+ }
+ }
+ }
+ if (
+ !hasTransactionDataOverride &&
+ transactionEndMeterValue != null &&
+ (transactionDataEnabled || signingForcesTransactionData)
+ ) {
+ const intervalState = captureTransactionIntervalState(connectorStatus)
+ const intervalMeterValue = OCPP16ServiceUtils.buildStopTransactionIntervalMeterValue(
+ chargingStation,
+ connectorId,
+ rawTransactionId,
+ meterStop,
+ transactionEndMeterValue
+ )
+ const transactionDataEndMeterValue = intervalMeterValue ?? transactionEndMeterValue
+ if (intervalMeterValue != null) {
+ completeTransactionIntervalState(intervalState, 'default', [intervalMeterValue])
+ stopTransactionIntervalState = intervalState
+ }
+ transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+ connectorStatus.transactionBeginMeterValue as OCPP16MeterValue,
+ transactionDataEndMeterValue
+ )
+ }
+
+ const oncePerTransactionPublicKey =
+ parsePublicKeyWithSignedMeterValue(
+ getConfigurationKey(
+ chargingStation,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue
+ )?.value
+ ) === PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ terminalMeterValuesHasPublicKey =
+ transactionEndMeterValue != null &&
+ OCPP16ServiceUtils.meterValueHasPublicKey(transactionEndMeterValue)
+ stopTransactionHasPublicKey =
+ Array.isArray(transactionData) &&
+ transactionData.some(meterValue =>
+ OCPP16ServiceUtils.meterValueHasPublicKey(meterValue)
+ )
+ const transactionDataWithPublicKey = transactionData
+ if (
+ !hasTransactionDataOverride &&
+ oncePerTransactionPublicKey &&
+ strictEndMeterValueEnabled &&
+ terminalMeterValuesHasPublicKey &&
+ stopTransactionHasPublicKey
+ ) {
+ transactionData = OCPP16ServiceUtils.removePublicKeys(transactionData)
+ stopTransactionHasPublicKey = false
+ }
+
+ strictEndMeterValueIsSolePublicKeyCarrier =
+ oncePerTransactionPublicKey &&
+ strictEndMeterValueEnabled &&
+ terminalMeterValuesHasPublicKey &&
+ !stopTransactionHasPublicKey
+
+ stopTransactionSnapshot = Object.freeze({
+ ...snapshotOverrides,
+ idTag,
+ meterStop,
+ timestamp,
+ transactionData,
+ transactionId,
+ ...(reason != null && { reason: reason as StopTransactionRequest['reason'] }),
+ })
+ if (
+ strictEndMeterValueIsSolePublicKeyCarrier &&
+ Array.isArray(transactionDataWithPublicKey) &&
+ transactionDataWithPublicKey.some(meterValue =>
+ OCPP16ServiceUtils.meterValueHasPublicKey(meterValue)
+ )
+ ) {
+ stopTransactionFallbackSnapshot = Object.freeze({
+ ...stopTransactionSnapshot,
+ transactionData: transactionDataWithPublicKey,
+ })
+ }
+ if (!strictEndMeterValueIsSolePublicKeyCarrier) {
+ publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ rawTransactionId,
+ stopTransactionSnapshot,
+ oncePerTransactionPublicKey && stopTransactionHasPublicKey
+ )
+ }
+ if (strictEndMeterValueEnabled && transactionEndMeterValue != null) {
+ terminalMeterValuesRequest = {
+ connectorId,
+ meterValue: [transactionEndMeterValue],
+ transactionId,
+ }
+ if (oncePerTransactionPublicKey && terminalMeterValuesHasPublicKey) {
+ publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ rawTransactionId,
+ terminalMeterValuesRequest,
+ true
+ )
+ }
+ }
+ if (chargingStation.stationInfo?.ocppStrictCompliance !== false) {
+ chargingStation.ocppRequestService.validateRequestPayload(
+ chargingStation,
+ RequestCommand.STOP_TRANSACTION,
+ stopTransactionSnapshot
+ )
+ }
+ }
+ materializeStopTransaction()
+ let stopRequestStarted = false
+ let deferredPredecessorRejection = false
+ const reconcileStopTransaction = (): boolean => {
+ const previousSnapshot = stopTransactionSnapshot
+ const previousPublicKeyDeliveryToken = publicKeyDeliveryToken
+ const previousPublicKeySentInTransaction =
+ connectorStatus.publicKeySentInTransaction === true
+ const previousTerminalMeterValuesRequest = terminalMeterValuesRequest
+ const previousFallbackSnapshot = stopTransactionFallbackSnapshot
+ const previousStopTransactionHasPublicKey = stopTransactionHasPublicKey
+ const previousTerminalMeterValuesHasPublicKey = terminalMeterValuesHasPublicKey
+ const previousStrictEndMeterValueIsSolePublicKeyCarrier =
+ strictEndMeterValueIsSolePublicKeyCarrier
+ const previousStopTransactionIntervalState = stopTransactionIntervalState
+ const previousStopTransactionIntervalRestored = stopTransactionIntervalRestored
+ const previousIntervalBaselines =
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalBaselines }
+ const previousIntervalCarry =
+ connectorStatus.transactionEnergyActiveImportIntervalCarry == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalCarry }
+ restoreStopTransactionInterval()
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ publicKeyDeliveryToken = undefined
+ terminalMeterValuesRequest = undefined
+ stopTransactionFallbackSnapshot = undefined
+ materializeStopTransaction()
+ if (
+ stopDeliveryState.buffered &&
+ previousSnapshot != null &&
+ stopTransactionSnapshot != null &&
+ !chargingStation.replaceBufferedRequestPayload(
+ RequestCommand.STOP_TRANSACTION,
+ previousSnapshot,
+ stopTransactionSnapshot
+ )
+ ) {
+ // Replay already owns the previous frame; keep local delivery state aligned with it.
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ publicKeyDeliveryToken = previousPublicKeyDeliveryToken
+ connectorStatus.publicKeySentInTransaction = previousPublicKeySentInTransaction
+ terminalMeterValuesRequest = previousTerminalMeterValuesRequest
+ stopTransactionFallbackSnapshot = previousFallbackSnapshot
+ stopTransactionHasPublicKey = previousStopTransactionHasPublicKey
+ terminalMeterValuesHasPublicKey = previousTerminalMeterValuesHasPublicKey
+ strictEndMeterValueIsSolePublicKeyCarrier =
+ previousStrictEndMeterValueIsSolePublicKeyCarrier
+ stopTransactionIntervalState = previousStopTransactionIntervalState
+ stopTransactionIntervalRestored = previousStopTransactionIntervalRestored
+ if (previousIntervalBaselines == null) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines =
+ previousIntervalBaselines
+ }
+ if (previousIntervalCarry == null) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = previousIntervalCarry
+ }
+ stopTransactionSnapshot = previousSnapshot
+ return false
+ }
+ deferredPredecessorRejection = false
+ return true
+ }
+ let terminalRejectionTransferred = false
+ const transferRejectedTerminalMeterValues = (): boolean => {
+ if (terminalRejectionTransferred) return true
+ if (
+ stopTransactionFallbackSnapshot == null ||
+ terminalMeterValuesRequest == null ||
+ connectorStatus.transactionId !== rawTransactionId
+ ) {
+ return false
+ }
+ const previousStopTransactionSnapshot = stopTransactionSnapshot
+ const previousToken = publicKeyDeliveryToken
+ const replacementToken =
+ transferPublicKeyDelivery(publicKeyDeliveryToken, stopTransactionFallbackSnapshot) ??
+ claimPublicKeyDelivery(
+ connectorStatus,
+ rawTransactionId,
+ stopTransactionFallbackSnapshot,
+ true
+ )
+ if (replacementToken == null) return false
+ publicKeyDeliveryToken = replacementToken
+ stopTransactionSnapshot = stopTransactionFallbackSnapshot
+ stopTransactionHasPublicKey = true
+ if (
+ stopDeliveryState.buffered &&
+ previousStopTransactionSnapshot != null &&
+ !chargingStation.replaceBufferedRequestPayload(
+ RequestCommand.STOP_TRANSACTION,
+ previousStopTransactionSnapshot,
+ stopTransactionSnapshot
+ )
+ ) {
+ publicKeyDeliveryToken =
+ transferPublicKeyDelivery(replacementToken, terminalMeterValuesRequest) ??
+ previousToken
+ stopTransactionSnapshot = previousStopTransactionSnapshot
+ stopTransactionHasPublicKey = false
+ return false
+ }
+ terminalRejectionTransferred = true
+ return true
+ }
+ for (const dependency of meterValueDependencies) {
+ dependency.onDefinitiveRejection(() => {
+ if (!stopRequestStarted || stopDeliveryState.buffered) {
+ reconcileStopTransaction()
+ } else {
+ deferredPredecessorRejection = true
+ }
+ })
+ }
+
+ if (connectorStatus.status !== OCPP16ChargePointStatus.Finishing) {
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ {
+ connectorId,
+ status: OCPP16ChargePointStatus.Finishing,
+ },
+ { expectedTransactionId: rawTransactionId, send: true }
+ )
+ }
+ if (terminalMeterValuesRequest != null) {
+ terminalMeterValuesDeliveryStarted = true
+ const meterValuesDeliveryState = {
+ buffered: false,
+ callError: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ }
+ const meterValuesReplay = Promise.withResolvers<undefined>()
+ let meterValuesReplayError: OCPPError | undefined
+ let meterValuesReplaySettled = false
+ const markMeterValuesSettled = (): void => {
+ if (meterValuesReplaySettled) return
+ meterValuesReplaySettled = true
+ meterValuesReplay.resolve(undefined)
+ }
+ const lifecycleAbort = Promise.withResolvers<'aborted'>()
+ const onLifecycleAbort = (): void => {
+ lifecycleAbort.resolve('aborted')
+ }
+ if (lifecycleAbortSignal.aborted) {
+ onLifecycleAbort()
+ } else {
+ lifecycleAbortSignal.addEventListener('abort', onLifecycleAbort, { once: true })
+ }
+ try {
+ const meterValuesRequest = Promise.resolve(
+ chargingStation.ocppRequestService.requestHandler<
+ MeterValuesRequest,
+ MeterValuesResponse
+ >(chargingStation, RequestCommand.METER_VALUES, terminalMeterValuesRequest, {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ onError: (error, isCallError) => {
+ meterValuesDeliveryState.callError ||= isCallError
+ if (isCallError) {
+ if (meterValuesDeliveryState.transportErrorAmbiguous) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ } else {
+ transferRejectedTerminalMeterValues()
+ }
+ markMeterValuesSettled()
+ } else if (!meterValuesReplaySettled) {
+ meterValuesReplayError = error
+ markMeterValuesSettled()
+ }
+ },
+ onMessageSent: () => {
+ meterValuesDeliveryState.sent = true
+ markMeterValuesSettled()
+ },
+ onRequestBuffered: () => {
+ meterValuesDeliveryState.buffered = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ },
+ onResponseReceived: () => {
+ meterValuesDeliveryState.responseReceived = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ markMeterValuesSettled()
+ },
+ onTransportError: (_error, deliveryAmbiguous) => {
+ meterValuesDeliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ if (deliveryAmbiguous) retainPublicKeyDelivery(publicKeyDeliveryToken)
+ },
+ skipBufferingOnError: false,
+ throwError: strictEndMeterValueIsSolePublicKeyCarrier,
+ })
+ )
+ try {
+ const requestOutcome = await Promise.race([
+ meterValuesRequest.then(() => 'settled' as const),
+ lifecycleAbort.promise,
+ ])
+ if (requestOutcome === 'aborted') {
+ bufferStopTransactionWithoutSending = OCPP16ServiceUtils.bufferCachedRequestPayload(
+ chargingStation,
+ RequestCommand.METER_VALUES,
+ terminalMeterValuesRequest,
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Charging station stopped while awaiting terminal MeterValues response',
+ RequestCommand.METER_VALUES
+ )
+ )
+ if (bufferStopTransactionWithoutSending) {
+ meterValuesDeliveryState.buffered = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ } else {
+ await meterValuesRequest
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ } else {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ } catch (error) {
+ const terminalMeterValuesCached = OCPP16ServiceUtils.hasCachedRequestPayload(
+ chargingStation,
+ RequestCommand.METER_VALUES,
+ terminalMeterValuesRequest
+ )
+ if (terminalMeterValuesCached) {
+ meterValuesDeliveryState.buffered = true
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ await Promise.race([meterValuesReplay.promise, lifecycleAbort.promise])
+ bufferStopTransactionWithoutSending =
+ (lifecycleAbortSignal.aborted || chargingStation.isStopping()) &&
+ OCPP16ServiceUtils.hasCachedRequestPayload(
+ chargingStation,
+ RequestCommand.METER_VALUES,
+ terminalMeterValuesRequest
+ )
+ if (!bufferStopTransactionWithoutSending && meterValuesReplayError != null) {
+ throw meterValuesReplayError
+ }
+ } else {
+ const definitelyRejected =
+ !meterValuesDeliveryState.transportErrorAmbiguous &&
+ (meterValuesDeliveryState.callError ||
+ (!meterValuesDeliveryState.responseReceived && !meterValuesDeliveryState.sent))
+ if (definitelyRejected && transferRejectedTerminalMeterValues()) {
+ // The StopTransaction fallback now owns the rejected terminal public key.
+ } else if (definitelyRejected) {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ throw error
+ } else {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ }
+ }
+ } finally {
+ lifecycleAbortSignal.removeEventListener('abort', onLifecycleAbort)
+ }
+ }
+ stopRequestStarted = true
+ const stopTransactionResponse = await chargingStation.ocppRequestService.requestHandler<
+ StopTransactionRequest,
+ StopTransactionResponse
+ >(chargingStation, RequestCommand.STOP_TRANSACTION, stopTransactionSnapshot, {
+ ...requestParams,
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ ...(bufferStopTransactionWithoutSending && { bufferWithoutSending: true }),
+ onError: (error, isCallError) => {
+ stopDeliveryState.callError ||= isCallError
+ if (isCallError) {
+ restoreStopTransactionInterval()
+ if (stopTransactionHasPublicKey) releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ if (stopTransactionRequestSettled && isCallError) {
+ clearTransactionEnding(true)
+ }
+ requestParams?.onError?.(error, isCallError)
+ },
+ onMessageSent: () => {
+ stopDeliveryState.sent = true
+ requestParams?.onMessageSent?.()
+ },
+ onRequestBuffered: () => {
+ stopDeliveryState.buffered = true
+ if (deferredPredecessorRejection) reconcileStopTransaction()
+ if (stopTransactionHasPublicKey) retainPublicKeyDelivery(publicKeyDeliveryToken)
+ requestParams?.onRequestBuffered?.()
+ },
+ onResponseReceived: () => {
+ stopDeliveryState.responseReceived = true
+ if (stopTransactionHasPublicKey) retainPublicKeyDelivery(publicKeyDeliveryToken)
+ requestParams?.onResponseReceived?.()
+ },
+ onTransportError: (error, deliveryAmbiguous) => {
+ stopDeliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ if (deliveryAmbiguous && stopTransactionHasPublicKey) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ requestParams?.onTransportError?.(error, deliveryAmbiguous)
+ },
+ rawPayload: true,
+ skipBufferingOnError: false,
+ throwError: true,
+ })
+ stopTransactionRequestSettled = true
+ if (stopTransactionCarriesPublicKey()) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ clearTransactionEnding()
+ return stopTransactionResponse
+ } catch (error) {
+ stopTransactionRequestSettled = true
+ if (
+ terminalMeterValuesRequest != null &&
+ !terminalMeterValuesDeliveryStarted &&
+ !stopTransactionCarriesPublicKey()
+ ) {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ const terminalMeterValuesCached =
+ terminalMeterValuesRequest != null &&
+ OCPP16ServiceUtils.hasCachedRequestPayload(
+ chargingStation,
+ RequestCommand.METER_VALUES,
+ terminalMeterValuesRequest
+ )
+ const stopTransactionCached =
+ stopTransactionSnapshot != null &&
+ OCPP16ServiceUtils.hasCachedRequestPayload(
+ chargingStation,
+ RequestCommand.STOP_TRANSACTION,
+ stopTransactionSnapshot
+ )
+ const definitelyRejected =
+ stopDeliveryState.callError ||
+ (!stopDeliveryState.buffered &&
+ !stopDeliveryState.responseReceived &&
+ !stopDeliveryState.sent &&
+ !stopDeliveryState.transportErrorAmbiguous)
+ if (stopTransactionCarriesPublicKey()) {
+ if (stopTransactionCached || !definitelyRejected) {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ } else {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ }
+ if (!stopTransactionCached && definitelyRejected) restoreStopTransactionInterval()
+ if (
+ connectorStatus.transactionId !== rawTransactionId ||
+ (!terminalMeterValuesCached && !stopTransactionCached)
+ ) {
+ clearTransactionEnding(true)
+ }
+ throw error
+ }
+ })()
+
+ const operation = { promise: stopTransactionPromise, transactionId: rawTransactionId }
+ OCPP16ServiceUtils.stopTransactionOperations.set(connectorStatus, operation)
+ stopTransactionPromise
+ .finally(() => {
+ if (OCPP16ServiceUtils.stopTransactionOperations.get(connectorStatus) === operation) {
+ OCPP16ServiceUtils.stopTransactionOperations.delete(connectorStatus)
+ }
+ })
+ .catch(() => undefined)
+ return stopTransactionPromise
}
/**
if (connectorStatus?.transactionUpdatedMeterValuesSetInterval != null) {
clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval)
delete connectorStatus.transactionUpdatedMeterValuesSetInterval
+ OCPP16ServiceUtils.periodicMeterValuesIntervals.delete(connectorStatus)
}
}
}
}
+ private static bufferCachedRequestPayload (
+ chargingStation: ChargingStation,
+ commandName: RequestCommand,
+ payload: unknown,
+ error: OCPPError
+ ): boolean {
+ for (const cachedRequest of chargingStation.requests.values()) {
+ const [, , cachedCommandName, cachedPayload, bufferRequest] = cachedRequest
+ if (cachedCommandName === commandName && cachedPayload === payload) {
+ return bufferRequest?.(error) === true
+ }
+ }
+ return false
+ }
+
/**
* Coherent-path builder for the OCPP 1.6 transaction-begin MeterValue.
* Routes through `buildMeterValue` so the begin MeterValue is drawn
}
}
+ /**
+ * Builds a StopTransaction transaction-data snapshot carrying restored interval energy without
+ * advancing the cumulative register a second time.
+ * @param chargingStation - Target charging station
+ * @param connectorId - Connector identifier
+ * @param transactionId - Active transaction identifier
+ * @param meterStop - Final cumulative register in Wh
+ * @param meterValue - Base Transaction.End meter value
+ * @returns An enriched snapshot when interval energy was represented
+ */
+ private static buildStopTransactionIntervalMeterValue (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ transactionId: number | string,
+ meterStop: number,
+ meterValue: OCPP16MeterValue
+ ): OCPP16MeterValue | undefined {
+ const intervalEnergyWh =
+ chargingStation.getConnectorStatus(connectorId)?.transactionEnergyActiveImportIntervalCarry
+ ?.default ?? 0
+ if (intervalEnergyWh <= 0) return
+ const intervalMeterValue = buildMeterValue(
+ chargingStation,
+ transactionId,
+ 0,
+ OCPP16StandardParametersKey.StopTxnSampledData,
+ OCPP16MeterValueContext.TRANSACTION_END,
+ false,
+ {
+ advanceEnergy: false,
+ connectorId,
+ energyIntervalWhOverride: intervalEnergyWh,
+ energyRegisterWhOverride: meterStop,
+ projectDcForLocation: true,
+ snapshot: true,
+ timestamp: meterValue.timestamp,
+ }
+ ) as OCPP16MeterValue
+ const intervalSamples = intervalMeterValue.sampledValue.filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ if (!isNotEmptyArray(intervalSamples)) return
+ const enrichedMeterValue = clone(meterValue)
+ enrichedMeterValue.sampledValue.push(...intervalSamples)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ recordTransactionIntervalEmission(
+ connectorStatus,
+ enrichedMeterValue,
+ 'default',
+ intervalEnergyWh,
+ chargingStation.getNumberOfPhases(),
+ resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency
+ )
+ )
+ }
+ return enrichedMeterValue
+ }
+
private static readonly composeChargingSchedule = (
chargingSchedule: OCPP16ChargingSchedule,
compositeInterval: Interval
}
}
+ private static hasCachedRequestPayload (
+ chargingStation: ChargingStation,
+ commandName: RequestCommand,
+ payload: unknown
+ ): boolean {
+ for (const [, , cachedCommandName, cachedPayload] of chargingStation.requests.values()) {
+ if (cachedCommandName === commandName && cachedPayload === payload) return true
+ }
+ return false
+ }
+
+ private static meterValueHasPublicKey (meterValue: unknown): boolean {
+ if (
+ typeof meterValue !== 'object' ||
+ meterValue == null ||
+ !('sampledValue' in meterValue) ||
+ !Array.isArray(meterValue.sampledValue)
+ ) {
+ return false
+ }
+ return meterValue.sampledValue.some((sampledValue: unknown) => {
+ if (typeof sampledValue !== 'object' || sampledValue == null) return false
+ const { format, value } = sampledValue as Partial<OCPP16SampledValue>
+ if (format !== OCPP16MeterValueFormat.SIGNED_DATA || typeof value !== 'string') return false
+ try {
+ const signedMeterValue = JSON.parse(value) as unknown
+ return (
+ typeof signedMeterValue === 'object' &&
+ signedMeterValue != null &&
+ 'publicKey' in signedMeterValue &&
+ isNotEmptyString(signedMeterValue.publicKey)
+ )
+ } catch {
+ return false
+ }
+ })
+ }
+
+ private static normalizeStopTransactionTimestamp (timestamp: unknown): Date {
+ if (
+ timestamp instanceof Date &&
+ Number.isFinite(timestamp.getTime()) &&
+ hasOcppTimestampYear(timestamp)
+ ) {
+ return timestamp
+ }
+ if (typeof timestamp === 'string') {
+ const normalizedTimestamp = parseStopTransactionTimestamp(timestamp)
+ if (normalizedTimestamp != null) return normalizedTimestamp
+ }
+ throw new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ `${moduleName}.stopTransactionOnConnector: Invalid StopTransaction timestamp`,
+ RequestCommand.STOP_TRANSACTION
+ )
+ }
+
private static readSigningConfigForConnector (
chargingStation: ChargingStation,
connectorId: number
signingMethod: prerequisiteResult.signingMethod,
}
}
+
+ private static removePublicKeys (
+ meterValues: OCPP16MeterValue[] | undefined
+ ): OCPP16MeterValue[] | undefined {
+ return meterValues?.map(meterValue => ({
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.map(sampledValue => {
+ if (sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA) return sampledValue
+ try {
+ const signedMeterValue = JSON.parse(sampledValue.value) as OCPP16SignedMeterValue
+ if (!isNotEmptyString(signedMeterValue.publicKey)) return sampledValue
+ return {
+ ...sampledValue,
+ value: JSON.stringify({ ...signedMeterValue, publicKey: '' }),
+ }
+ } catch {
+ return sampledValue
+ }
+ }),
+ }))
+ }
}
import { secondsToMilliseconds } from 'date-fns'
-import type { ChargingStation } from '../../../charging-station/index.js'
import type { OCPP20IdTokenEnumType } from '../../../types/index.js'
import { OCPPError } from '../../../exception/index.js'
type OCPP20InstallCertificateResponse,
type OCPP20LogStatusNotificationRequest,
type OCPP20LogStatusNotificationResponse,
- OCPP20MeasurandEnumType,
type OCPP20MeterValue,
type OCPP20MeterValuesRequest,
- type OCPP20MeterValuesResponse,
type OCPP20NotifyCustomerInformationRequest,
type OCPP20NotifyCustomerInformationResponse,
type OCPP20NotifyReportRequest,
import {
addConfigurationKey,
buildConfigKey,
+ type ChargingStation,
getConfigurationKey,
+ getRawSignedMeterValuePublicKey,
hasPendingReservation,
hasPendingReservations,
+ hasQueuedEndedTransactionEvent,
+ isTransactionEventQueueStaged,
resetConnectorStatus,
} from '../../index.js'
+import {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ recordFrozenTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ restoreTransactionIntervalState,
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+} from '../../meter-values/index.js'
import {
AuthContext,
AuthResultStatus,
} from '../OCPPConnectorStatusOperations.js'
import { OCPPIncomingRequestService } from '../OCPPIncomingRequestService.js'
import {
- buildMeterValue,
+ buildClockAlignedConnectorMeterValue,
createPayloadValidatorMap,
isIncomingRequestCommandSupported,
} from '../OCPPServiceUtils.js'
+import {
+ claimPublicKeyDelivery,
+ type PublicKeyDeliveryToken,
+} from '../OCPPSignedMeterValueUtils.js'
import {
type GetInstalledCertificatesResult,
hasCertificateManager,
} from './OCPP20CertificateManager.js'
import { OCPP20CertSigningRetryManager } from './OCPP20CertSigningRetryManager.js'
import { OCPP20Constants } from './OCPP20Constants.js'
-import { isOCPP20ConnectorStatus, OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
+import {
+ type IntervalBaselineRestore,
+ isOCPP20ConnectorStatus,
+ isTransactionEnding,
+ OCPP20ServiceUtils,
+ type TriggeredTransactionEventDeliveryReservation,
+} from './OCPP20ServiceUtils.js'
import { OCPP20VariableManager } from './OCPP20VariableManager.js'
import { getVariableMetadata, VARIABLE_REGISTRY } from './OCPP20VariableRegistry.js'
certSigningRetryManager?: OCPP20CertSigningRetryManager
isDrainingSecurityEvents: boolean
lastFirmwareStatusNotification?: LastFirmwareStatusNotification
- preInoperativeConnectorStatuses: Map<number, OCPP20ConnectorStatusEnumType>
+ preInoperativeConnectorStatuses: Map<string, OCPP20ConnectorStatusEnumType>
reportDataCache: Map<number, ReportDataType[]>
+ reportDataReservations: Map<number, ReportDataReservation>
securityEventQueue: QueuedSecurityEvent[]
/**
* `setTimeout` handle for the pending {@link sendQueuedSecurityEvents}
stopped?: boolean
}
+interface PendingRemoteStartResponse {
+ readonly connectorId: number
+ readonly evseId: number
+ readonly transactionId: string
+}
+
interface QueuedSecurityEvent {
retryCount?: number
techInfo?: string
type: string
}
+interface RemoteStartConnectorTarget {
+ readonly connectorId: number
+ readonly connectorStatus: ConnectorStatus
+ readonly evseId: number
+ readonly evseStatus: EvseStatus
+}
+
+interface ReportDataReservation {
+ release: () => void
+}
+
+interface TriggeredMeterValueSample {
+ readonly connectorId?: number
+ readonly delivery?: TransactionMeterValueDelivery
+ readonly intervalBaselineKey?: string
+ readonly intervalState?: ReturnType<typeof captureTransactionIntervalState>
+ readonly meterValue: OCPP20MeterValue
+ readonly transactionId?: string
+}
+
+interface TriggeredMeterValuesDeliveryTarget {
+ delivery: TransactionMeterValueDelivery
+ evseId: number
+}
+
+interface TriggeredMeterValuesReservation {
+ readonly deliveryTargets: readonly TriggeredMeterValuesDeliveryTarget[]
+ readonly targets: readonly TriggeredMeterValuesTarget[]
+}
+
+interface TriggeredMeterValuesTarget {
+ readonly evseId: number
+ readonly samples: readonly TriggeredMeterValueSample[]
+}
+
+interface TriggeredTransactionEventTarget {
+ connectorId: number
+ evseId: number
+ reservation: TriggeredTransactionEventDeliveryReservation
+ transactionId: string
+}
+
+interface TriggerMessageConnectorTarget {
+ readonly connectorId: number
+ readonly connectorStatus: ConnectorStatus
+ readonly evseId: number
+}
+
/**
* OCPP 2.0.1 incoming-request service.
*
OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
]
+ private readonly pendingRemoteStartResponses = new WeakMap<
+ OCPP20RequestStartTransactionRequest,
+ PendingRemoteStartResponse
+ >()
+
+ private readonly pendingResetActions = new WeakMap<
+ OCPP20ResetRequest,
+ (lifecycleSignal: AbortSignal) => Promise<void> | void
+ >()
+
+ private readonly triggerMeterValuesReservations = new WeakMap<
+ OCPP20TriggerMessageRequest,
+ TriggeredMeterValuesReservation
+ >()
+
+ private readonly triggerTransactionEventReservations = new WeakMap<
+ OCPP20TriggerMessageRequest,
+ readonly TriggeredTransactionEventTarget[]
+ >()
+
public constructor () {
super(OCPPVersion.VERSION_201)
this.incomingRequestHandlers = new Map<IncomingRequestCommand, IncomingRequestHandler>([
}
)
// E02.FR.01: Send TransactionEvent(Started) after accepting remote start
+ this.on(
+ OCPP20IncomingRequestCommand.RESET,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20ResetRequest,
+ response: OCPP20ResetResponse
+ ) => {
+ const action = this.pendingResetActions.get(request)
+ this.pendingResetActions.delete(request)
+ if (
+ (response.status !== ResetStatusEnumType.Accepted &&
+ response.status !== ResetStatusEnumType.Scheduled) ||
+ action == null
+ ) {
+ return
+ }
+ this.runOutsideIncomingRequestContext(() => {
+ const lifecycleSignal = chargingStation.lifecycleAbortSignal
+ Promise.resolve(action(lifecycleSignal)).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: Reset action error:`,
+ error
+ )
+ })
+ })
+ }
+ )
this.on(
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
(
request: OCPP20RequestStartTransactionRequest,
response: OCPP20RequestStartTransactionResponse
) => {
- if (response.status === RequestStartStopStatusEnumType.Accepted) {
- const connectorId = chargingStation.getConnectorIdByTransactionId(response.transactionId)
- if (connectorId != null && response.transactionId != null) {
- const txId = response.transactionId
- chargingStation.createCoherentSession(txId, connectorId)
+ const pending = this.pendingRemoteStartResponses.get(request)
+ this.pendingRemoteStartResponses.delete(request)
+ const txId = response.transactionId
+ if (
+ response.status === RequestStartStopStatusEnumType.Accepted &&
+ txId != null &&
+ pending?.transactionId === txId
+ ) {
+ const { connectorId, evseId } = pending
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ connectorStatus?.transactionId?.toString() === txId &&
+ connectorStatus.transactionPending === true &&
+ connectorStatus.transactionStarting !== true &&
+ connectorStatus.transactionStarted !== true &&
+ !isTransactionEnding(connectorStatus)
+ ) {
+ chargingStation.createCoherentSession(txId, connectorId, evseId)
const startedMeterValues = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
chargingStation,
- txId
+ txId,
+ connectorId,
+ evseId
)
OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
connectorId,
txId,
{
+ evseId,
...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }),
remoteStartId: request.remoteStartId,
- }
+ },
+ undefined,
+ undefined,
+ 'pending'
).catch((error: unknown) => {
// Session was created for this fire-and-forget send. If the
// send rejects, destroy it here — no other cleanup path
const connectorId = chargingStation.getConnectorIdByTransactionId(request.transactionId)
const evseId = chargingStation.getEvseIdByTransactionId(request.transactionId)
if (connectorId != null && evseId != null) {
- OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.constructor: RequestStopTransaction error:`,
- error
- )
- }
- )
+ OCPP20ServiceUtils.requestStopTransaction(
+ chargingStation,
+ connectorId,
+ evseId,
+ undefined,
+ undefined,
+ request.transactionId
+ ).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.constructor: RequestStopTransaction error:`,
+ error
+ )
+ })
}
}
}
// L01.FR.20 & L02.FR.14 — requestId mandatory unless status = Idle.
// Fresh station (no notification ever sent): Idle (spec-silent precondition,
// consistent with FirmwareStatusEnumType.Idle §3.33 "trigger-only" restriction).
- const lastSent = this.stationsState.get(chargingStation)?.lastFirmwareStatusNotification
+ const lastSent = this.getStationState(chargingStation)?.lastFirmwareStatusNotification
const payload: OCPP20FirmwareStatusNotificationRequest =
lastSent == null || lastSent.status === OCPP20FirmwareStatusEnumType.Installed
? { status: OCPP20FirmwareStatusEnumType.Idle }
.catch(errorHandler)
break
case MessageTriggerEnumType.LogStatusNotification: {
- const stationState = this.stationsState.get(chargingStation)
+ const stationState = this.getStationState(chargingStation)
const requestId = stationState?.activeLogUploadRequestId
const logStatus =
requestId != null
.catch(errorHandler)
break
}
- case MessageTriggerEnumType.MeterValues:
- this.triggerMeterValues(chargingStation, evse, errorHandler)
+ case MessageTriggerEnumType.MeterValues: {
+ const reservation = this.triggerMeterValuesReservations.get(request)
+ this.triggerMeterValuesReservations.delete(request)
+ if (reservation != null) {
+ this.triggerMeterValues(chargingStation, reservation, errorHandler)
+ }
break
+ }
case MessageTriggerEnumType.StatusNotification:
this.triggerStatusNotification(chargingStation, evse, errorHandler)
break
- case MessageTriggerEnumType.TransactionEvent:
- this.triggerTransactionEvent(chargingStation, evse, errorHandler)
+ case MessageTriggerEnumType.TransactionEvent: {
+ const reservedTargets = this.triggerTransactionEventReservations.get(request)
+ this.triggerTransactionEventReservations.delete(request)
+ if (reservedTargets != null) {
+ for (const target of reservedTargets) {
+ target.reservation
+ .run(() => this.triggerTransactionEvent(chargingStation, target))
+ .catch(errorHandler)
+ }
+ }
break
+ }
}
}
)
if (resolved == null) {
return ConfigurationStatus.NOT_SUPPORTED
}
- const { component, instance, variable } = resolved
+ const { component, evseId, instance, variable } = resolved
const response = this.handleRequestSetVariables(chargingStation, {
setVariableData: [
{
attributeType: AttributeEnumType.Actual,
attributeValue: value,
- component: { name: component, ...(instance != null && { instance }) },
+ component: {
+ name: component,
+ ...(evseId != null && { evse: { id: evseId } }),
+ ...(instance != null && { instance }),
+ },
variable: { name: variable },
},
],
*/
public override stop (chargingStation: ChargingStation): void {
super.stop(chargingStation)
+ OCPP20ServiceUtils.cancelClockAlignedMeterValuesRequests(chargingStation)
try {
const variableManager = OCPP20VariableManager.getInstance()
const stationId = chargingStation.stationInfo?.hashId
}
/**
- * @returns A fresh `OCPP20StationState` with the four required fields
- * initialized (empty `Map` for `preInoperativeConnectorStatuses` and
- * `reportDataCache`, empty array for `securityEventQueue`,
- * `isDrainingSecurityEvents: false`). The seven optional
- * lifecycle-owned fields (`activeFirmwareUpdate*`,
- * `activeLogUpload*`, `certSigningRetryManager`,
- * `lastFirmwareStatusNotification`) start absent and are assigned
- * lazily by handlers.
+ * @param commandName - Delivered incoming command
+ * @returns Whether the response callback remains admissible during shutdown
+ */
+ protected override canDispatchResponseWhileStopping (
+ commandName: IncomingRequestCommand
+ ): boolean {
+ return commandName === OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION
+ }
+
+ /**
+ * @returns A fresh `OCPP20StationState` with required collections initialized;
+ * optional lifecycle-owned fields start absent and are assigned lazily by handlers.
*/
protected override createStationState (): OCPP20StationState {
return {
isDrainingSecurityEvents: false,
preInoperativeConnectorStatuses: new Map(),
reportDataCache: new Map(),
+ reportDataReservations: new Map(),
securityEventQueue: [],
}
}
+ /**
+ * Measures exact trigger snapshots retained by response delivery callbacks.
+ * @param commandName - Incoming command associated with the response.
+ * @param commandPayload - Exact incoming request payload owning the reservation.
+ * @returns Serialized bytes retained by trigger reservations.
+ */
+ protected override getResponseDeliveryRetainedBytes (
+ commandName: IncomingRequestCommand,
+ commandPayload: JsonType
+ ): number {
+ if (commandName !== OCPP20IncomingRequestCommand.TRIGGER_MESSAGE) return 0
+ const request = commandPayload as OCPP20TriggerMessageRequest
+ const meterValues = this.triggerMeterValuesReservations.get(request)?.targets.map(target => ({
+ evseId: target.evseId,
+ meterValue: target.samples.map(sample => sample.meterValue),
+ }))
+ const transactionEvents = this.triggerTransactionEventReservations
+ .get(request)
+ ?.map(({ connectorId, evseId, transactionId }) => ({ connectorId, evseId, transactionId }))
+ return Buffer.byteLength(JSON.stringify([meterValues, transactionEvents]), 'utf8')
+ }
+
/**
* Handles OCPP 2.0.1 ClearCache request by clearing the Authorization Cache
* per OCPP 2.0.1 spec C11.FR.01
return isIncomingRequestCommandSupported(chargingStation, commandName)
}
+ protected override async onResponseSendError (
+ chargingStation: ChargingStation,
+ commandName: IncomingRequestCommand,
+ commandPayload: JsonType
+ ): Promise<void> {
+ if (commandName === OCPP20IncomingRequestCommand.RESET) {
+ this.pendingResetActions.delete(commandPayload as OCPP20ResetRequest)
+ return
+ }
+ if (commandName === OCPP20IncomingRequestCommand.GET_BASE_REPORT) {
+ const request = commandPayload as OCPP20GetBaseReportRequest
+ const stationState = this.getStationState(chargingStation)
+ if (stationState != null) {
+ this.releaseReportDataReservation(chargingStation, stationState, request.requestId)
+ }
+ return
+ }
+ if (commandName === OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION) {
+ const request = commandPayload as OCPP20RequestStartTransactionRequest
+ const pending = this.pendingRemoteStartResponses.get(request)
+ this.pendingRemoteStartResponses.delete(request)
+ if (pending == null) return
+ const connectorStatus = chargingStation.getConnectorStatus(
+ pending.connectorId,
+ pending.evseId
+ )
+ if (
+ connectorStatus?.transactionPending !== true ||
+ connectorStatus.transactionId?.toString() !== pending.transactionId
+ ) {
+ return
+ }
+ try {
+ await this.resetConnectorOnStartTransactionError(
+ chargingStation,
+ pending.connectorId,
+ pending.evseId,
+ pending.transactionId
+ )
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.onResponseSendError: Failed to roll back RequestStartTransaction state:`,
+ error
+ )
+ }
+ return
+ }
+ if (commandName !== OCPP20IncomingRequestCommand.TRIGGER_MESSAGE) return
+ const request = commandPayload as OCPP20TriggerMessageRequest
+ const meterValuesReservation = this.triggerMeterValuesReservations.get(request)
+ if (meterValuesReservation != null) {
+ this.triggerMeterValuesReservations.delete(request)
+ OCPP20ServiceUtils.releaseTriggeredMeterValuesRequests(
+ chargingStation,
+ meterValuesReservation.targets.map(target => target.evseId)
+ )
+ for (const { delivery } of meterValuesReservation.deliveryTargets) delivery.settle(true)
+ }
+ const reservedTransactionTargets = this.triggerTransactionEventReservations.get(request)
+ if (reservedTransactionTargets != null) {
+ this.triggerTransactionEventReservations.delete(request)
+ for (const { reservation } of reservedTransactionTargets) reservation.release()
+ }
+ }
+
/**
* Reset per-station lifecycle state prior to the base template
* marking the entry `stopped: true`.
stationState.activeLogUploadAbortController?.abort()
stationState.certSigningRetryManager?.cancelRetryTimer()
this.cancelSecurityEventRetryTimer(stationState)
+ for (const reservation of stationState.reportDataReservations.values()) {
+ reservation.release()
+ }
+ stationState.reportDataReservations.clear()
+ stationState.reportDataCache.clear()
this.resetActiveFirmwareUpdateState(stationState)
this.resetActiveLogUploadState(stationState)
}
return authResult.status === AuthResultStatus.ACCEPTED
}
+ private buildIdleTriggeredMeterValue (
+ chargingStation: ChargingStation,
+ evseId: number,
+ evseStatus: EvseStatus,
+ alignedMeasurandsKey: string,
+ timestamp: Date,
+ connectorId?: number
+ ): OCPP20MeterValue | undefined {
+ const resolvedConnectorId = connectorId ?? evseStatus.connectors.keys().next().value
+ if (resolvedConnectorId == null) return
+ const connectorStatus = evseStatus.connectors.get(resolvedConnectorId)
+ if (connectorStatus == null) return
+ const energyRegisterWhOverride =
+ connectorId == null
+ ? [...evseStatus.connectors.values()].reduce(
+ (total, status) => total + Math.max(0, status.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ : Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0)
+ try {
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ advanceEnergy: false,
+ commitState: false,
+ connectorId: resolvedConnectorId,
+ energyRegisterWhOverride,
+ evseId,
+ idle: true,
+ timestamp,
+ },
+ 0,
+ alignedMeasurandsKey,
+ OCPP20ReadingContextEnumType.TRIGGER
+ )
+ return isNotEmptyArray(meterValue.sampledValue) ? meterValue : undefined
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.buildIdleTriggeredMeterValue: ${getErrorMessage(error)}`
+ )
+ return undefined
+ }
+ }
+
private buildReportData (
chargingStation: ChargingStation,
reportBase: ReportBaseEnumType
const reportData: ReportDataType[] = []
switch (reportBase) {
- case ReportBaseEnumType.ConfigurationInventory:
+ case ReportBaseEnumType.ConfigurationInventory: {
+ const variableManager = OCPP20VariableManager.getInstance()
if (chargingStation.ocppConfiguration?.configurationKey) {
for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
+ const resolved = variableManager.resolveConfigurationKeyName(configKey.key)
+ const metadata =
+ resolved == null
+ ? undefined
+ : getVariableMetadata(resolved.component, resolved.variable, resolved.instance)
reportData.push({
- component: {
- name: OCPP20ComponentName.OCPPCommCtrlr,
- },
- variable: {
- name: configKey.key,
- },
+ component:
+ resolved == null
+ ? { name: OCPP20ComponentName.OCPPCommCtrlr }
+ : {
+ name: resolved.component,
+ ...(resolved.evseId != null && { evse: { id: resolved.evseId } }),
+ },
+ variable:
+ resolved == null
+ ? { name: configKey.key }
+ : {
+ name: resolved.variable,
+ ...(resolved.instance != null && { instance: resolved.instance }),
+ },
variableAttribute: [
{
type: AttributeEnumType.Actual,
},
],
variableCharacteristics: {
- dataType: DataEnumType.string,
+ dataType: metadata?.dataType ?? DataEnumType.string,
supportsMonitoring: false,
},
})
}
}
break
+ }
- case ReportBaseEnumType.FullInventory:
+ case ReportBaseEnumType.FullInventory: {
reportData.push(...buildStationInfoReportData(chargingStation, STATION_INFO_FIELDS_FULL))
+ const variableManager = OCPP20VariableManager.getInstance()
if (chargingStation.ocppConfiguration?.configurationKey) {
for (const configKey of chargingStation.ocppConfiguration.configurationKey) {
+ if (variableManager.resolveConfigurationKeyName(configKey.key) != null) continue
const variableAttributes = []
variableAttributes.push({
type: AttributeEnumType.Actual,
}
try {
- const variableManager = OCPP20VariableManager.getInstance()
const getVariableData: OCPP20GetVariablesRequest['getVariableData'] = []
for (const variableMetadata of Object.values(VARIABLE_REGISTRY)) {
const variableDescriptor: { instance?: string; name: string } = {
if (variableMetadata.instance) {
variableDescriptor.instance = variableMetadata.instance
}
- getVariableData.push({
- attributeType: AttributeEnumType.Actual,
- component: { name: variableMetadata.component },
- variable: variableDescriptor,
- })
- if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MinSet)) {
- getVariableData.push({
- attributeType: AttributeEnumType.MinSet,
- component: { name: variableMetadata.component },
- variable: variableDescriptor,
- })
- }
- if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MaxSet)) {
+ for (const component of variableManager.getSupportedComponents(
+ chargingStation,
+ variableMetadata
+ )) {
getVariableData.push({
- attributeType: AttributeEnumType.MaxSet,
- component: { name: variableMetadata.component },
+ attributeType: AttributeEnumType.Actual,
+ component,
variable: variableDescriptor,
})
+ if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MinSet)) {
+ getVariableData.push({
+ attributeType: AttributeEnumType.MinSet,
+ component,
+ variable: variableDescriptor,
+ })
+ }
+ if (variableMetadata.supportedAttributes.includes(AttributeEnumType.MaxSet)) {
+ getVariableData.push({
+ attributeType: AttributeEnumType.MaxSet,
+ component,
+ variable: variableDescriptor,
+ })
+ }
}
}
const getResults = variableManager.getVariables(chargingStation, getVariableData)
}
>()
for (const r of getResults) {
- const key = `${r.component.name}::${r.variable.name}${r.variable.instance ? '::' + r.variable.instance : ''}`
+ const key = JSON.stringify([r.component, r.variable])
const variableMetadata = getVariableMetadata(
r.component.name,
r.variable.name,
})
}
break
+ }
case ReportBaseEnumType.SummaryInventory:
reportData.push(...buildStationInfoReportData(chargingStation, STATION_INFO_FIELDS_SUMMARY))
return reportData
}
+ private buildTriggeredMeterValuesTarget (
+ chargingStation: ChargingStation,
+ evseId: number,
+ evseStatus: EvseStatus,
+ alignedMeasurandsKey: string,
+ timestamp: Date,
+ connectorId?: number
+ ): TriggeredMeterValuesTarget | undefined {
+ const connectorStatus = connectorId == null ? undefined : evseStatus.connectors.get(connectorId)
+ const connectors: [number, ConnectorStatus][] =
+ connectorId == null
+ ? [...evseStatus.connectors.entries()]
+ : connectorStatus == null
+ ? []
+ : [[connectorId, connectorStatus]]
+ const activeConnectors = connectors.filter(([, connectorStatus]) =>
+ this.isTriggeredTransactionActive(connectorStatus)
+ )
+ if (isEmpty(activeConnectors)) {
+ if (connectors.some(([, status]) => this.hasTriggeredTransactionState(status))) {
+ return
+ }
+ const meterValue = this.buildIdleTriggeredMeterValue(
+ chargingStation,
+ evseId,
+ evseStatus,
+ alignedMeasurandsKey,
+ timestamp,
+ connectorId
+ )
+ return meterValue == null ? undefined : { evseId, samples: [{ meterValue }] }
+ }
+
+ const samples: TriggeredMeterValueSample[] = []
+ for (const [connectorId, connectorStatus] of activeConnectors) {
+ const transactionId = connectorStatus.transactionId?.toString()
+ if (transactionId == null) continue
+ const delivery = TransactionMeterValueDeliveryBarrier.begin(connectorStatus, transactionId)
+ if (delivery == null) {
+ for (const sample of samples) sample.delivery?.settle(true)
+ return
+ }
+ const intervalState = captureTransactionIntervalState(connectorStatus)
+ try {
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ advanceEnergy: true,
+ commitState: false,
+ connectorId,
+ evseId,
+ timestamp,
+ transactionId,
+ },
+ 0,
+ alignedMeasurandsKey,
+ OCPP20ReadingContextEnumType.TRIGGER
+ )
+ if (isNotEmptyArray(meterValue.sampledValue)) {
+ samples.push({
+ connectorId,
+ delivery,
+ intervalBaselineKey: alignedMeasurandsKey,
+ intervalState,
+ meterValue,
+ transactionId,
+ })
+ } else {
+ delivery.settle(true)
+ }
+ } catch (error) {
+ delivery.settle(true)
+ for (const sample of samples) sample.delivery?.settle(true)
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.buildTriggeredMeterValuesTarget: ${getErrorMessage(error)}`
+ )
+ return
+ }
+ }
+ return isNotEmptyArray(samples) ? { evseId, samples } : undefined
+ }
+
/**
* Cancels a pending {@link sendQueuedSecurityEvents} retry
* `setTimeout` and clears the stored handle. No-op when no retry is
}
private clearActiveFirmwareUpdate (chargingStation: ChargingStation, requestId: number): void {
- const stationState = this.stationsState.get(chargingStation)
+ const stationState = this.getStationState(chargingStation)
if (stationState == null) {
return
}
}
private clearActiveLogUpload (chargingStation: ChargingStation, requestId: number): void {
- const stationState = this.stationsState.get(chargingStation)
+ const stationState = this.getStationState(chargingStation)
if (stationState == null) {
return
}
}
}
- private connectorHasQueuedEvents (
+ private connectorHasPendingTransactionEvents (
connectorStatus: ConnectorStatus,
transactionId?: string
): boolean {
+ const directDeliveryPending = OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ connectorStatus,
+ transactionId
+ )
const queue = connectorStatus.transactionEventQueue
if (queue == null || !isNotEmptyArray(queue)) {
- return false
+ return directDeliveryPending
}
- if (transactionId == null) {
- return true
- }
- return queue.some(({ request }) => request.transactionInfo.transactionId === transactionId)
+ return (
+ directDeliveryPending ||
+ queue.some(
+ queuedEvent =>
+ !isTransactionEventQueueStaged(queuedEvent) &&
+ (transactionId == null ||
+ queuedEvent.request.transactionInfo.transactionId === transactionId)
+ )
+ )
}
/**
- * Emits one `MeterValuesRequest` aggregating the given EVSE's
- * active-transaction connectors, or a schema-conforming placeholder
- * when the EVSE has no active transactions.
+ * Emits the exact MeterValues snapshot reserved before TriggerMessage acceptance.
+ * Transaction-bound samples are discarded if their transaction is no longer active.
* @param chargingStation - Target charging station.
- * @param evseId - Target EVSE identifier.
- * @param evseStatus - Target EVSE status (yields the connectors).
- * @param alignedInterval - Aligned-data emission interval in milliseconds.
- * @param alignedMeasurandsKey - Configuration key for the AlignedDataCtrlr.Measurands allow-list.
+ * @param target - Reserved EVSE snapshot and transaction identities.
* @param errorHandler - Handler for downstream request-emission errors.
+ * @param onSettled - Releases transaction delivery barriers after the request outcome is resolved.
*/
private emitEvseMeterValues (
chargingStation: ChargingStation,
- evseId: number,
- evseStatus: EvseStatus,
- alignedInterval: number,
- alignedMeasurandsKey: string,
- errorHandler: (error: unknown) => void
+ target: TriggeredMeterValuesTarget,
+ errorHandler: (error: unknown) => void,
+ onSettled: (definitivelyRejected: boolean) => void
): void {
- const meterValues: OCPP20MeterValue[] = []
- for (const connector of evseStatus.connectors.values()) {
- if (connector.transactionId == null) continue
- let meterValue: OCPP20MeterValue
- try {
- meterValue = buildMeterValue(
- chargingStation,
- connector.transactionId,
- alignedInterval,
- alignedMeasurandsKey,
- OCPP20ReadingContextEnumType.TRIGGER
- ) as OCPP20MeterValue
- } catch (error) {
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.emitEvseMeterValues: ${getErrorMessage(error)}`
- )
- continue
- }
- // OCPP 2.0.1 MeterValueType.sampledValue cardinality is 1..*:
- // skip a MeterValue whose emit set collapsed to empty so the
- // outgoing MeterValuesRequest stays schema-conforming.
- if (isNotEmptyArray(meterValue.sampledValue)) {
- meterValues.push(meterValue)
- }
+ const samples = target.samples.filter(sample => {
+ if (sample.connectorId == null || sample.transactionId == null) return true
+ const connectorStatus = chargingStation.getConnectorStatus(sample.connectorId, target.evseId)
+ return (
+ connectorStatus != null &&
+ this.isReservedTriggeredTransactionCurrent(connectorStatus, sample.transactionId)
+ )
+ })
+ if (isEmpty(samples)) {
+ OCPP20ServiceUtils.releaseTriggeredMeterValuesRequests(chargingStation, [target.evseId])
+ onSettled(true)
+ return
}
- if (isEmpty(meterValues)) {
- meterValues.push({
- sampledValue: [
- {
- context: OCPP20ReadingContextEnumType.TRIGGER,
- measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
- value: 0,
- },
- ],
- timestamp: new Date(),
- })
+
+ const request: OCPP20MeterValuesRequest = {
+ evseId: target.evseId,
+ meterValue: samples.map(sample => sample.meterValue),
}
- chargingStation.ocppRequestService
- .requestHandler<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
- chargingStation,
- OCPP20RequestCommand.METER_VALUES,
- {
- evseId,
- meterValue: meterValues,
- },
- { skipBufferingOnError: true, triggerMessage: true }
+ const publicKeyDeliveryTokens: PublicKeyDeliveryToken[] = []
+ const restoreIntervalBaselines: IntervalBaselineRestore[] = []
+ for (const sample of samples) {
+ const { connectorId, intervalBaselineKey, intervalState, transactionId } = sample
+ if (connectorId == null || transactionId == null) continue
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, target.evseId)
+ if (connectorStatus == null) continue
+ if (intervalBaselineKey != null && intervalState != null) {
+ recordFrozenTransactionIntervalEmission(
+ connectorStatus,
+ sample.meterValue,
+ intervalBaselineKey,
+ chargingStation.getNumberOfPhases(),
+ resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency,
+ target.evseId
+ )
+ )
+ completeTransactionIntervalState(intervalState, intervalBaselineKey, [sample.meterValue])
+ restoreIntervalBaselines.push({
+ consumed: intervalState.consumed,
+ generation: transactionId,
+ key: connectorStatus,
+ restore: consumed => {
+ restoreTransactionIntervalState(
+ { ...intervalState, consumed },
+ connectorStatus,
+ intervalBaselineKey
+ )
+ },
+ })
+ }
+ const token = claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ request,
+ sample.meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
)
- .catch(errorHandler)
+ if (token != null) publicKeyDeliveryTokens.push(token)
+ }
+ OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ chargingStation,
+ target.evseId,
+ request,
+ undefined,
+ restoreIntervalBaselines,
+ true,
+ publicKeyDeliveryTokens,
+ onSettled
+ ).catch(errorHandler)
}
private getRestoredConnectorStatus (
chargingStation: ChargingStation,
+ evseId: number,
connectorId: number
): OCPP20ConnectorStatusEnumType {
const stationState = this.getOrCreateStationState(chargingStation)
- const saved = stationState.preInoperativeConnectorStatuses.get(connectorId)
+ const connectorKey = `${evseId.toString()}:${connectorId.toString()}`
+ const saved = stationState.preInoperativeConnectorStatuses.get(connectorKey)
if (saved != null) {
- stationState.preInoperativeConnectorStatuses.delete(connectorId)
+ stationState.preInoperativeConnectorStatuses.delete(connectorKey)
return saved
}
return OCPP20ConnectorStatusEnumType.Available
}
const evseStatus = chargingStation.getEvseStatus(evseId)
- if (!evseStatus?.connectors.has(connectorId)) {
+ const connectorStatus = evseStatus?.connectors.get(connectorId)
+ if (connectorStatus == null) {
return {
status: ChangeAvailabilityStatusEnumType.Rejected,
statusInfo: {
}
}
+ if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
+ this.savePreInoperativeStatuses(chargingStation, evseId, connectorId)
+ }
+ connectorStatus.availability = operationalStatus
const resolvedStatus =
operationalStatus === OCPP20OperationalStatusEnumType.Operative
- ? this.getRestoredConnectorStatus(chargingStation, connectorId)
+ ? this.getRestoredConnectorStatus(chargingStation, evseId, connectorId)
: newConnectorStatus
sendAndSetConnectorStatus(chargingStation, {
connectorId,
connectorStatus: resolvedStatus,
+ evseId,
}).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.handleConnectorChangeAvailability: Error sending status notification for connector ${connectorId.toString()}:`,
}
const evseStatus = chargingStation.getEvseStatus(evseId)
+ if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
+ this.savePreInoperativeStatuses(chargingStation, evseId)
+ }
if (
evseStatus != null &&
operationalStatus === OCPP20OperationalStatusEnumType.Inoperative &&
}
}
+ const deliveryCurrent = this.isIncomingRequestDeliveryCurrent(chargingStation)
const effectiveCertificateType =
certificateType ?? CertificateSigningUseEnumType.ChargingStationCertificate
- if (effectiveCertificateType === CertificateSigningUseEnumType.ChargingStationCertificate) {
+ if (
+ deliveryCurrent &&
+ effectiveCertificateType === CertificateSigningUseEnumType.ChargingStationCertificate
+ ) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Triggering websocket reconnect to use new ChargingStationCertificate`
)
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestCertificateSigned: Certificate chain stored successfully`
)
- // A02.FR.20: Cancel retry timer when CertificateSignedRequest is received and accepted.
- // Optional-chain: `getCertSigningRetryManager` returns undefined
- // when the station has been stopped.
- this.getCertSigningRetryManager(chargingStation)?.cancelRetryTimer()
+ // A02.FR.20: Cancel retry timer only while the accepted request still owns
+ // the active lifecycle. A durable store that outlives its lifecycle remains successful.
+ if (deliveryCurrent) {
+ this.getCertSigningRetryManager(chargingStation)?.cancelRetryTimer()
+ }
return {
status: GenericStatus.Accepted,
}
`${chargingStation.logPrefix()} ${moduleName}.handleRequestChangeAvailability: Received ChangeAvailability request with operationalStatus=${operationalStatus}${evseIdLabel}`
)
- if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
- // G03.FR.07: Save current connector statuses before setting Inoperative
- this.savePreInoperativeStatuses(chargingStation, evse?.id)
- }
-
const newConnectorStatus =
operationalStatus === OCPP20OperationalStatusEnumType.Inoperative
? OCPP20ConnectorStatusEnumType.Unavailable
// CS-level change (no evse or evse.id === 0)
if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
+ this.savePreInoperativeStatuses(chargingStation)
const result = this.handleCsLevelInoperative(
chargingStation,
operationalStatus,
)
const isCSCert = isCSCertResult instanceof Promise ? await isCSCertResult : isCSCertResult
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return {
+ status: DeleteCertificateStatusEnumType.Failed,
+ statusInfo: {
+ additionalInfo: 'DeleteCertificate request lifecycle is no longer current',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ }
+ }
if (isCSCert) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Attempted to delete ChargingStationCertificate (M04.FR.06)`
}
const stationState = this.getOrCreateStationState(chargingStation)
- const cached = stationState.reportDataCache.get(commandPayload.requestId)
+ const requestId = commandPayload.requestId
+ const cached = stationState.reportDataCache.get(requestId)
const reportData = cached ?? this.buildReportData(chargingStation, commandPayload.reportBase)
- if (!cached && isNotEmptyArray(reportData)) {
- stationState.reportDataCache.set(commandPayload.requestId, reportData)
- }
if (!isNotEmptyArray(reportData)) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: No data available for reportBase ${commandPayload.reportBase}`
status: GenericDeviceModelStatusEnumType.EmptyResultSet,
}
}
+ const existingReservation = stationState.reportDataReservations.get(requestId)
+ if (cached != null || existingReservation != null) {
+ throw new OCPPError(
+ ErrorType.OCCURRENCE_CONSTRAINT_VIOLATION,
+ `GetBaseReport requestId ${requestId.toString()} is already active`,
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ commandPayload
+ )
+ }
+ const retainedBytes = Buffer.byteLength(JSON.stringify(reportData), 'utf8')
+ const reservationRecord: ReportDataReservation = {
+ release: () => undefined,
+ }
+ const reservation = chargingStation.reserveBufferedMessageCallbacks(retainedBytes, () => {
+ if (stationState.reportDataReservations.get(requestId) !== reservationRecord) return
+ stationState.reportDataReservations.delete(requestId)
+ stationState.reportDataCache.delete(requestId)
+ })
+ if (reservation == null) {
+ throw new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Report callback capacity exceeded for requestId ${requestId.toString()}`,
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ commandPayload
+ )
+ }
+ reservationRecord.release = () => {
+ chargingStation.releaseBufferedMessageCallbacks(reservation)
+ }
+ stationState.reportDataReservations.set(requestId, reservationRecord)
+ stationState.reportDataCache.set(requestId, reportData)
return {
status: GenericDeviceModelStatusEnumType.Accepted,
}
`${chargingStation.logPrefix()} ${moduleName}.handleRequestGetLog: Received GetLog request with requestId ${requestId.toString()} for logType '${logType}'`
)
- const stationState = this.stationsState.get(chargingStation)
+ const stationState = this.getStationState(chargingStation)
if (stationState?.activeLogUploadRequestId != null) {
const previousRequestId = stationState.activeLogUploadRequestId
stationState.activeLogUploadAbortController?.abort()
// E14.FR.06: When transactionId is omitted, ongoingIndicator SHALL NOT be set
if (transactionId == null) {
return {
- messagesInQueue: this.hasQueuedTransactionEvents(chargingStation),
+ messagesInQueue: this.hasPendingTransactionEvents(chargingStation),
}
}
const evseId = chargingStation.getEvseIdByTransactionId(transactionId)
+ const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId)
+ const connectorStatus =
+ connectorId != null ? chargingStation.getConnectorStatus(connectorId, evseId) : undefined
return {
- messagesInQueue: this.hasQueuedTransactionEvents(chargingStation, transactionId),
- ongoingIndicator: evseId != null,
+ messagesInQueue: this.hasPendingTransactionEvents(chargingStation, transactionId),
+ ongoingIndicator: connectorStatus != null && !isTransactionEnding(connectorStatus),
}
}
}
}
- private async handleRequestReset (
+ private handleRequestReset (
chargingStation: ChargingStation,
commandPayload: OCPP20ResetRequest
- ): Promise<OCPP20ResetResponse> {
+ ): OCPP20ResetResponse {
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Reset request received with type ${commandPayload.type}${commandPayload.evseId != null ? ` for EVSE ${commandPayload.evseId.toString()}` : ''}`
)
try {
if (type === ResetEnumType.Immediate) {
if (evseId != null && evseId > 0) {
- if (evseHasActiveTransactions) {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset with active transaction, will terminate transaction and reset EVSE ${evseId.toString()}`
- )
-
- await this.terminateEvseTransactions(
- chargingStation,
- evseId,
- OCPP20ReasonEnumType.ImmediateReset
- )
- this.scheduleEvseReset(chargingStation, evseId, true)
-
- return {
- status: ResetStatusEnumType.Accepted,
- }
- } else {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset without active transactions for EVSE ${evseId.toString()}`
- )
-
- this.scheduleEvseReset(chargingStation, evseId, false)
-
- return {
- status: ResetStatusEnumType.Accepted,
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate EVSE reset ${evseHasActiveTransactions ? 'with active transaction, will terminate transaction and reset' : 'without active transactions for'} EVSE ${evseId.toString()}`
+ )
+ this.pendingResetActions.set(commandPayload, async lifecycleSignal => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
+ const currentEvse = chargingStation.getEvseStatus(evseId)
+ const terminateTransactions =
+ currentEvse != null && this.hasEvseActiveTransactions(currentEvse)
+ if (terminateTransactions) {
+ await this.terminateEvseTransactions(
+ chargingStation,
+ evseId,
+ OCPP20ReasonEnumType.ImmediateReset
+ )
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
}
- }
- } else {
- if (hasActiveTransactions) {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset with active transactions, will terminate transactions and reset`
- )
-
+ this.scheduleEvseReset(chargingStation, evseId, terminateTransactions, lifecycleSignal)
+ })
+ return { status: ResetStatusEnumType.Accepted }
+ }
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset ${hasActiveTransactions ? 'with active transactions, will terminate transactions and reset' : 'without active transactions'}`
+ )
+ this.pendingResetActions.set(commandPayload, async lifecycleSignal => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
+ if (chargingStation.getNumberOfRunningTransactions() > 0) {
await this.terminateAllTransactions(
chargingStation,
OCPP20ReasonEnumType.ImmediateReset
)
- chargingStation.reset(StopTransactionReason.REMOTE).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Error during immediate reset:`,
- error
- )
- })
-
- return {
- status: ResetStatusEnumType.Accepted,
- }
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
} else {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Immediate reset without active transactions`
- )
-
this.sendAllConnectorsStatusNotifications(
chargingStation,
OCPP20ConnectorStatusEnumType.Unavailable
)
- chargingStation.reset(StopTransactionReason.REMOTE).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Error during immediate reset:`,
- error
- )
- })
-
- return {
- status: ResetStatusEnumType.Accepted,
- }
}
- }
- } else {
- if (evseId != null && evseId > 0) {
- const evse = chargingStation.getEvseStatus(evseId)
- if (evse != null && !this.isEvseIdle(chargingStation, evse)) {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset scheduled for EVSE ${evseId.toString()}, waiting for idle state`
- )
-
- this.scheduleEvseResetOnIdle(chargingStation, evseId)
+ await chargingStation.reset(StopTransactionReason.REMOTE)
+ })
+ return { status: ResetStatusEnumType.Accepted }
+ }
- return {
- status: ResetStatusEnumType.Scheduled,
- }
+ if (evseId != null && evseId > 0) {
+ const evse = chargingStation.getEvseStatus(evseId)
+ const resetScheduled = evse != null && !this.isEvseIdle(chargingStation, evse)
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset ${resetScheduled ? `scheduled for EVSE ${evseId.toString()}, waiting for idle state` : `- EVSE ${evseId.toString()} is idle, resetting immediately`}`
+ )
+ this.pendingResetActions.set(commandPayload, lifecycleSignal => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
+ const currentEvse = chargingStation.getEvseStatus(evseId)
+ if (currentEvse != null && !this.isEvseIdle(chargingStation, currentEvse)) {
+ this.scheduleEvseResetOnIdle(chargingStation, evseId, lifecycleSignal)
} else {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle EVSE reset - EVSE ${evseId.toString()} is idle, resetting immediately`
- )
-
- this.scheduleEvseReset(chargingStation, evseId, false)
-
- return {
- status: ResetStatusEnumType.Accepted,
- }
+ this.scheduleEvseReset(chargingStation, evseId, false, lifecycleSignal)
}
- } else {
- if (!this.isChargingStationIdle(chargingStation)) {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset scheduled, waiting for idle state`
- )
-
- this.scheduleResetOnIdle(chargingStation)
-
- return {
- status: ResetStatusEnumType.Scheduled,
- }
- } else {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset - charging station is idle, resetting immediately`
- )
-
- chargingStation.reset(StopTransactionReason.REMOTE).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: Error during OnIdle reset:`,
- error
- )
- })
+ })
+ return {
+ status: resetScheduled ? ResetStatusEnumType.Scheduled : ResetStatusEnumType.Accepted,
+ }
+ }
- return {
- status: ResetStatusEnumType.Accepted,
- }
- }
+ const resetScheduled = !this.isChargingStationIdle(chargingStation)
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestReset: OnIdle reset ${resetScheduled ? 'scheduled, waiting for idle state' : '- charging station is idle, resetting immediately'}`
+ )
+ this.pendingResetActions.set(commandPayload, async lifecycleSignal => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
+ if (!this.isChargingStationIdle(chargingStation)) {
+ this.scheduleResetOnIdle(chargingStation, lifecycleSignal)
+ } else {
+ await chargingStation.reset(StopTransactionReason.REMOTE)
}
+ })
+ return {
+ status: resetScheduled ? ResetStatusEnumType.Scheduled : ResetStatusEnumType.Accepted,
}
} catch (error) {
const errorResponse: OCPP20ResetResponse = {
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction request received on EVSE ${evseId?.toString() ?? 'undefined'} with idToken '${truncateId(idToken.idToken)}' and remoteStartId ${remoteStartId.toString()}`
)
- let resolvedEvseId = evseId
- if (resolvedEvseId == null) {
- resolvedEvseId = this.selectAvailableEvse(chargingStation)
- if (resolvedEvseId == null) {
+ let requestedEvseStatus: EvseStatus | undefined
+ if (evseId != null) {
+ requestedEvseStatus = chargingStation.getEvseStatus(evseId)
+ if (requestedEvseStatus == null) {
+ const errorMsg = `EVSE ${evseId.toString()} does not exist on charging station`
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: No available EVSE for remote start`
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: ${errorMsg}`
)
- return buildRejectedResponse(
- ReasonCodeEnumType.NotFound,
- 'No available EVSE found for remote start'
+ throw new OCPPError(
+ ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+ errorMsg,
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ commandPayload
)
}
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Auto-selected EVSE ${resolvedEvseId.toString()}`
- )
- }
-
- const evse = chargingStation.getEvseStatus(resolvedEvseId)
- if (evse == null) {
- const errorMsg = `EVSE ${resolvedEvseId.toString()} does not exist on charging station`
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: ${errorMsg}`
- )
- throw new OCPPError(
- ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
- errorMsg,
- OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
- commandPayload
- )
- }
- const connectorId = chargingStation.getConnectorIdByEvseId(resolvedEvseId)
- const connectorStatus =
- connectorId != null ? chargingStation.getConnectorStatus(connectorId) : null
-
- if (connectorStatus == null || connectorId == null) {
- const errorMsg = `Connector ${connectorId?.toString() ?? 'undefined'} status is undefined`
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: ${errorMsg}`
- )
- throw new OCPPError(
- ErrorType.INTERNAL_ERROR,
- errorMsg,
- OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
- commandPayload
+ const pendingConnector = [...requestedEvseStatus.connectors.entries()].find(
+ ([, connectorStatus]) =>
+ connectorStatus.transactionPending === true &&
+ typeof connectorStatus.transactionId === 'string'
)
+ if (pendingConnector != null) {
+ const [connectorId, connectorStatus] = pendingConnector
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} has a pending transaction not yet authorized`
+ )
+ return buildRejectedResponse(
+ ReasonCodeEnumType.TxStarted,
+ `Connector ${connectorId.toString()} has a pending transaction not yet authorized`,
+ connectorStatus.transactionId as UUIDv4
+ )
+ }
}
- // OCPP 2.0.1 part 2 §F01.FR.13: when a transaction was created on the station but not yet
- // authorized (transactionPending), the existing transactionId SHALL be echoed back. The
- // appendix distinguishes TxStarted (cable-plugin-first, not yet authorized) from TxInProgress
- // (authorized and running) — use TxStarted here to match the precondition.
- if (
- connectorStatus.transactionPending === true &&
- typeof connectorStatus.transactionId === 'string'
- ) {
+ const target = this.selectRemoteStartConnector(chargingStation, evseId)
+ if (target == null) {
+ const hasAvailableConnector =
+ requestedEvseStatus != null &&
+ [...requestedEvseStatus.connectors.values()].some(connectorStatus =>
+ this.isRemoteStartConnectorAvailable(requestedEvseStatus, connectorStatus)
+ )
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} has a pending transaction not yet authorized`
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: No eligible connector for remote start${evseId != null ? ` on EVSE ${evseId.toString()}` : ''}`
)
return buildRejectedResponse(
- ReasonCodeEnumType.TxStarted,
- `Connector ${connectorId.toString()} has a pending transaction not yet authorized`,
- // safe: OCPP 2.0.1 paths always store generateUUID() output here (see line ~2740)
- connectorStatus.transactionId as UUIDv4
+ hasAvailableConnector ? ReasonCodeEnumType.TxInProgress : ReasonCodeEnumType.NotFound,
+ `No eligible connector found for remote start${evseId != null ? ` on EVSE ${evseId.toString()}` : ''}`
)
}
-
- if (
- connectorStatus.transactionStarted === true ||
- connectorStatus.transactionPending === true ||
- connectorStatus.locked === true
- ) {
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Connector ${connectorId.toString()} already has an active or pending transaction`
- )
- return buildRejectedResponse(
- ReasonCodeEnumType.TxInProgress,
- `Connector ${connectorId.toString()} already has an active or pending transaction`
+ const {
+ connectorId,
+ connectorStatus,
+ evseId: resolvedEvseId,
+ evseStatus: selectedEvseStatus,
+ } = target
+ if (evseId == null) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Auto-selected EVSE ${resolvedEvseId.toString()} connector ${connectorId.toString()}`
)
}
}
}
+ if (!this.isIncomingRequestAdmissionCurrent(chargingStation)) {
+ return buildRejectedResponse(
+ ReasonCodeEnumType.InternalError,
+ 'RequestStartTransaction admission lifecycle is no longer current'
+ )
+ }
+ const currentEvseStatus = chargingStation.getEvseStatus(resolvedEvseId)
+ const currentConnectorStatus = chargingStation.getConnectorStatus(connectorId, resolvedEvseId)
+ if (currentEvseStatus !== selectedEvseStatus || currentConnectorStatus !== connectorStatus) {
+ return buildRejectedResponse(
+ ReasonCodeEnumType.TxInProgress,
+ `Connector ${connectorId.toString()} ownership changed while authorizing remote start`
+ )
+ }
+ if (!this.isRemoteStartConnectorAvailable(currentEvseStatus, currentConnectorStatus)) {
+ return buildRejectedResponse(
+ ReasonCodeEnumType.NotFound,
+ `Connector ${connectorId.toString()} became unavailable while authorizing remote start`
+ )
+ }
+ const pendingConnector =
+ evseId != null
+ ? [...currentEvseStatus.connectors.entries()].find(
+ ([, candidate]) =>
+ candidate.transactionPending === true && typeof candidate.transactionId === 'string'
+ )
+ : currentConnectorStatus.transactionPending === true &&
+ typeof currentConnectorStatus.transactionId === 'string'
+ ? ([connectorId, currentConnectorStatus] as const)
+ : undefined
+ if (pendingConnector != null) {
+ const [pendingConnectorId, pendingConnectorStatus] = pendingConnector
+ return buildRejectedResponse(
+ ReasonCodeEnumType.TxStarted,
+ `Connector ${pendingConnectorId.toString()} has a pending transaction not yet authorized`,
+ pendingConnectorStatus.transactionId as UUIDv4
+ )
+ }
+ if (this.hasEvseTransactionOwnership(currentEvseStatus)) {
+ return buildRejectedResponse(
+ ReasonCodeEnumType.TxInProgress,
+ `EVSE ${resolvedEvseId.toString()} gained transaction ownership while authorizing remote start`
+ )
+ }
+ if (!this.isRemoteStartConnectorEligible(currentEvseStatus, currentConnectorStatus)) {
+ return buildRejectedResponse(
+ ReasonCodeEnumType.TxInProgress,
+ `Connector ${connectorId.toString()} already has an active, pending, starting, ending, or locked transaction`
+ )
+ }
+
const transactionId = generateUUID()
try {
// E01.FR.07 + E01.FR.16 + E03.FR.01: ensure clean transaction state for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(
+ chargingStation,
+ connectorId,
+ resolvedEvseId
+ )
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Setting transaction state for connector ${connectorId.toString()}, transaction ID: ${transactionId}`
)
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Remote start transaction ACCEPTED on #${connectorId.toString()} for idToken '${truncateId(idToken.idToken)}'`
)
+ this.pendingRemoteStartResponses.set(commandPayload, {
+ connectorId,
+ evseId: resolvedEvseId,
+ transactionId,
+ })
return {
status: RequestStartStopStatusEnumType.Accepted,
transactionId,
}
} catch (error) {
- await this.resetConnectorOnStartTransactionError(chargingStation, connectorId, resolvedEvseId)
+ await this.resetConnectorOnStartTransactionError(
+ chargingStation,
+ connectorId,
+ resolvedEvseId,
+ transactionId
+ )
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStartTransaction: Error starting transaction:`,
ensureError(error)
}
}
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (connectorStatus == null || isTransactionEnding(connectorStatus)) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.handleRequestStopTransaction: Transaction ID ${transactionId as string} is not active`
+ )
+ return {
+ status: RequestStartStopStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `Transaction ID ${transactionId as string} is not active`,
+ reasonCode: ReasonCodeEnumType.TxNotFound,
+ },
+ }
+ }
+
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleRequestStopTransaction: Remote stop transaction ACCEPTED for transactionId '${transactionId as string}'`
)
case MessageTriggerEnumType.LogStatusNotification:
return { status: TriggerMessageStatusEnumType.Accepted }
- case MessageTriggerEnumType.MeterValues:
- case MessageTriggerEnumType.StatusNotification: {
+ case MessageTriggerEnumType.MeterValues: {
const evseValidation = this.validateTriggerMessageEvse(chargingStation, evse)
- if (evseValidation != null) {
- return evseValidation
+ if (evseValidation != null) return evseValidation
+ const targetEvseIds = this.resolveMeterValuesTriggerEvseIds(chargingStation, evse)
+ if (
+ !OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(chargingStation, targetEvseIds)
+ ) {
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'A MeterValues trigger is already pending for a target EVSE',
+ reasonCode: ReasonCodeEnumType.OutOfMemory,
+ },
+ }
+ }
+ const alignedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ const timestamp = new Date()
+ const targets: TriggeredMeterValuesTarget[] = []
+ for (const targetEvseId of targetEvseIds) {
+ const evseStatus = chargingStation.getEvseStatus(targetEvseId)
+ const target =
+ evseStatus == null
+ ? undefined
+ : this.buildTriggeredMeterValuesTarget(
+ chargingStation,
+ targetEvseId,
+ evseStatus,
+ alignedMeasurandsKey,
+ timestamp,
+ evse?.id != null && evse.id > 0 ? evse.connectorId : undefined
+ )
+ if (target == null) {
+ OCPP20ServiceUtils.releaseTriggeredMeterValuesRequests(chargingStation, targetEvseIds)
+ for (const reservedTarget of targets) {
+ for (const sample of reservedTarget.samples) sample.delivery?.settle(true)
+ }
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `EVSE ${targetEvseId.toString()} has no configured MeterValues sample`,
+ reasonCode: ReasonCodeEnumType.NotEnabled,
+ },
+ }
+ }
+ targets.push(target)
}
+ const deliveryTargets: TriggeredMeterValuesDeliveryTarget[] = targets.flatMap(target =>
+ target.samples.flatMap(sample =>
+ sample.delivery == null ? [] : [{ delivery: sample.delivery, evseId: target.evseId }]
+ )
+ )
+ this.triggerMeterValuesReservations.set(commandPayload, { deliveryTargets, targets })
+ return { status: TriggerMessageStatusEnumType.Accepted }
+ }
+
+ case MessageTriggerEnumType.StatusNotification: {
+ const evseValidation = this.validateTriggerMessageEvse(chargingStation, evse)
+ if (evseValidation != null) return evseValidation
return { status: TriggerMessageStatusEnumType.Accepted }
}
if (evseValidation != null) {
return evseValidation
}
- if (isEmpty(this.resolveActiveTransactionConnectors(chargingStation, evse))) {
+ const activeTargets = this.resolveActiveTransactionConnectors(chargingStation, evse)
+ if (isEmpty(activeTargets)) {
return {
status: TriggerMessageStatusEnumType.Rejected,
statusInfo: {
},
}
}
+ const reservedTargets: TriggeredTransactionEventTarget[] = []
+ for (const target of activeTargets) {
+ const reservation = OCPP20ServiceUtils.reserveTriggeredTransactionEventDelivery(
+ chargingStation,
+ target.connectorId,
+ target.evseId,
+ target.transactionId
+ )
+ if (reservation == null) {
+ for (const reservedTarget of reservedTargets) reservedTarget.reservation.release()
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: 'A TransactionEvent trigger is already pending for a target',
+ reasonCode: ReasonCodeEnumType.OutOfMemory,
+ },
+ }
+ }
+ reservedTargets.push({ ...target, reservation })
+ }
+ this.triggerTransactionEventReservations.set(commandPayload, reservedTargets)
return { status: TriggerMessageStatusEnumType.Accepted }
}
`${chargingStation.logPrefix()} ${moduleName}.handleRequestUnlockConnector: Unlocking connector ${connectorId.toString()} on EVSE ${evseId.toString()}`
)
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- connectorStatus: ConnectorStatusEnum.Available,
- evseId,
- })
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ { connectorId, connectorStatus: ConnectorStatusEnum.Available, evseId },
+ {
+ isOperationCurrent: () => this.isIncomingRequestDeliveryCurrent(chargingStation),
+ send: true,
+ }
+ )
+ if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+ return {
+ status: UnlockStatusEnumType.UnlockFailed,
+ statusInfo: {
+ additionalInfo: 'UnlockConnector request lifecycle is no longer current',
+ reasonCode: ReasonCodeEnumType.InternalError,
+ },
+ }
+ }
chargingStation.unlockConnector(connectorId)
return { status: UnlockStatusEnumType.Unlocked }
} catch (error) {
}
}
- /**
- * Checks if a specific EVSE has any active transactions.
- * @param evse - The EVSE to check
- * @returns `true` if any connector on the EVSE has an active transaction
- */
private hasEvseActiveTransactions (evse: EvseStatus): boolean {
for (const connector of evse.connectors.values()) {
if (connector.transactionId != null) {
return false
}
+ /**
+ * Checks if a specific EVSE has any active transactions.
+ * @param evse - The EVSE to check
+ * @returns `true` if any connector on the EVSE has an active transaction
+ */
+ private hasEvseTransactionOwnership (evse: EvseStatus): boolean {
+ return [...evse.connectors.values()].some(
+ connector =>
+ connector.transactionId != null ||
+ connector.transactionStarted === true ||
+ connector.transactionPending === true ||
+ connector.transactionStarting === true ||
+ isTransactionEnding(connector)
+ )
+ }
+
/**
* Checks if firmware update is in progress per OCPP 2.0.1 Errata idle definition.
* @param chargingStation - The charging station instance
)
}
- private hasQueuedTransactionEvents (
+ private hasPendingTransactionEvents (
chargingStation: ChargingStation,
transactionId?: string
): boolean {
for (const { connectorStatus } of chargingStation.iterateConnectors()) {
- if (this.connectorHasQueuedEvents(connectorStatus, transactionId)) {
+ if (this.connectorHasPendingTransactionEvents(connectorStatus, transactionId)) {
return true
}
}
return false
}
+ private hasTriggeredTransactionState (connectorStatus: ConnectorStatus): boolean {
+ return (
+ connectorStatus.transactionId != null ||
+ connectorStatus.transactionStarted === true ||
+ connectorStatus.transactionStarting === true ||
+ connectorStatus.transactionPending === true ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionRestored === true
+ )
+ }
+
private isAuthorizedToStopTransaction (
chargingStation: ChargingStation,
connectorId: number,
)
}
+ private isRemoteStartConnectorAvailable (
+ evseStatus: EvseStatus,
+ connectorStatus: ConnectorStatus
+ ): boolean {
+ return (
+ evseStatus.availability === OCPP20OperationalStatusEnumType.Operative &&
+ connectorStatus.availability === OCPP20OperationalStatusEnumType.Operative &&
+ connectorStatus.status !== ConnectorStatusEnum.Unavailable &&
+ connectorStatus.status !== ConnectorStatusEnum.Faulted
+ )
+ }
+
+ private isRemoteStartConnectorEligible (
+ evseStatus: EvseStatus,
+ connectorStatus: ConnectorStatus
+ ): boolean {
+ return (
+ this.isRemoteStartConnectorAvailable(evseStatus, connectorStatus) &&
+ connectorStatus.transactionStarted !== true &&
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionStarting !== true &&
+ !isTransactionEnding(connectorStatus) &&
+ connectorStatus.locked !== true
+ )
+ }
+
+ private isReservedTriggeredTransactionCurrent (
+ connectorStatus: ConnectorStatus,
+ transactionId: string
+ ): boolean {
+ return (
+ connectorStatus.transactionStarted === true &&
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionRestored !== true &&
+ connectorStatus.transactionId?.toString() === transactionId &&
+ !hasQueuedEndedTransactionEvent(connectorStatus, transactionId)
+ )
+ }
+
+ private isTriggeredTransactionActive (
+ connectorStatus: ConnectorStatus,
+ transactionId?: string
+ ): boolean {
+ return (
+ connectorStatus.transactionStarted === true &&
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionRestored !== true &&
+ !isTransactionEnding(connectorStatus) &&
+ connectorStatus.transactionId != null &&
+ (transactionId == null || connectorStatus.transactionId.toString() === transactionId)
+ )
+ }
+
private isValidFirmwareLocation (location: string): boolean {
try {
const url = new URL(location)
}
}
+ // eslint-disable-next-line perfectionist/sort-classes
+ private isResetLifecycleCurrent (
+ chargingStation: ChargingStation,
+ lifecycleSignal: AbortSignal
+ ): boolean {
+ return chargingStation.lifecycleAbortSignal === lifecycleSignal && !lifecycleSignal.aborted
+ }
+
+ private releaseReportDataReservation (
+ chargingStation: ChargingStation,
+ stationState: OCPP20StationState,
+ requestId: number
+ ): void {
+ const reservation = stationState.reportDataReservations.get(requestId)
+ if (reservation == null) {
+ stationState.reportDataCache.delete(requestId)
+ return
+ }
+ stationState.reportDataReservations.delete(requestId)
+ stationState.reportDataCache.delete(requestId)
+ reservation.release()
+ }
+
private resetActiveFirmwareUpdateState (stationState: OCPP20StationState): void {
stationState.activeFirmwareUpdateAbortController = undefined
stationState.activeFirmwareUpdateRequestId = undefined
* @param chargingStation - The charging station instance
* @param connectorId - The connector ID that needs rollback
* @param evseId - The EVSE ID
+ * @param expectedTransactionId - Transaction identity that still owns the provisional state
*/
private async resetConnectorOnStartTransactionError (
chargingStation: ChargingStation,
connectorId: number,
- evseId?: number
+ evseId?: number,
+ expectedTransactionId?: string
): Promise<void> {
- OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ expectedTransactionId != null &&
+ connectorStatus?.transactionId?.toString() !== expectedTransactionId
+ ) {
+ return
+ }
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
// Snapshot transactionId BEFORE resetConnectorStatus deletes it.
const txId = connectorStatus?.transactionId
resetConnectorStatus(connectorStatus)
chargingStation.destroyCoherentSession(txId)
- await restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
+ await restoreConnectorStatus(chargingStation, connectorId, connectorStatus, evseId)
}
/**
* (F06.FR.11 — "for all allowed evse values").
*
* The predicate requires `transactionStarted === true` and deliberately
- * excludes `transactionPending` connectors (unlike the looser
- * {@link OCPP20ServiceUtils.resolveActiveTransaction}): only a started
- * transaction yields `chargingState = Charging`, which F06.FR.07 and the
- * OCPP 2.0.1 conformance test cases TC_F_13/TC_F_14 mandate for the triggered
+ * excludes pending or ending transactions, including restored state with a
+ * matching queued Ended event. Only a started, non-ending transaction yields
+ * `chargingState = Charging`, which F06.FR.07 and the OCPP 2.0.1
+ * conformance test cases TC_F_13/TC_F_14 mandate for the triggered
* TransactionEventRequest.
* @param chargingStation - Target charging station.
* @param evse - Optional EVSE scope from the TriggerMessageRequest.
- * @returns The `{ connectorId, transactionId }` pair of each active transaction within the trigger scope.
+ * @returns The connector, EVSE, and transaction identity of each active transaction within the trigger scope.
* @see {@link hasEvseActiveTransactions} for the looser presence-only predicate used elsewhere.
*/
private resolveActiveTransactionConnectors (
chargingStation: ChargingStation,
evse: OCPP20TriggerMessageRequest['evse']
- ): { connectorId: number; transactionId: string }[] {
- const targetEvseId = evse?.id != null && evse.id > 0 ? evse.id : undefined
- const activeConnectors: { connectorId: number; transactionId: string }[] = []
- for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors(
- true
+ ): { connectorId: number; evseId: number; transactionId: string }[] {
+ const activeConnectors: { connectorId: number; evseId: number; transactionId: string }[] = []
+ for (const { connectorId, connectorStatus, evseId } of this.resolveTriggerMessageConnectors(
+ chargingStation,
+ evse
)) {
if (
- (targetEvseId == null || evseId === targetEvseId) &&
connectorStatus.transactionStarted === true &&
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionRestored !== true &&
+ !isTransactionEnding(connectorStatus) &&
connectorStatus.transactionId != null
) {
activeConnectors.push({
connectorId,
+ evseId,
transactionId: connectorStatus.transactionId.toString(),
})
}
return activeConnectors
}
+ private resolveMeterValuesTriggerEvseIds (
+ chargingStation: ChargingStation,
+ evse: OCPP20TriggerMessageRequest['evse']
+ ): number[] {
+ if (evse?.id != null && evse.id > 0) return [evse.id]
+ return [...chargingStation.iterateEvses(true)].map(({ evseId }) => evseId)
+ }
+
+ /**
+ * Resolves physical connectors within the requested TriggerMessage scope.
+ * @param chargingStation - Target charging station.
+ * @param evse - Optional EVSE and connector scope.
+ * @returns Every connector in the requested scope.
+ */
+ private resolveTriggerMessageConnectors (
+ chargingStation: ChargingStation,
+ evse: OCPP20TriggerMessageRequest['evse']
+ ): TriggerMessageConnectorTarget[] {
+ if (evse?.id != null && evse.id > 0) {
+ const evseStatus = chargingStation.getEvseStatus(evse.id)
+ if (evseStatus == null) return []
+ if (evse.connectorId != null) {
+ const connectorStatus = evseStatus.connectors.get(evse.connectorId)
+ return connectorStatus == null
+ ? []
+ : [{ connectorId: evse.connectorId, connectorStatus, evseId: evse.id }]
+ }
+ return [...evseStatus.connectors.entries()]
+ .filter(([connectorId]) => connectorId > 0)
+ .map(([connectorId, connectorStatus]) => ({
+ connectorId,
+ connectorStatus,
+ evseId: evse.id,
+ }))
+ }
+ return [...chargingStation.iterateConnectors(true)].flatMap(
+ ({ connectorId, connectorStatus, evseId }) =>
+ evseId == null ? [] : [{ connectorId, connectorStatus, evseId }]
+ )
+ }
+
/**
* Saves current connector statuses before Inoperative is applied, for later restoration.
* @param chargingStation - The charging station instance
* @param evseId - Optional EVSE ID to scope the save; if omitted, saves all EVSEs
+ * @param connectorId - Optional connector ID to scope the save within one EVSE
*/
- private savePreInoperativeStatuses (chargingStation: ChargingStation, evseId?: number): void {
+ private savePreInoperativeStatuses (
+ chargingStation: ChargingStation,
+ evseId?: number,
+ connectorId?: number
+ ): void {
const stationState = this.getOrCreateStationState(chargingStation)
const evseIds =
evseId != null && evseId > 0
for (const id of evseIds) {
const evseStatus = chargingStation.getEvseStatus(id)
if (evseStatus != null) {
- for (const [connectorId, connector] of evseStatus.connectors) {
+ for (const [currentConnectorId, connector] of evseStatus.connectors) {
+ if (connectorId != null && currentConnectorId !== connectorId) continue
+ const connectorKey = `${id.toString()}:${currentConnectorId.toString()}`
if (
connector.status != null &&
isOCPP20ConnectorStatus(connector.status) &&
- !stationState.preInoperativeConnectorStatuses.has(connectorId)
+ !stationState.preInoperativeConnectorStatuses.has(connectorKey)
) {
- stationState.preInoperativeConnectorStatuses.set(connectorId, connector.status)
+ stationState.preInoperativeConnectorStatuses.set(connectorKey, connector.status)
}
}
}
* @param chargingStation - The charging station instance
* @param evseId - The EVSE identifier to reset
* @param hasActiveTransactions - Whether there are active transactions to handle
+ * @param lifecycleSignal - Lifecycle that owns the delivered Reset response
*/
private scheduleEvseReset (
chargingStation: ChargingStation,
evseId: number,
- hasActiveTransactions: boolean
+ hasActiveTransactions: boolean,
+ lifecycleSignal: AbortSignal
): void {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
this.sendEvseStatusNotifications(
chargingStation,
evseId,
OCPP20ConnectorStatusEnumType.Unavailable
)
-
- setImmediate(() => {
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Executing EVSE ${evseId.toString()} reset${hasActiveTransactions ? ' after transaction termination' : ''}`
- )
- // Unref: fire-and-forget pending EVSE reset must not block
- // node.js exit.
- setTimeout(() => {
- const evse = chargingStation.getEvseStatus(evseId)
- if (evse) {
- for (const [connectorId] of evse.connectors) {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- restoreConnectorStatus(chargingStation, connectorId, connectorStatus).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Error restoring connector ${connectorId.toString()} status:`,
- error
- )
- }
- )
- }
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: EVSE ${evseId.toString()} reset completed`
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Executing EVSE ${evseId.toString()} reset${hasActiveTransactions ? ' after transaction termination' : ''}`
+ )
+ // Unref: fire-and-forget pending EVSE reset must not block node.js exit.
+ setTimeout(() => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) return
+ const evse = chargingStation.getEvseStatus(evseId)
+ if (evse != null) {
+ for (const [connectorId] of evse.connectors) {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ restoreConnectorStatus(chargingStation, connectorId, connectorStatus, evseId).catch(
+ (error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: Error restoring connector ${connectorId.toString()} status:`,
+ error
+ )
+ }
)
}
- }, OCPP20Constants.RESET_DELAY_MS).unref()
- })
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.scheduleEvseReset: EVSE ${evseId.toString()} reset completed`
+ )
+ }
+ }, OCPP20Constants.RESET_DELAY_MS).unref()
}
/**
* Schedules EVSE reset on idle (when no active transactions)
* @param chargingStation - The charging station instance
* @param evseId - The EVSE identifier to reset
+ * @param lifecycleSignal - Lifecycle that owns the delivered Reset response
*/
- private scheduleEvseResetOnIdle (chargingStation: ChargingStation, evseId: number): void {
+ private scheduleEvseResetOnIdle (
+ chargingStation: ChargingStation,
+ evseId: number,
+ lifecycleSignal: AbortSignal
+ ): void {
// Unref: idle-monitor poll must not block node.js exit; the interval
- // self-clears once the EVSE is idle or its state is gone.
+ // self-clears once the EVSE is idle, its state is gone, or its lifecycle is superseded.
const monitorInterval = setInterval(() => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) {
+ clearInterval(monitorInterval)
+ return
+ }
const evse = chargingStation.getEvseStatus(evseId)
if (evse != null) {
if (this.isEvseIdle(chargingStation, evse)) {
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.scheduleEvseResetOnIdle: EVSE ${evseId.toString()} is now idle, executing reset`
)
- this.scheduleEvseReset(chargingStation, evseId, false)
+ this.scheduleEvseReset(chargingStation, evseId, false, lifecycleSignal)
}
} else {
clearInterval(monitorInterval)
/**
* Schedules charging station reset on idle (when no active transactions)
* @param chargingStation - The charging station instance
+ * @param lifecycleSignal - Lifecycle that owns the delivered Reset response
*/
- private scheduleResetOnIdle (chargingStation: ChargingStation): void {
+ private scheduleResetOnIdle (
+ chargingStation: ChargingStation,
+ lifecycleSignal: AbortSignal
+ ): void {
// Unref: idle-monitor poll must not block node.js exit; the interval
- // self-clears once the station reports idle.
+ // self-clears once the station reports idle or its lifecycle is superseded.
const monitorInterval = setInterval(() => {
+ if (!this.isResetLifecycleCurrent(chargingStation, lifecycleSignal)) {
+ clearInterval(monitorInterval)
+ return
+ }
if (this.isChargingStationIdle(chargingStation)) {
clearInterval(monitorInterval)
logger.info(
}, OCPP20Constants.RESET_IDLE_MONITOR_INTERVAL_MS).unref()
}
- private selectAvailableEvse (chargingStation: ChargingStation): number | undefined {
+ private selectRemoteStartConnector (
+ chargingStation: ChargingStation,
+ requestedEvseId?: number
+ ): RemoteStartConnectorTarget | undefined {
for (const { evseId, evseStatus } of chargingStation.iterateEvses(true)) {
- if (
- evseStatus.availability !== OCPP20OperationalStatusEnumType.Inoperative &&
- !this.hasEvseActiveTransactions(evseStatus)
- ) {
- return evseId
+ if (requestedEvseId != null && evseId !== requestedEvseId) continue
+ if (this.hasEvseTransactionOwnership(evseStatus)) continue
+ for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+ if (connectorId > 0 && this.isRemoteStartConnectorEligible(evseStatus, connectorStatus)) {
+ return { connectorId, connectorStatus, evseId, evseStatus }
+ }
}
}
return undefined
chargingStation: ChargingStation,
status: OCPP20ConnectorStatusEnumType
): void {
- for (const { connectorId } of chargingStation.iterateConnectors()) {
+ for (const { connectorId, evseId } of chargingStation.iterateConnectors(true)) {
sendAndSetConnectorStatus(chargingStation, {
connectorId,
connectorStatus: status,
+ ...(evseId != null && { evseId }),
}).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.sendAllConnectorsStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`,
sendAndSetConnectorStatus(chargingStation, {
connectorId,
connectorStatus: status,
+ evseId,
}).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.sendEvseStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`,
request: OCPP20GetBaseReportRequest,
response: OCPP20GetBaseReportResponse
): Promise<void> {
- // Silent-drop late `.on(GET_BASE_REPORT).catch(...)` dispatch when
- // no state entry exists or the entry is sealed stopped.
- const stationState = this.stationsState.get(chargingStation)
- if (stationState == null || stationState.stopped === true) {
- return
- }
+ const stationState = this.getStationState(chargingStation)
+ if (stationState == null) return
+ const lifecycleSignal = chargingStation.lifecycleAbortSignal
+ const reportActionIsCurrent = (): boolean =>
+ stationState.stopped !== true &&
+ this.stationsState.get(chargingStation) === stationState &&
+ chargingStation.lifecycleAbortSignal === lifecycleSignal &&
+ !lifecycleSignal.aborted &&
+ this.isIncomingRequestDeliveryCurrent(chargingStation)
const { reportBase, requestId } = request
const cached = stationState.reportDataCache.get(requestId)
const reportData = cached ?? this.buildReportData(chargingStation, reportBase)
- const chunks = []
- for (let i = 0; i < reportData.length; i += MAX_ITEMS_PER_REPORT_MESSAGE) {
- chunks.push(reportData.slice(i, i + MAX_ITEMS_PER_REPORT_MESSAGE))
- }
+ try {
+ if (!reportActionIsCurrent()) return
+ const chunks = []
+ for (let i = 0; i < reportData.length; i += MAX_ITEMS_PER_REPORT_MESSAGE) {
+ chunks.push(reportData.slice(i, i + MAX_ITEMS_PER_REPORT_MESSAGE))
+ }
- if (!isNotEmptyArray(chunks)) {
- chunks.push(undefined) // undefined means reportData will be omitted from the request
- }
+ if (!isNotEmptyArray(chunks)) {
+ chunks.push(undefined)
+ }
- for (let seqNo = 0; seqNo < chunks.length; seqNo++) {
- const isLastChunk = seqNo === chunks.length - 1
- const chunk = chunks[seqNo]
+ for (let seqNo = 0; seqNo < chunks.length; seqNo++) {
+ if (!reportActionIsCurrent()) return
+ const isLastChunk = seqNo === chunks.length - 1
+ const chunk = chunks[seqNo]
+
+ const notifyReportRequest: OCPP20NotifyReportRequest = {
+ generatedAt: new Date(),
+ requestId,
+ seqNo,
+ tbc: !isLastChunk,
+ ...(chunk !== undefined && isNotEmptyArray(chunk) && { reportData: chunk }),
+ }
- const notifyReportRequest: OCPP20NotifyReportRequest = {
- generatedAt: new Date(),
- requestId,
- seqNo,
- tbc: !isLastChunk,
- ...(chunk !== undefined && isNotEmptyArray(chunk) && { reportData: chunk }),
- }
+ if (!reportActionIsCurrent()) return
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP20NotifyReportRequest,
+ OCPP20NotifyReportResponse
+ >(chargingStation, OCPP20RequestCommand.NOTIFY_REPORT, notifyReportRequest)
+ if (!reportActionIsCurrent()) return
- await chargingStation.ocppRequestService.requestHandler<
- OCPP20NotifyReportRequest,
- OCPP20NotifyReportResponse
- >(chargingStation, OCPP20RequestCommand.NOTIFY_REPORT, notifyReportRequest)
+ logger.debug(
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: NotifyReport sent seqNo=${seqNo} for requestId ${requestId} with ${chunk?.length ?? 0} report items (tbc=${!isLastChunk})`
+ )
+ }
logger.debug(
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: NotifyReport sent seqNo=${seqNo} for requestId ${requestId} with ${chunk?.length ?? 0} report items (tbc=${!isLastChunk})`
+ `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)`
)
+ } finally {
+ this.releaseReportDataReservation(chargingStation, stationState, requestId)
}
-
- logger.debug(
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)`
- )
- stationState.reportDataCache.delete(requestId)
}
private sendQueuedSecurityEvents (chargingStation: ChargingStation): void {
// Silent-drop when no state entry exists or the entry is sealed
// stopped: covers a fired-before-cancel race on the unref'd retry
// setTimeout scheduled below.
- const stationState = this.stationsState.get(chargingStation)
+ const stationState = this.getStationState(chargingStation)
if (stationState == null || stationState.stopped === true) {
return
}
}
private sendRestoredAllConnectorsStatusNotifications (chargingStation: ChargingStation): void {
- for (const { connectorId } of chargingStation.iterateConnectors(true)) {
- const restoredStatus = this.getRestoredConnectorStatus(chargingStation, connectorId)
+ for (const { connectorId, evseId } of chargingStation.iterateConnectors(true)) {
+ if (evseId == null) continue
+ const restoredStatus = this.getRestoredConnectorStatus(chargingStation, evseId, connectorId)
sendAndSetConnectorStatus(chargingStation, {
connectorId,
connectorStatus: restoredStatus,
+ evseId,
}).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.sendRestoredAllConnectorsStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`,
const evse = chargingStation.getEvseStatus(evseId)
if (evse) {
for (const [connectorId] of evse.connectors) {
- const restoredStatus = this.getRestoredConnectorStatus(chargingStation, connectorId)
+ const restoredStatus = this.getRestoredConnectorStatus(chargingStation, evseId, connectorId)
sendAndSetConnectorStatus(chargingStation, {
connectorId,
connectorStatus: restoredStatus,
+ evseId,
}).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.sendRestoredEvseStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`,
// Guards (`successorClaimed`, `isExplicitTerminal`) mirror
// `clearActiveFirmwareUpdate`'s requestId-supersession pattern and
// preserve any explicit terminal already emitted from inside try.
- const activeRequestId = this.stationsState.get(chargingStation)?.activeFirmwareUpdateRequestId
+ const activeRequestId = this.getStationState(chargingStation)?.activeFirmwareUpdateRequestId
const successorClaimed = activeRequestId != null && activeRequestId !== requestId
const currentStatus = chargingStation.stationInfo?.firmwareStatus
const isExplicitTerminal =
)
}
- private triggerAllEvseStatusNotifications (
- chargingStation: ChargingStation,
- errorHandler: (error: unknown) => void
- ): void {
- for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors(
- true
- )) {
- const resolvedStatus = connectorStatus.status ?? ConnectorStatusEnum.Available
- chargingStation.ocppRequestService
- .requestHandler<StatusNotificationOptions, OCPP20StatusNotificationResponse>(
- chargingStation,
- OCPP20RequestCommand.STATUS_NOTIFICATION,
- {
- connectorId,
- connectorStatus: resolvedStatus,
- evseId,
- },
- { skipBufferingOnError: true, triggerMessage: true }
- )
- .catch(errorHandler)
- }
- }
-
/**
* OCPP 2.0.1 F06.FR.06: TriggerMessage with `MessageTrigger.MeterValues`
* SHALL respond with `MeterValuesRequest` (not `TransactionEventRequest`),
* using the `AlignedDataCtrlr.Measurands` allow-list and `TRIGGER`
- * ReadingContext. One `MeterValuesRequest` per target EVSE aggregating
- * MeterValues from every connector with an active transaction; when an
- * EVSE has no active transactions, still emit one request with a
- * schema-conforming placeholder so the CSMS observes the response
- * (F06.FR.10). F06.FR.11 broadcast semantics apply when `evse` is absent.
+ * ReadingContext. One `MeterValuesRequest` is emitted for every EVSE slot
+ * reserved before the TriggerMessage response was accepted.
* @param chargingStation - Target charging station.
- * @param evse - Optional target EVSE from the TriggerMessage payload.
+ * @param reservation - Exact EVSE batch and transaction deliveries reserved at admission.
* @param errorHandler - Handler for downstream request-emission errors.
*/
private triggerMeterValues (
chargingStation: ChargingStation,
- evse: OCPP20TriggerMessageRequest['evse'],
+ reservation: TriggeredMeterValuesReservation,
errorHandler: (error: unknown) => void
): void {
- const alignedInterval = OCPP20ServiceUtils.getAlignedDataInterval(chargingStation)
- const alignedMeasurandsKey = buildConfigKey(
- OCPP20ComponentName.AlignedDataCtrlr,
- OCPP20RequiredVariableName.Measurands
- )
- if (evse?.id != null && evse.id > 0) {
- const evseStatus = chargingStation.getEvseStatus(evse.id)
- if (evseStatus != null) {
- this.emitEvseMeterValues(
- chargingStation,
- evse.id,
- evseStatus,
- alignedInterval,
- alignedMeasurandsKey,
- errorHandler
- )
- }
- } else {
- for (const { evseId, evseStatus } of chargingStation.iterateEvses(true)) {
- this.emitEvseMeterValues(
- chargingStation,
- evseId,
- evseStatus,
- alignedInterval,
- alignedMeasurandsKey,
- errorHandler
- )
+ for (const target of reservation.targets) {
+ const deliveryTargets = reservation.deliveryTargets.filter(
+ deliveryTarget => deliveryTarget.evseId === target.evseId
+ )
+ const settleDeliveries = (definitivelyRejected: boolean): void => {
+ for (const { delivery } of deliveryTargets) delivery.settle(definitivelyRejected)
}
+ ;(async () => {
+ const deliveryTurns = deliveryTargets.flatMap(({ delivery }) => {
+ const deliveryTurn = delivery.waitForTurn()
+ return deliveryTurn == null ? [] : [deliveryTurn]
+ })
+ if (isNotEmptyArray(deliveryTurns)) await Promise.all(deliveryTurns)
+ this.emitEvseMeterValues(chargingStation, target, errorHandler, settleDeliveries)
+ })().catch((error: unknown) => {
+ OCPP20ServiceUtils.releaseTriggeredMeterValuesRequests(chargingStation, [target.evseId])
+ settleDeliveries(true)
+ errorHandler(error)
+ })
}
}
evse: OCPP20TriggerMessageRequest['evse'],
errorHandler: (error: unknown) => void
): void {
- if (evse?.id != null && evse.id > 0 && evse.connectorId != null) {
- const evseStatus = chargingStation.getEvseStatus(evse.id)
- const connectorStatus = evseStatus?.connectors.get(evse.connectorId)
- const resolvedStatus = connectorStatus?.status ?? ConnectorStatusEnum.Available
+ for (const { connectorId, connectorStatus, evseId } of this.resolveTriggerMessageConnectors(
+ chargingStation,
+ evse
+ )) {
chargingStation.ocppRequestService
.requestHandler<StatusNotificationOptions, OCPP20StatusNotificationResponse>(
chargingStation,
OCPP20RequestCommand.STATUS_NOTIFICATION,
{
- connectorId: evse.connectorId,
- connectorStatus: resolvedStatus,
- evseId: evse.id,
+ connectorId,
+ connectorStatus: connectorStatus.status ?? ConnectorStatusEnum.Available,
+ evseId,
},
{ skipBufferingOnError: true, triggerMessage: true }
)
.catch(errorHandler)
- } else if (chargingStation.hasEvses) {
- this.triggerAllEvseStatusNotifications(chargingStation, errorHandler)
}
}
/**
- * Sends a triggered TransactionEventRequest for each in-scope transaction
- * (F06.FR.07). Each event carries `eventType = Updated`, `triggerReason =
- * Trigger`, the transaction's `chargingState` (derived by
- * `buildTransactionEvent`), and a `meterValue` built from the
- * SampledDataCtrlr.TxUpdatedMeasurands allow-list. When `evse` is absent the
- * send is broadcast to every EVSE with an active transaction (F06.FR.11).
+ * Sends the TransactionEvent reserved when TriggerMessage was accepted.
+ * The reservation revalidates the exact active transaction before invoking this method.
* @param chargingStation - Target charging station.
- * @param evse - Optional target EVSE from the TriggerMessage payload.
- * @param errorHandler - Handler for downstream request-emission errors.
+ * @param target - Exact connector and transaction reserved at admission.
*/
- private triggerTransactionEvent (
+ private async triggerTransactionEvent (
chargingStation: ChargingStation,
- evse: OCPP20TriggerMessageRequest['evse'],
- errorHandler: (error: unknown) => void
- ): void {
+ target: Omit<TriggeredTransactionEventTarget, 'reservation'>
+ ): Promise<void> {
+ const { connectorId, evseId, transactionId } = target
const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
const txUpdatedMeasurandsKey = buildConfigKey(
OCPP20ComponentName.SampledDataCtrlr,
OCPP20RequiredVariableName.TxUpdatedMeasurands
)
- for (const { connectorId, transactionId } of this.resolveActiveTransactionConnectors(
- chargingStation,
- evse
- )) {
- // F06.FR.10: a message marked Accepted SHALL be sent. A meterValue build
- // failure must not suppress the event: `meterValue` is `0..*` while the
- // `chargingState` (populated by buildTransactionEvent for Updated events)
- // is the F06.FR.07-mandatory core, so omit the meterValue field on failure.
- let eventPayload: { meterValue?: [OCPP20MeterValue] } = {}
- try {
- const meterValue = buildMeterValue(
- chargingStation,
- transactionId,
- txUpdatedInterval,
- txUpdatedMeasurandsKey,
- OCPP20ReadingContextEnumType.TRIGGER
- ) as OCPP20MeterValue
- // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while
- // `TransactionEventRequest.meterValue` is `0..*`: when TxUpdatedMeasurands
- // yields no sampled values, omit the `meterValue` field entirely rather
- // than send an empty-wrapper schema violation.
- if (isNotEmptyArray(meterValue.sampledValue)) {
- eventPayload = { meterValue: [meterValue] }
- }
- } catch (error) {
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.triggerTransactionEvent: ${getErrorMessage(error)}`
- )
- }
- OCPP20ServiceUtils.sendTransactionEvent(
+ const timestamp = new Date()
+ // F06.FR.10: while the reserved transaction remains active, a message marked
+ // Accepted SHALL be sent. A meterValue build failure must not suppress the
+ // event: `meterValue` is `0..*` while the
+ // `chargingState` (populated by buildTransactionEvent for Updated events)
+ // is the F06.FR.07-mandatory core, so omit the meterValue field on failure.
+ let eventPayload: { meterValue?: [OCPP20MeterValue] } = {}
+ try {
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
chargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.Trigger,
connectorId,
+ evseId,
transactionId,
- eventPayload
- ).catch(errorHandler)
+ txUpdatedInterval,
+ txUpdatedMeasurandsKey,
+ OCPP20ReadingContextEnumType.TRIGGER,
+ timestamp
+ )
+ // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while
+ // `TransactionEventRequest.meterValue` is `0..*`: when TxUpdatedMeasurands
+ // yields no sampled values, omit the `meterValue` field entirely rather
+ // than send an empty-wrapper schema violation.
+ if (isNotEmptyArray(meterValue.sampledValue)) {
+ eventPayload = { meterValue: [meterValue] }
+ }
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.triggerTransactionEvent: ${getErrorMessage(error)}`
+ )
}
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId,
+ { ...eventPayload, evseId, timestamp }
+ )
}
private validateChargingProfile (
},
}
}
+ if (
+ evse.connectorId != null &&
+ !chargingStation.getEvseStatus(evse.id)?.connectors.has(evse.connectorId)
+ ) {
+ return {
+ status: TriggerMessageStatusEnumType.Rejected,
+ statusInfo: {
+ additionalInfo: `Connector ${evse.connectorId.toString()} on EVSE ${evse.id.toString()} does not exist`,
+ reasonCode: ReasonCodeEnumType.UnknownConnectorId,
+ },
+ }
+ }
return undefined
}
}
): SignedSampledValueResult<OCPP20SampledValue> {
const fields = resolveSampledValueFields(sampledValueTemplate, value, context, phase)
const sampledValue: OCPP20SampledValue = {
+ ...(sampledValueTemplate.customData != null && {
+ customData: sampledValueTemplate.customData,
+ }),
context: fields.context,
location: fields.location,
measurand: fields.measurand,
}
}
}
+
+ protected getDefaultResponseTimeoutMs (chargingStation: ChargingStation): number {
+ return OCPP20ServiceUtils.getMessageTimeout(chargingStation)
+ }
}
addConfigurationKey,
buildConfigKey,
type ChargingStation,
+ hasQueuedEndedTransactionEvent,
+ shiftBoundedTransactionEvent,
} from '../../../charging-station/index.js'
import {
ChargingStationEvents,
import { OCPPResponseService } from '../OCPPResponseService.js'
import { createPayloadValidatorMap, isRequestCommandSupported } from '../OCPPServiceUtils.js'
import { OCPP20IncomingRequestService } from './OCPP20IncomingRequestService.js'
-import { OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
+import { isTransactionEnding, OCPP20ServiceUtils } from './OCPP20ServiceUtils.js'
const moduleName = 'OCPP20ResponseService'
/**
logger.debug(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: TransactionEvent(${requestPayload.eventType}) response received`
)
+ const replayOwner = OCPP20ServiceUtils.getReplayedTransactionEventOwner(requestPayload)
+ const evseId =
+ replayOwner?.evseId ??
+ requestPayload.evse?.id ??
+ chargingStation.getEvseIdByTransactionId(requestPayload.transactionInfo.transactionId)
const connectorId =
+ replayOwner?.connectorId ??
requestPayload.evse?.connectorId ??
- requestPayload.evse?.id ??
chargingStation.getConnectorIdByTransactionId(requestPayload.transactionInfo.transactionId)
const connectorStatus =
- connectorId != null ? chargingStation.getConnectorStatus(connectorId) : undefined
+ connectorId != null ? chargingStation.getConnectorStatus(connectorId, evseId) : undefined
+ const endedTransactionQueued =
+ connectorStatus != null &&
+ hasQueuedEndedTransactionEvent(connectorStatus, requestPayload.transactionInfo.transactionId)
+ const transactionEnding =
+ connectorStatus != null && (isTransactionEnding(connectorStatus) || endedTransactionQueued)
+ const replayedTransactionEvent =
+ OCPP20ServiceUtils.isReplayedTransactionEventRequest(requestPayload)
switch (requestPayload.eventType) {
- case OCPP20TransactionEventEnumType.Ended:
+ case OCPP20TransactionEventEnumType.Ended: {
+ let replayedHeadDequeued = false
+ if (replayedTransactionEvent) {
+ if (connectorStatus?.transactionEventQueue?.[0]?.request !== requestPayload) break
+ shiftBoundedTransactionEvent(connectorStatus)
+ replayedHeadDequeued = true
+ }
if (connectorId != null && connectorStatus != null) {
- await OCPP20ServiceUtils.cleanupEndedTransaction(
+ const transactionFinalized = await OCPP20ServiceUtils.cleanupEndedTransaction(
chargingStation,
connectorId,
- connectorStatus
+ connectorStatus,
+ evseId,
+ requestPayload.transactionInfo.transactionId
)
+ if (replayedHeadDequeued && !transactionFinalized) {
+ chargingStation.saveTransactionEventQueues()
+ }
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} ENDED on connector ${connectorId.toString()}`
)
chargingStation.destroyCoherentSession(requestPayload.transactionInfo.transactionId)
}
break
- case OCPP20TransactionEventEnumType.Started:
- if (connectorStatus != null) {
+ }
+ case OCPP20TransactionEventEnumType.Started: {
+ const ownsRestoredQueuedStart =
+ connectorStatus?.transactionRestored === true &&
+ connectorStatus.transactionStarted !== true &&
+ connectorStatus.transactionEventQueue?.[0]?.request === requestPayload
+ const ownsActiveStart =
+ connectorStatus?.transactionId?.toString() ===
+ requestPayload.transactionInfo.transactionId &&
+ (connectorStatus.transactionStarting === true ||
+ connectorStatus.transactionPending === true ||
+ ownsRestoredQueuedStart)
+ if (ownsActiveStart && !transactionEnding) {
connectorStatus.transactionStarted = true
connectorStatus.transactionPending = false
- connectorStatus.transactionId ??= requestPayload.transactionInfo.transactionId
+ connectorStatus.transactionStarting = false
connectorStatus.transactionIdTag ??= requestPayload.idToken?.idToken
connectorStatus.transactionStart ??= new Date()
connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0
connectorStatus.locked = true
}
if (connectorId != null && isIdTokenAccepted) {
- sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- connectorStatus: ConnectorStatusEnum.Occupied,
- }).catch((error: unknown) => {
+ sendAndSetConnectorStatus(
+ chargingStation,
+ {
+ connectorId,
+ connectorStatus: ConnectorStatusEnum.Occupied,
+ ...(evseId != null && { evseId }),
+ },
+ {
+ expectedTransactionId: requestPayload.transactionInfo.transactionId,
+ send: true,
+ }
+ ).catch((error: unknown) => {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Error sending StatusNotification(Occupied):`,
error
)
})
- const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
- OCPP20ServiceUtils.startUpdatedMeterValues(
- chargingStation,
- connectorId,
- txUpdatedInterval
- )
- const txEndedInterval = OCPP20ServiceUtils.getTxEndedInterval(chargingStation)
- OCPP20ServiceUtils.startEndedMeterValues(chargingStation, connectorId, txEndedInterval)
- // Create coherent MeterValues session after transactionId is known.
- // No-op when the feature flag or the EV profile file is not
- // configured (see ChargingStation.createCoherentSession).
- chargingStation.createCoherentSession(
- requestPayload.transactionInfo.transactionId,
- connectorId
- )
+ if (connectorStatus.transactionRestored !== true) {
+ const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
+ OCPP20ServiceUtils.startUpdatedMeterValues(
+ chargingStation,
+ connectorId,
+ txUpdatedInterval,
+ evseId
+ )
+ const txEndedInterval = OCPP20ServiceUtils.getTxEndedInterval(chargingStation)
+ OCPP20ServiceUtils.startEndedMeterValues(
+ chargingStation,
+ connectorId,
+ txEndedInterval,
+ evseId
+ )
+ // Create coherent MeterValues session after transactionId is known.
+ // Restored transactions are reconciled once after replay finishes.
+ chargingStation.createCoherentSession(
+ requestPayload.transactionInfo.transactionId,
+ connectorId,
+ evseId
+ )
+ }
}
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} STARTED on connector ${String(connectorId)}`
)
}
break
+ }
}
if (payload.totalCost != null) {
logger.info(
requestPayload.eventType === OCPP20TransactionEventEnumType.Started
if (
payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted &&
- !overrideRejection
+ !overrideRejection &&
+ !transactionEnding
) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: IdToken authorization rejected with status '${payload.idTokenInfo.status}', de-authorizing transaction per E05.FR.09/E05.FR.10/E06.FR.04`
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Could not find connector for transaction ${requestPayload.transactionInfo.transactionId}, cannot de-authorize`
)
}
+ } else if (
+ payload.idTokenInfo.status !== OCPP20AuthorizationStatusEnumType.Accepted &&
+ transactionEnding
+ ) {
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} is already ending; skipping redundant de-authorization events`
+ )
} else if (overrideRejection) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Forcing transaction ${requestPayload.transactionInfo.transactionId} on eventType=Started despite idTokenInfo status '${payload.idTokenInfo.status}' per forceTransactionOnInvalidIdToken=true`
import { secondsToMilliseconds } from 'date-fns'
-import { type ChargingStation, resetConnectorStatus } from '../../../charging-station/index.js'
-import { OCPPError } from '../../../exception/index.js'
import {
+ buildConfigKey,
+ type ChargingStation,
+ clearTransactionEventQueueInFlight,
+ enqueueBoundedTransactionEvent,
+ getConfigurationKey,
+ getMutableSignedMeterValue,
+ getRawSignedMeterValuePublicKey,
+ getTransactionEventQueueBytes,
+ hasQueuedEndedTransactionEvent,
+ invalidateTransactionEventQueueAccounting,
+ isTransactionEventQueueBlocked,
+ isTransactionEventQueueStaged,
+ markTransactionEventQueueDeliveryAttempted,
+ queuedTransactionEventHasPublicKey,
+ removeBoundedTransactionEvent,
+ resetConnectorStatus,
+ restoreRejectedTransactionEventIntervalCarry,
+ setTransactionEventQueueBlocked,
+ setTransactionEventQueueInFlight,
+ setTransactionEventQueueStaged,
+ shiftBoundedTransactionEvent,
+ transferDiscardedTransactionEventIntervalEnergy,
+} from '../../../charging-station/index.js'
+import {
+ advanceConnectorEnergyRegister,
+ advanceStationEnergyRegister,
+ advanceTransactionEnergyRegister,
+ canonicalizeCustomData,
+ computeCoherentSampleAtTime,
+ consumePendingSharedEnergy,
+ getMeterValueUnitFamily,
+ getRepresentedTransactionIntervalEnergyWh,
+ getTransactionIntervalConsumptions,
+ type MeterValueUnitFamily,
+ recordPendingSharedEnergy,
+ resolveInletToOutputEfficiency,
+ resolveLinePhaseIndex,
+ resolveRootSeed,
+ type TransactionMeterValueDelivery,
+ TransactionMeterValueDeliveryBarrier,
+ type TransactionMeterValueDependency,
+ truncateTransactionIntervalValue,
+} from '../../../charging-station/meter-values/index.js'
+import { BaseError, OCPPError } from '../../../exception/index.js'
+import {
+ AvailabilityType,
+ type ConfigurationKeyType,
type ConnectorStatus,
type ConnectorStatusEnum,
+ CurrentType,
ErrorType,
type MeterValue,
+ MeterValueLocation,
OCPP20AuthorizationStatusEnumType,
OCPP20ChargingStateEnumType,
OCPP20ComponentName,
type OCPP20IdTokenInfoType,
type OCPP20IdTokenType,
OCPP20IncomingRequestCommand,
+ OCPP20LocationEnumType,
+ OCPP20MeasurandEnumType,
type OCPP20MeterValue,
+ type OCPP20MeterValuesRequest,
+ type OCPP20MeterValuesResponse,
OCPP20OptionalVariableName,
OCPP20ReadingContextEnumType,
OCPP20ReasonEnumType,
OCPP20RequestCommand,
OCPP20RequiredVariableName,
+ type OCPP20SampledValue,
type OCPP20StatusNotificationRequest,
OCPP20TransactionEventEnumType,
type OCPP20TransactionEventOptions,
type OCPP20TransactionEventResponse,
type OCPP20TransactionType,
OCPP20TriggerReasonEnumType,
+ OCPP20UnitEnumType,
OCPPVersion,
+ type QueuedTransactionEvent,
ReasonCodeEnumType,
RequestCommand,
+ type RequestParams,
+ type SampledValueTemplate,
type StartTransactionResult,
type StatusNotificationOptions,
type StopTransactionReason,
type UUIDv4,
} from '../../../types/index.js'
import {
+ buildPersistentTransactionEnergyIntervalState,
clampToSafeTimerValue,
+ clone,
computeExponentialBackOffDelay,
Constants,
convertToBoolean,
convertToInt,
convertToIntOrNaN,
+ ensureError,
formatDurationMilliSeconds,
generateUUID,
getErrorMessage,
+ interruptibleSleep,
+ isEmpty,
isNotEmptyArray,
+ isNotEmptyString,
logger,
+ roundTo,
sleep,
validateIdentifierString,
} from '../../../utils/index.js'
-import { buildConfigKey, getConfigurationKey } from '../../index.js'
import {
mapOCPP20AuthorizationStatus,
mapOCPP20TokenType,
} from '../auth/index.js'
import { sendPostTransactionStatus } from '../OCPPConnectorStatusOperations.js'
import {
+ buildClockAlignedConnectorMeterValue,
buildMeterValue,
createPayloadConfigs,
+ DC_STATION_AGGREGATION_DIRECTION,
PayloadValidatorOptions,
} from '../OCPPServiceUtils.js'
+import {
+ claimPublicKeyDelivery,
+ type PublicKeyDeliveryToken,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
+ transferPublicKeyDelivery,
+} from '../OCPPSignedMeterValueUtils.js'
import { OCPP20Constants } from './OCPP20Constants.js'
import { mapStopReasonToOCPP20 } from './OCPP20RequestBuilders.js'
import { OCPP20VariableManager } from './OCPP20VariableManager.js'
+import { getVariableMetadata } from './OCPP20VariableRegistry.js'
const moduleName = 'OCPP20ServiceUtils'
+// Measurands that are not applicable to the station main power meter (evseId 0).
+// J01.FR.14 note: "evseId = 0 (grid meter) will not have a Current.Offered or
+// SoC measurand." Current.Offered/Power.Offered are "strictly speaking no
+// measured values" (part2 note 1), so summing them into the grid aggregate is
+// meaningless; State of Charge is battery-scoped.
+const GRID_METER_INAPPLICABLE_MEASURANDS: Partial<Record<OCPP20MeasurandEnumType, true>> = {
+ [OCPP20MeasurandEnumType.CURRENT_OFFERED]: true,
+ [OCPP20MeasurandEnumType.POWER_OFFERED]: true,
+ [OCPP20MeasurandEnumType.STATE_OF_CHARGE]: true,
+}
+
+interface StopTransactionOperation {
+ readonly cancel: (error: OCPPError) => boolean
+ readonly cancellationController: AbortController
+ durableEndedEvent?: QueuedTransactionEvent
+ readonly promise: Promise<OCPP20TransactionEventResponse>
+ restorePreDurableEndedQueue?: () => boolean
+ restoreRetrySensitiveState?: () => void
+ readonly transactionId: string
+}
+
+interface TransactionEventQueueSnapshot {
+ readonly publicKeySentInTransaction?: boolean
+ readonly queue?: QueuedTransactionEvent[]
+ readonly queuedEvents: readonly {
+ readonly queuedEvent: QueuedTransactionEvent
+ readonly value: QueuedTransactionEvent
+ }[]
+ readonly transactionEnergyActiveImportIntervalCarry?: Record<string, number>
+}
+
+const captureTransactionEventQueueSnapshot = (
+ connectorStatus: ConnectorStatus
+): TransactionEventQueueSnapshot => {
+ const queue = connectorStatus.transactionEventQueue
+ return {
+ publicKeySentInTransaction: connectorStatus.publicKeySentInTransaction,
+ queue,
+ queuedEvents: (queue ?? []).map(queuedEvent => ({
+ queuedEvent,
+ value: clone(queuedEvent),
+ })),
+ transactionEnergyActiveImportIntervalCarry:
+ connectorStatus.transactionEnergyActiveImportIntervalCarry == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalCarry },
+ }
+}
+
+const restoreTransactionEventQueueSnapshot = (
+ connectorStatus: ConnectorStatus,
+ snapshot: TransactionEventQueueSnapshot
+): void => {
+ for (const { queuedEvent, value } of snapshot.queuedEvents) {
+ for (const key of Object.keys(queuedEvent)) Reflect.deleteProperty(queuedEvent, key)
+ Object.assign(queuedEvent, value)
+ }
+ if (snapshot.queue == null) {
+ delete connectorStatus.transactionEventQueue
+ } else {
+ snapshot.queue.splice(
+ 0,
+ snapshot.queue.length,
+ ...snapshot.queuedEvents.map(({ queuedEvent }) => queuedEvent)
+ )
+ connectorStatus.transactionEventQueue = snapshot.queue
+ }
+ if (snapshot.publicKeySentInTransaction == null) {
+ delete connectorStatus.publicKeySentInTransaction
+ } else {
+ connectorStatus.publicKeySentInTransaction = snapshot.publicKeySentInTransaction
+ }
+ if (snapshot.transactionEnergyActiveImportIntervalCarry == null) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ ...snapshot.transactionEnergyActiveImportIntervalCarry,
+ }
+ }
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+}
+
+type TransactionEventDeliveryOutcome =
+ | 'aborted'
+ | 'exhausted'
+ | 'offline'
+ | 'ownership-lost'
+ | 'pre-send-failed'
+ | 'response-handling-failed'
+ | 'write-ahead-failed'
+
+class TransactionEventDeliveryError extends OCPPError {
+ public readonly confirmedRejected: boolean
+ public readonly definitelyUnsent: boolean
+ public readonly outcome: TransactionEventDeliveryOutcome
+
+ constructor (
+ outcome: TransactionEventDeliveryOutcome,
+ cause: unknown,
+ confirmedRejected = false,
+ definitelyUnsent = false
+ ) {
+ const error =
+ cause instanceof OCPPError
+ ? cause
+ : new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ getErrorMessage(cause),
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ super(error.code, error.message, error.command, error.details)
+ this.confirmedRejected = confirmedRejected
+ this.definitelyUnsent = definitelyUnsent
+ this.outcome = outcome
+ }
+}
+
+class TransactionEventQueueCapacityError extends OCPPError {
+ constructor () {
+ super(
+ ErrorType.INTERNAL_ERROR,
+ 'TransactionEvent queue capacity exhausted by protected entries',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ }
+}
+
+class TransactionEventSignedEvidenceError extends OCPPError {
+ constructor () {
+ super(
+ ErrorType.INTERNAL_ERROR,
+ 'Signed terminal energy evidence is unavailable',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ }
+}
+
+const isAbortSignalAborted = (signal?: AbortSignal): boolean => signal?.aborted === true
+
export const isOCPP20ConnectorStatus = (
status: ConnectorStatusEnum
): status is OCPP20ConnectorStatusEnumType =>
reasonCode: ReasonCodeEnumType
}
+const hasQueuedEndedEvent = (connectorStatus: ConnectorStatus): boolean =>
+ connectorStatus.transactionId != null &&
+ hasQueuedEndedTransactionEvent(connectorStatus, connectorStatus.transactionId.toString())
+
+export const isTransactionEnding = (connectorStatus: ConnectorStatus): boolean =>
+ connectorStatus.transactionEnding === true || hasQueuedEndedEvent(connectorStatus)
+
+const hasOngoingTransaction = (connectorStatus: ConnectorStatus): boolean =>
+ !isTransactionEnding(connectorStatus) &&
+ (connectorStatus.transactionStarting === true ||
+ (connectorStatus.transactionStarted === true && connectorStatus.transactionId != null))
+
+const getTransactionObservationInterval = (
+ connectorStatus: ConnectorStatus,
+ timestamp: Date,
+ fallbackInterval: number
+): number => {
+ const previousUpdate =
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt ??
+ connectorStatus.transactionStart
+ return previousUpdate != null
+ ? Math.max(0, timestamp.getTime() - previousUpdate.getTime())
+ : fallbackInterval
+}
+
+interface EndedIntervalEnergyCoverage {
+ readonly coveredDebtIntervals: readonly EnergyInterval[]
+ readonly legacyCoveredEnergyWh: number
+ readonly recoveryEnergyWh: number
+}
+
+interface EnergyInterval {
+ end: number
+ start: number
+}
+
+const getEnergyIntervalLength = ({ end, start }: EnergyInterval): number => Math.max(0, end - start)
+
+const mergeEnergyIntervals = (intervals: readonly EnergyInterval[]): EnergyInterval[] => {
+ const sortedIntervals = intervals
+ .filter(interval => interval.end > interval.start)
+ .map(interval => ({ ...interval }))
+ .sort((left, right) => left.start - right.start || left.end - right.end)
+ const mergedIntervals: EnergyInterval[] = []
+ for (const interval of sortedIntervals) {
+ const previousInterval = mergedIntervals.at(-1)
+ if (previousInterval == null || interval.start > previousInterval.end) {
+ mergedIntervals.push(interval)
+ } else {
+ previousInterval.end = Math.max(previousInterval.end, interval.end)
+ }
+ }
+ return mergedIntervals
+}
+
+const subtractEnergyInterval = (
+ intervals: readonly EnergyInterval[],
+ coveredInterval: EnergyInterval | undefined
+): EnergyInterval[] => {
+ if (coveredInterval == null) return intervals.map(interval => ({ ...interval }))
+ const remainingIntervals: EnergyInterval[] = []
+ for (const interval of intervals) {
+ if (coveredInterval.end <= interval.start || coveredInterval.start >= interval.end) {
+ remainingIntervals.push({ ...interval })
+ continue
+ }
+ if (coveredInterval.start > interval.start) {
+ remainingIntervals.push({
+ end: Math.min(interval.end, coveredInterval.start),
+ start: interval.start,
+ })
+ }
+ if (coveredInterval.end < interval.end) {
+ remainingIntervals.push({
+ end: interval.end,
+ start: Math.max(interval.start, coveredInterval.end),
+ })
+ }
+ }
+ return remainingIntervals
+}
+
+const takeEnergyIntervals = (
+ intervals: readonly EnergyInterval[],
+ requestedEnergyWh: number
+): EnergyInterval[] => {
+ const selectedIntervals: EnergyInterval[] = []
+ let remainingEnergyWh = requestedEnergyWh
+ for (const interval of intervals) {
+ if (remainingEnergyWh <= 0) break
+ const intervalLength = getEnergyIntervalLength(interval)
+ const selectedLength = Math.min(intervalLength, remainingEnergyWh)
+ if (selectedLength > 0) {
+ selectedIntervals.push({ end: interval.start + selectedLength, start: interval.start })
+ remainingEnergyWh -= selectedLength
+ }
+ }
+ return selectedIntervals
+}
+
+const getEnergyIntervalOverlap = (left: EnergyInterval, right: EnergyInterval): number =>
+ Math.max(0, Math.min(left.end, right.end) - Math.max(left.start, right.start))
+
+const buildEndedIntervalEnergyCoverage = (
+ connectorStatus: ConnectorStatus,
+ representedIntervals: readonly EnergyInterval[],
+ unmappedRepresentedEnergyWh: number,
+ previousCoverage?: EndedIntervalEnergyCoverage
+): EndedIntervalEnergyCoverage => {
+ const carry = connectorStatus.transactionEnergyActiveImportIntervalCarry ?? {}
+ const baselines = connectorStatus.transactionEnergyActiveImportIntervalBaselines ?? {}
+ const debtIntervals: EnergyInterval[] = []
+ let maximumLegacyCarryWh = 0
+ for (const [key, carryWh] of Object.entries(carry)) {
+ if (!Number.isFinite(carryWh) || carryWh <= 0) continue
+ const baselineWh = baselines[key]
+ if (typeof baselineWh === 'number' && Number.isFinite(baselineWh)) {
+ debtIntervals.push({ end: baselineWh, start: baselineWh - carryWh })
+ } else {
+ maximumLegacyCarryWh = Math.max(maximumLegacyCarryWh, carryWh)
+ }
+ }
+ const mergedDebtIntervals = mergeEnergyIntervals(debtIntervals)
+ const mergedRepresentedIntervals = mergeEnergyIntervals([
+ ...(previousCoverage?.coveredDebtIntervals ?? []),
+ ...representedIntervals,
+ ])
+ const uncoveredDebtIntervals = mergedRepresentedIntervals.reduce(
+ (remainingIntervals, representedInterval) =>
+ subtractEnergyInterval(remainingIntervals, representedInterval),
+ mergedDebtIntervals
+ )
+ const uncoveredKnownEnergyWh = uncoveredDebtIntervals.reduce(
+ (total, interval) => total + getEnergyIntervalLength(interval),
+ 0
+ )
+ const positionedRepresentedEnergyWh = mergedRepresentedIntervals.reduce(
+ (total, interval) => total + getEnergyIntervalLength(interval),
+ 0
+ )
+ // Legacy carry without a baseline has no physical position. Treat legacy entries as
+ // overlapping one another and with all represented energy so recovery remains conservative.
+ const representedLegacyEnergyWh = Math.max(
+ previousCoverage?.legacyCoveredEnergyWh ?? 0,
+ positionedRepresentedEnergyWh + unmappedRepresentedEnergyWh
+ )
+ const uncoveredLegacyEnergyWh = Math.max(0, maximumLegacyCarryWh - representedLegacyEnergyWh)
+ const recoveryEnergyWh = truncateTransactionIntervalValue(
+ Math.max(uncoveredKnownEnergyWh, uncoveredLegacyEnergyWh)
+ )
+ const recoveryIntervals = takeEnergyIntervals(uncoveredDebtIntervals, recoveryEnergyWh)
+ return {
+ coveredDebtIntervals: mergeEnergyIntervals([
+ ...mergedRepresentedIntervals,
+ ...recoveryIntervals,
+ ]),
+ legacyCoveredEnergyWh: representedLegacyEnergyWh + recoveryEnergyWh,
+ recoveryEnergyWh,
+ }
+}
+
+const isEndedIntervalRecoverySigningRequired = (chargingStation: ChargingStation): boolean =>
+ [OCPP20ComponentName.SampledDataCtrlr, OCPP20ComponentName.AlignedDataCtrlr].some(component =>
+ convertToBoolean(
+ getConfigurationKey(
+ chargingStation,
+ buildConfigKey(component, OCPP20OptionalVariableName.SignReadings)
+ )?.value
+ )
+ )
+
+const hasSignedTerminalImportRegister = (
+ meterValues: readonly OCPP20MeterValue[] | undefined
+): boolean =>
+ meterValues?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue => {
+ const signedMeterValue = sampledValue.signedMeterValue
+ return (
+ sampledValue.context === OCPP20ReadingContextEnumType.TRANSACTION_END &&
+ (sampledValue.measurand ?? OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER) ===
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER &&
+ typeof sampledValue.value === 'number' &&
+ Number.isFinite(sampledValue.value) &&
+ signedMeterValue != null &&
+ isNotEmptyString(signedMeterValue.encodingMethod) &&
+ typeof signedMeterValue.publicKey === 'string' &&
+ isNotEmptyString(signedMeterValue.signedMeterData) &&
+ typeof signedMeterValue.signingMethod === 'string'
+ )
+ })
+ ) === true
+
+const getSharedEnergyObservationInterval = (
+ connectors: [number, ConnectorStatus][],
+ previousSharedEnergyUpdate: Date | undefined,
+ timestamp: Date,
+ fallbackInterval: number
+): number => {
+ let earliestTransactionUpdate: number | undefined
+ for (const [, connectorStatus] of connectors) {
+ if (!hasOngoingTransaction(connectorStatus)) continue
+ const previousUpdate =
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt ??
+ connectorStatus.transactionStart
+ if (previousUpdate == null) continue
+ earliestTransactionUpdate = Math.min(
+ earliestTransactionUpdate ?? previousUpdate.getTime(),
+ previousUpdate.getTime()
+ )
+ }
+ const previousSharedUpdate = previousSharedEnergyUpdate?.getTime()
+ const observationBaseline =
+ previousSharedUpdate != null && earliestTransactionUpdate != null
+ ? Math.max(previousSharedUpdate, earliestTransactionUpdate)
+ : (previousSharedUpdate ?? earliestTransactionUpdate)
+ return observationBaseline != null
+ ? Math.max(0, timestamp.getTime() - observationBaseline)
+ : fallbackInterval
+}
+
+const prorateSharedObservationEnergy = (
+ energyWh: number,
+ observationInterval: number,
+ connectorStatus: ConnectorStatus,
+ timestamp: Date,
+ fallbackInterval: number
+): number =>
+ observationInterval > 0
+ ? roundTo(
+ energyWh *
+ Math.min(
+ 1,
+ getTransactionObservationInterval(connectorStatus, timestamp, fallbackInterval) /
+ observationInterval
+ ),
+ 2
+ )
+ : 0
+
+const hasConfiguredEnergyMeasurand = (
+ chargingStation: ChargingStation,
+ measurandsKey: ConfigurationKeyType
+): boolean => {
+ const configuredMeasurands = getConfigurationKey(chargingStation, measurandsKey)?.value
+ return (
+ configuredMeasurands == null ||
+ configuredMeasurands.split(',').some(measurand => {
+ const normalizedMeasurand = measurand.trim()
+ return (
+ normalizedMeasurand === (OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER as string) ||
+ normalizedMeasurand === (OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL as string)
+ )
+ })
+ )
+}
+
+const consumeConnectorEnergySinceBaseline = (
+ connectorStatus: ConnectorStatus,
+ baselineKey: string,
+ initialBaseline: number
+): number => {
+ const currentEnergy = connectorStatus.energyActiveImportRegisterValue ?? 0
+ const baseline =
+ connectorStatus.energyActiveImportIntervalBaselines?.[baselineKey] ?? initialBaseline
+ connectorStatus.energyActiveImportIntervalBaselines ??= {}
+ connectorStatus.energyActiveImportIntervalBaselines[baselineKey] = currentEnergy
+ return Math.max(0, currentEnergy - baseline)
+}
+
+const hasConfiguredAlignedEnergyMeasurand = (chargingStation: ChargingStation): boolean =>
+ hasConfiguredEnergyMeasurand(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands)
+ )
+
+const getTransactionEnergyNominalInterval = (
+ chargingStation: ChargingStation,
+ fallbackInterval: number,
+ overrides?: { txEndedInterval?: number; txUpdatedInterval?: number }
+): number | undefined => {
+ const txUpdatedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+ const txUpdatedInterval =
+ overrides?.txUpdatedInterval ?? OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
+ if (
+ txUpdatedInterval > 0 &&
+ hasConfiguredEnergyMeasurand(chargingStation, txUpdatedMeasurandsKey)
+ ) {
+ return txUpdatedInterval
+ }
+ const txEndedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const txEndedInterval =
+ overrides?.txEndedInterval ?? OCPP20ServiceUtils.getTxEndedInterval(chargingStation)
+ if (txEndedInterval > 0 && hasConfiguredEnergyMeasurand(chargingStation, txEndedMeasurandsKey)) {
+ return txEndedInterval
+ }
+ return fallbackInterval > 0 ? fallbackInterval : undefined
+}
+
+const getEnabledAlignedEnergyInterval = (chargingStation: ChargingStation): number | undefined => {
+ if (
+ !hasConfiguredAlignedEnergyMeasurand(chargingStation) ||
+ !OCPP20ServiceUtils.readVariableAsBoolean(
+ chargingStation,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Enabled,
+ false
+ )
+ ) {
+ return undefined
+ }
+ const intervalSeconds = OCPP20ServiceUtils.readAlignedDataIntervalSeconds(chargingStation)
+ return intervalSeconds != null && intervalSeconds > 0
+ ? secondsToMilliseconds(intervalSeconds)
+ : undefined
+}
+
+const canAdvanceAlignedEnergy = (connectorStatus: ConnectorStatus): boolean =>
+ connectorStatus.transactionRestored !== true &&
+ connectorStatus.transactionEnding !== true &&
+ !hasQueuedEndedEvent(connectorStatus) &&
+ connectorStatus.transactionStarted === true &&
+ connectorStatus.transactionId != null
+
+const canContributeToSharedObservation = (
+ connectorStatus: ConnectorStatus,
+ context: OCPP20ReadingContextEnumType | undefined
+): boolean =>
+ canAdvanceAlignedEnergy(connectorStatus) ||
+ (context === OCPP20ReadingContextEnumType.TRANSACTION_END &&
+ hasOngoingTransaction(connectorStatus))
+
+export interface IntervalBaselineRestore {
+ consumed?: number
+ generation?: string
+ key: object
+ restore: (consumed: number) => void
+}
+
+export interface TriggeredTransactionEventDeliveryReservation {
+ readonly release: () => void
+ readonly run: (operation: () => Promise<void>) => Promise<void>
+}
+
+interface ClockAlignedMeterValuesSendState {
+ inFlight?: Promise<void>
+ inFlightRequiresExactDelivery?: boolean
+ pending?: PendingClockAlignedMeterValuesRequest
+ triggerReserved?: boolean
+}
+
+interface EndedTransactionEventPredecessors {
+ readonly dependencies: readonly TransactionMeterValueDependency[]
+ readonly onPersisted?: (queuedEvent: QueuedTransactionEvent) => void
+ readonly onRolledBack?: () => void
+ readonly onStaging?: (restoreQueue: () => boolean) => void
+}
+
+interface PendingClockAlignedMeterValuesRequest {
+ publicKeyDeliveryTokens: PublicKeyDeliveryToken[]
+ request: OCPP20MeterValuesRequest
+ responseTimeoutMs: number
+ restoreIntervalBaselines: Map<object, IntervalBaselineRestore>
+ settleCallbacks: ((definitivelyRejected: boolean) => void)[]
+ settled?: boolean
+ triggerMessage?: boolean
+}
+
+const clockAlignedRequestRequiresExactDelivery = (
+ pending: Pick<
+ PendingClockAlignedMeterValuesRequest,
+ 'publicKeyDeliveryTokens' | 'request' | 'triggerMessage'
+ >
+): boolean =>
+ pending.triggerMessage === true ||
+ isNotEmptyArray(pending.publicKeyDeliveryTokens) ||
+ pending.request.meterValue.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue => sampledValue.signedMeterValue != null)
+ )
+
+const mergeIntervalBaselineRestores = (
+ earlier: ReadonlyMap<object, IntervalBaselineRestore>,
+ later: ReadonlyMap<object, IntervalBaselineRestore>
+): Map<object, IntervalBaselineRestore> => {
+ const merged = new Map(later)
+ for (const [key, restore] of earlier) {
+ const laterRestore = merged.get(key)
+ if (laterRestore == null) {
+ merged.set(key, restore)
+ } else if (laterRestore.generation === restore.generation) {
+ merged.set(key, {
+ ...restore,
+ consumed: (restore.consumed ?? 0) + (laterRestore.consumed ?? 0),
+ })
+ }
+ }
+ return merged
+}
+
+const runIntervalBaselineRestores = (
+ chargingStation: ChargingStation,
+ restores: ReadonlyMap<object, IntervalBaselineRestore>
+): void => {
+ if (isEmpty(restores)) return
+ for (const { consumed = 0, restore } of [...restores.values()].toReversed()) {
+ restore(consumed)
+ }
+ chargingStation.saveTransactionEventQueues()
+}
+
+const settlePendingClockAlignedRequest = (
+ chargingStation: ChargingStation,
+ state: ClockAlignedMeterValuesSendState,
+ pending: PendingClockAlignedMeterValuesRequest,
+ definitivelyRejected: boolean
+): void => {
+ if (pending.settled === true) return
+ pending.settled = true
+ if (state.pending === pending) delete state.pending
+ if (definitivelyRejected) {
+ runIntervalBaselineRestores(chargingStation, pending.restoreIntervalBaselines)
+ for (const token of pending.publicKeyDeliveryTokens) releasePublicKeyDelivery(token)
+ } else {
+ for (const token of pending.publicKeyDeliveryTokens) retainPublicKeyDelivery(token)
+ }
+ for (const settle of pending.settleCallbacks) {
+ try {
+ settle(definitivelyRejected)
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.settlePendingClockAlignedRequest: Error settling an aligned MeterValues owner:`,
+ error
+ )
+ }
+ }
+}
+
+const normalizeClockAlignedAdditiveSample = (
+ sampledValue: OCPP20SampledValue,
+ unitFamily: MeterValueUnitFamily
+): OCPP20SampledValue => {
+ const configuredUnit = sampledValue.unitOfMeasure?.unit
+ const namedUnitMultiplier =
+ configuredUnit === unitFamily.kiloUnit ? Constants.UNIT_DIVIDER_KILO : 1
+ const decimalMultiplier = 10 ** (sampledValue.unitOfMeasure?.multiplier ?? 0)
+ return {
+ ...sampledValue,
+ unitOfMeasure: {
+ ...sampledValue.unitOfMeasure,
+ multiplier: 0,
+ unit: unitFamily.baseUnit,
+ },
+ value: sampledValue.value * namedUnitMultiplier * decimalMultiplier,
+ }
+}
+
+const normalizePhysicalMeterValueForStationAggregation = (
+ meterValue: OCPP20MeterValue,
+ currentType: CurrentType | undefined,
+ conversionEfficiency: number
+): OCPP20MeterValue => {
+ const selectedSamples = new Map<
+ string,
+ { explicitInlet: boolean; sampledValue: OCPP20SampledValue }
+ >()
+ const passthroughSamples: OCPP20SampledValue[] = []
+ for (const sampledValue of meterValue.sampledValue) {
+ const additiveUnitFamily = getMeterValueUnitFamily(
+ sampledValue.measurand,
+ sampledValue.unitOfMeasure?.unit
+ )
+ if (additiveUnitFamily == null) {
+ passthroughSamples.push(sampledValue)
+ continue
+ }
+ const isOutlet = sampledValue.location === OCPP20LocationEnumType.Outlet
+ const dcProjectionDirection =
+ sampledValue.measurand == null
+ ? undefined
+ : DC_STATION_AGGREGATION_DIRECTION.get(sampledValue.measurand)
+ const canPromoteOutletToInlet =
+ isOutlet &&
+ (currentType === CurrentType.DC
+ ? dcProjectionDirection != null
+ : sampledValue.measurand?.startsWith('Power.') === true ||
+ sampledValue.measurand?.startsWith('Energy.') === true)
+ if (isOutlet && !canPromoteOutletToInlet) {
+ passthroughSamples.push(sampledValue)
+ continue
+ }
+ const projectedValue =
+ currentType === CurrentType.DC && canPromoteOutletToInlet
+ ? dcProjectionDirection === 'import'
+ ? sampledValue.value / conversionEfficiency
+ : sampledValue.value * conversionEfficiency
+ : sampledValue.value
+ const normalizedSample = canPromoteOutletToInlet
+ ? {
+ ...sampledValue,
+ location: OCPP20LocationEnumType.Inlet,
+ value: projectedValue,
+ }
+ : sampledValue
+ const unitNormalizedSample = normalizeClockAlignedAdditiveSample(
+ normalizedSample,
+ additiveUnitFamily
+ )
+ const key = JSON.stringify([
+ unitNormalizedSample.measurand,
+ unitNormalizedSample.context,
+ unitNormalizedSample.location,
+ unitNormalizedSample.phase,
+ canonicalizeCustomData(unitNormalizedSample.customData),
+ ])
+ const explicitInlet = sampledValue.location === OCPP20LocationEnumType.Inlet
+ const existing = selectedSamples.get(key)
+ if (existing == null || (!existing.explicitInlet && explicitInlet)) {
+ selectedSamples.set(key, { explicitInlet, sampledValue: normalizedSample })
+ }
+ }
+ return {
+ ...meterValue,
+ sampledValue: [
+ ...passthroughSamples,
+ ...[...selectedSamples.values()].map(({ sampledValue }) => sampledValue),
+ ],
+ }
+}
+
+// An EVSE template describes one physical observation even when its additive
+// samples are copied into multiple connector-scoped TransactionEvent payloads.
+const filterDuplicateSharedEvseSamples = (
+ meterValue: OCPP20MeterValue,
+ seenSamples: Set<string>,
+ registersOnly = false
+): OCPP20MeterValue => ({
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.filter(sampledValue => {
+ if (registersOnly && sampledValue.measurand?.endsWith('.Register') !== true) return true
+ const unitFamily = getMeterValueUnitFamily(
+ sampledValue.measurand,
+ sampledValue.unitOfMeasure?.unit
+ )
+ if (unitFamily == null) return true
+ const normalizedSample = normalizeClockAlignedAdditiveSample(sampledValue, unitFamily)
+ const identity = JSON.stringify([
+ normalizedSample.measurand,
+ normalizedSample.context,
+ normalizedSample.location,
+ normalizedSample.phase,
+ canonicalizeCustomData(normalizedSample.customData),
+ normalizedSample.unitOfMeasure?.unit,
+ normalizedSample.unitOfMeasure?.multiplier,
+ ])
+ if (seenSamples.has(identity)) return false
+ seenSamples.add(identity)
+ return true
+ }),
+})
+
+const isClockAlignedIntervalSample = (sampledValue: OCPP20SampledValue): boolean =>
+ sampledValue.measurand?.startsWith('Energy.') === true &&
+ sampledValue.measurand.endsWith('.Interval')
+
+const clockAlignedSampleIdentity = (sampledValue: OCPP20SampledValue): string =>
+ JSON.stringify([
+ sampledValue.measurand,
+ sampledValue.context,
+ sampledValue.location,
+ sampledValue.phase,
+ canonicalizeCustomData(sampledValue.customData),
+ sampledValue.unitOfMeasure?.unit,
+ sampledValue.unitOfMeasure?.multiplier,
+ ])
+
+const coalesceClockAlignedMeterValuesRequests = (
+ previous: OCPP20MeterValuesRequest,
+ next: OCPP20MeterValuesRequest
+): OCPP20MeterValuesRequest => {
+ const targetMeterValue = next.meterValue.at(-1)
+ if (targetMeterValue == null) return next
+ const sampledValue = targetMeterValue.sampledValue.map(sample => ({ ...sample }))
+ const intervalSamplesByIdentity = new Map<string, OCPP20SampledValue>()
+ for (const sample of sampledValue) {
+ if (!isClockAlignedIntervalSample(sample) || sample.signedMeterValue != null) continue
+ intervalSamplesByIdentity.set(clockAlignedSampleIdentity(sample), sample)
+ }
+ const preservedSignedMeterValues: OCPP20MeterValue[] = []
+ for (const meterValue of previous.meterValue) {
+ const signedSamples: OCPP20SampledValue[] = []
+ for (const previousSample of meterValue.sampledValue) {
+ if (previousSample.signedMeterValue != null) {
+ signedSamples.push(clone(previousSample))
+ continue
+ }
+ if (!isClockAlignedIntervalSample(previousSample)) continue
+ const identity = clockAlignedSampleIdentity(previousSample)
+ const currentSample = intervalSamplesByIdentity.get(identity)
+ if (currentSample == null) {
+ const preservedSample = { ...previousSample }
+ sampledValue.push(preservedSample)
+ intervalSamplesByIdentity.set(identity, preservedSample)
+ } else {
+ currentSample.value += previousSample.value
+ }
+ }
+ if (isNotEmptyArray(signedSamples)) {
+ preservedSignedMeterValues.push({ ...meterValue, sampledValue: signedSamples })
+ }
+ }
+ return {
+ ...next,
+ meterValue: [
+ ...preservedSignedMeterValues,
+ ...next.meterValue.slice(0, -1),
+ { ...targetMeterValue, sampledValue },
+ ],
+ }
+}
+
+const COLLECTED_ENDED_METER_VALUES_HEADROOM_RATIO = 0.75
+const MAX_COLLECTED_ENDED_METER_VALUES = Math.floor(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH * COLLECTED_ENDED_METER_VALUES_HEADROOM_RATIO
+)
+const MAX_COLLECTED_ENDED_METER_VALUE_BYTES = Math.floor(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * COLLECTED_ENDED_METER_VALUES_HEADROOM_RATIO
+)
+interface CollectedEndedMeterValuesAccounting {
+ bytes: number
+ last?: OCPP20MeterValue
+ length: number
+ meterValues: OCPP20MeterValue[]
+}
+const collectedEndedMeterValuesAccounting = new WeakMap<
+ ConnectorStatus,
+ CollectedEndedMeterValuesAccounting
+>()
+
+const getCollectedMeterValuesBytes = (meterValues: readonly OCPP20MeterValue[]): number =>
+ Buffer.byteLength(JSON.stringify(meterValues), 'utf8')
+
+const appendBoundedCollectedEndedMeterValue = (
+ connectorStatus: ConnectorStatus,
+ meterValue: OCPP20MeterValue
+): void => {
+ const meterValues = connectorStatus.transactionEndedMeterValues as OCPP20MeterValue[] | undefined
+ if (meterValues == null) return
+ let accounting = collectedEndedMeterValuesAccounting.get(connectorStatus)
+ if (
+ accounting?.meterValues !== meterValues ||
+ accounting.length !== meterValues.length ||
+ accounting.last !== meterValues.at(-1)
+ ) {
+ accounting = {
+ bytes: getCollectedMeterValuesBytes(meterValues),
+ last: meterValues.at(-1),
+ length: meterValues.length,
+ meterValues,
+ }
+ }
+ const serializedMeterValueBytes = Buffer.byteLength(JSON.stringify(meterValue), 'utf8')
+ accounting.bytes += serializedMeterValueBytes + (isEmpty(meterValues) ? 0 : 1)
+ meterValues.push(meterValue)
+ accounting.last = meterValue
+ accounting.length = meterValues.length
+ collectedEndedMeterValuesAccounting.set(connectorStatus, accounting)
+ if (
+ accounting.length <= MAX_COLLECTED_ENDED_METER_VALUES &&
+ accounting.bytes <= MAX_COLLECTED_ENDED_METER_VALUE_BYTES
+ ) {
+ return
+ }
+
+ const retainedIndexes = new Set<number>([0, meterValues.length - 1])
+ let firstSignedIndex: number | undefined
+ let latestSignedIndex: number | undefined
+ let publicKeyIndex: number | undefined
+ const intervalTotals = new Map<string, { readonly sample: OCPP20SampledValue; total: number }>()
+ for (const [meterValueIndex, collectedMeterValue] of meterValues.entries()) {
+ for (const sampledValue of collectedMeterValue.sampledValue) {
+ if (sampledValue.signedMeterValue != null) {
+ firstSignedIndex ??= meterValueIndex
+ latestSignedIndex = meterValueIndex
+ if (
+ publicKeyIndex == null &&
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ ) {
+ publicKeyIndex = meterValueIndex
+ }
+ }
+ if (!isClockAlignedIntervalSample(sampledValue) || !Number.isFinite(sampledValue.value)) {
+ continue
+ }
+ const identity = clockAlignedSampleIdentity({ ...sampledValue, signedMeterValue: undefined })
+ const total = intervalTotals.get(identity)
+ if (total == null) {
+ intervalTotals.set(identity, { sample: sampledValue, total: sampledValue.value })
+ } else {
+ total.total += sampledValue.value
+ }
+ }
+ }
+ for (const index of [firstSignedIndex, latestSignedIndex, publicKeyIndex]) {
+ if (index != null) retainedIndexes.add(index)
+ }
+
+ const retainedMeterValues = meterValues.filter((_, index) => retainedIndexes.has(index))
+ const retainedIntervalTotals = new Map<string, number>()
+ const retainedUnsignedSamples = new Map<string, OCPP20SampledValue>()
+ for (const retainedMeterValue of retainedMeterValues) {
+ for (const sampledValue of retainedMeterValue.sampledValue) {
+ if (!isClockAlignedIntervalSample(sampledValue) || !Number.isFinite(sampledValue.value)) {
+ continue
+ }
+ const identity = clockAlignedSampleIdentity({ ...sampledValue, signedMeterValue: undefined })
+ retainedIntervalTotals.set(
+ identity,
+ (retainedIntervalTotals.get(identity) ?? 0) + sampledValue.value
+ )
+ if (sampledValue.signedMeterValue == null) retainedUnsignedSamples.set(identity, sampledValue)
+ }
+ }
+ const latestMeterValue = retainedMeterValues.at(-1)
+ if (latestMeterValue != null) {
+ for (const [identity, { sample, total }] of intervalTotals) {
+ const missingEnergy = total - (retainedIntervalTotals.get(identity) ?? 0)
+ if (missingEnergy === 0) continue
+ const retainedSample = retainedUnsignedSamples.get(identity)
+ if (retainedSample != null) {
+ retainedSample.value += missingEnergy
+ continue
+ }
+ const aggregateSample = clone(sample)
+ delete aggregateSample.signedMeterValue
+ aggregateSample.value = missingEnergy
+ latestMeterValue.sampledValue.push(aggregateSample)
+ }
+ }
+ meterValues.splice(0, meterValues.length, ...retainedMeterValues)
+ accounting.bytes = getCollectedMeterValuesBytes(meterValues)
+ accounting.last = meterValues.at(-1)
+ accounting.length = meterValues.length
+}
+
+const normalizeElectricalPhase = (phase: string | undefined): string | undefined => {
+ const lineIndex = resolveLinePhaseIndex(phase)
+ return lineIndex != null ? `L${lineIndex.toString()}` : phase
+}
+
+const filterUnconvertibleDcStationSamples = (
+ sampledValues: OCPP20SampledValue[],
+ physicalBaseline: readonly OCPP20SampledValue[],
+ currentType: CurrentType | undefined
+): OCPP20SampledValue[] => {
+ if (currentType !== CurrentType.DC) return sampledValues
+ return sampledValues.filter(sampledValue => {
+ if (
+ sampledValue.location == null ||
+ (sampledValue.measurand != null &&
+ DC_STATION_AGGREGATION_DIRECTION.has(sampledValue.measurand))
+ ) {
+ return true
+ }
+ const matchingSources = physicalBaseline.filter(
+ source =>
+ source.measurand === sampledValue.measurand &&
+ source.context === sampledValue.context &&
+ normalizeElectricalPhase(source.phase) === normalizeElectricalPhase(sampledValue.phase) &&
+ canonicalizeCustomData(source.customData) ===
+ canonicalizeCustomData(sampledValue.customData)
+ )
+ return (
+ isEmpty(matchingSources) ||
+ matchingSources.some(source => source.location === sampledValue.location)
+ )
+ })
+}
+
+const aggregateClockAlignedSamples = (
+ meterValues: readonly OCPP20MeterValue[],
+ numberOfPhases: number
+): OCPP20SampledValue[] => {
+ const samples = new Map<string, OCPP20SampledValue>()
+ const phaseOnlyGroups = new Map<string, Map<string, OCPP20SampledValue>>()
+ const buildKey = (sample: OCPP20SampledValue, additive: boolean): string =>
+ JSON.stringify([
+ sample.measurand,
+ sample.context,
+ sample.location,
+ sample.phase,
+ canonicalizeCustomData(sample.customData),
+ ...(additive ? [] : [sample.unitOfMeasure?.unit, sample.unitOfMeasure?.multiplier]),
+ ])
+ const buildGroupKey = (sample: OCPP20SampledValue): string =>
+ JSON.stringify([
+ sample.measurand,
+ sample.context,
+ sample.location,
+ sample.unitOfMeasure?.unit,
+ sample.unitOfMeasure?.multiplier,
+ canonicalizeCustomData(sample.customData),
+ ])
+
+ for (const meterValue of meterValues) {
+ const meterSamples = new Map<string, { additive: boolean; sampledValue: OCPP20SampledValue }>()
+ for (const sampledValue of meterValue.sampledValue) {
+ const additiveUnitFamily = getMeterValueUnitFamily(
+ sampledValue.measurand,
+ sampledValue.unitOfMeasure?.unit
+ )
+ const normalizedSample =
+ additiveUnitFamily != null
+ ? normalizeClockAlignedAdditiveSample(sampledValue, additiveUnitFamily)
+ : sampledValue
+ meterSamples.set(buildKey(normalizedSample, additiveUnitFamily != null), {
+ additive: additiveUnitFamily != null,
+ sampledValue: normalizedSample,
+ })
+ }
+
+ const meterGroups = Map.groupBy(
+ [...meterSamples.values()].filter(({ additive }) => additive),
+ ({ sampledValue }) => buildGroupKey(sampledValue)
+ )
+ for (const [groupKey, groupSamples] of meterGroups) {
+ if (groupSamples.some(({ sampledValue }) => sampledValue.phase == null)) continue
+ const meterPhases = new Map<string, OCPP20SampledValue>()
+ for (const { sampledValue } of groupSamples) {
+ const lineMatch =
+ sampledValue.phase == null ? null : /^L([123])(?:-N)?$/.exec(sampledValue.phase)
+ const line = lineMatch?.[1]
+ if (line != null && !meterPhases.has(line)) meterPhases.set(line, sampledValue)
+ }
+ const accumulatedPhases =
+ phaseOnlyGroups.get(groupKey) ?? new Map<string, OCPP20SampledValue>()
+ for (const [line, sampledValue] of meterPhases) {
+ const existing = accumulatedPhases.get(line)
+ if (existing == null) {
+ accumulatedPhases.set(line, { ...sampledValue })
+ } else {
+ existing.value += sampledValue.value
+ }
+ }
+ phaseOnlyGroups.set(groupKey, accumulatedPhases)
+ }
+
+ for (const [key, { additive, sampledValue }] of meterSamples) {
+ const existing = samples.get(key)
+ if (existing != null && additive) {
+ existing.value += sampledValue.value
+ continue
+ }
+ if (existing == null) {
+ const aggregate = { ...sampledValue }
+ delete aggregate.signedMeterValue
+ samples.set(key, aggregate)
+ }
+ }
+ }
+
+ for (const byLine of phaseOnlyGroups.values()) {
+ if (byLine.size < Math.max(1, numberOfPhases)) continue
+ const values = [...byLine.values()]
+ const first = values[0]
+ const total = values.reduce((sum, sample) => sum + sample.value, 0)
+ const aggregate = {
+ ...first,
+ phase: undefined,
+ value: first.measurand?.startsWith('Current.') === true ? total / values.length : total,
+ }
+ delete aggregate.signedMeterValue
+ const key = buildKey(aggregate, true)
+ const existing = samples.get(key)
+ if (existing == null) {
+ samples.set(key, aggregate)
+ } else {
+ existing.value += aggregate.value
+ }
+ }
+ return [...samples.values()]
+}
+
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class OCPP20ServiceUtils {
+ private static readonly clockAlignedMeterValuesSendStates = new WeakMap<
+ ChargingStation,
+ Map<number, ClockAlignedMeterValuesSendState>
+ >()
+
+ private static readonly endedIntervalEnergyCoverages = new WeakMap<
+ readonly OCPP20MeterValue[],
+ EndedIntervalEnergyCoverage
+ >()
+
private static readonly incomingRequestSchemaNames: readonly [
OCPP20IncomingRequestCommand,
string
[OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'],
]
+ private static readonly pendingTransactionEventDeliveryCounts = new WeakMap<
+ ConnectorStatus,
+ Map<string, number>
+ >()
+
+ private static readonly replayedTransactionEventOwners = new WeakMap<
+ OCPP20TransactionEventRequest,
+ { connectorId: number; evseId: number }
+ >()
+
+ private static readonly replayedTransactionEventRequests =
+ new WeakSet<OCPP20TransactionEventRequest>()
+
+ private static readonly saturatedTransactionEventQueues = new WeakSet<ConnectorStatus>()
+ private static readonly stopTransactionOperations = new WeakMap<
+ ConnectorStatus,
+ StopTransactionOperation
+ >()
+
+ private static readonly stopTransactionOperationsByStation = new WeakMap<
+ ChargingStation,
+ Set<StopTransactionOperation>
+ >()
+
+ private static readonly transactionEventQueueDrains = new WeakSet<ConnectorStatus>()
+ private static readonly transactionEventSendChains = new WeakMap<
+ ConnectorStatus,
+ Promise<unknown>
+ >()
+
+ private static readonly transactionMeterValueDeliveries = new WeakMap<
+ QueuedTransactionEvent,
+ TransactionMeterValueDelivery
+ >()
+
+ private static readonly triggeredTransactionEventReservations = new WeakMap<
+ ConnectorStatus,
+ Set<string>
+ >()
+
/**
* @param chargingStation - Target charging station for EVSE resolution
* @param commandParams - StatusNotification input; `connectorStatus` takes precedence over `status`
}
/**
- * Build meter values for the start of a transaction.
+ * Build a transaction MeterValue while keeping a shared EVSE register single-writer.
* @param chargingStation - Target charging station
- * @param transactionId - Transaction identifier
- * @returns Array of OCPP 2.0.1 meter values at transaction begin
+ * @param connectorId - EVSE-local connector identifier
+ * @param evseId - EVSE identifier
+ * @param transactionId - Active transaction identifier
+ * @param interval - Sampling interval in milliseconds
+ * @param measurandsKey - Configuration key selecting sampled measurands
+ * @param context - MeterValue reading context
+ * @param timestamp - Optional shared observation timestamp
+ * @param energyNominalIntervalOverride - Optional cadence governing elapsed legacy energy.
+ * @param settlementOnly - Build an unsigned accounting snapshot that is not sent on the wire.
+ * @returns Populated OCPP 2.0 MeterValue
*/
- static buildTransactionStartedMeterValues (
+ public static buildTransactionMeterValue (
chargingStation: ChargingStation,
- transactionId: number | string
- ): OCPP20MeterValue[] {
- try {
- const measurandsKey = buildConfigKey(
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxStartedMeasurands
+ connectorId: number,
+ evseId: number | undefined,
+ transactionId: number | string,
+ interval: number,
+ measurandsKey?: ConfigurationKeyType,
+ context?: OCPP20ReadingContextEnumType,
+ timestamp?: Date,
+ energyNominalIntervalOverride?: number,
+ settlementOnly = false
+ ): OCPP20MeterValue {
+ const evseStatus = evseId != null ? chargingStation.getEvseStatus(evseId) : undefined
+ const usesSharedEvseRegister =
+ evseId != null &&
+ evseId !== 0 &&
+ isNotEmptyArray(evseStatus?.MeterValues) &&
+ chargingStation.stationInfo?.meteringPerTransaction !== true
+ const connectors = usesSharedEvseRegister
+ ? [...evseStatus.connectors.entries()].sort(
+ ([leftConnectorId], [rightConnectorId]) => leftConnectorId - rightConnectorId
+ )
+ : []
+ const coherentSession = chargingStation.getCoherentSession(transactionId)
+ const energyNominalInterval =
+ energyNominalIntervalOverride ??
+ getTransactionEnergyNominalInterval(chargingStation, interval)
+ const meterValueTimestamp = timestamp ?? new Date()
+ const previousSharedEnergyUpdate = evseStatus?.energyActiveImportRegisterLastUpdatedAt
+ const sharedEnergyInterval = getSharedEnergyObservationInterval(
+ connectors,
+ previousSharedEnergyUpdate,
+ meterValueTimestamp,
+ interval
+ )
+ const hasCoherentTransaction = connectors.some(([, status]) => {
+ return (
+ canContributeToSharedObservation(status, context) &&
+ status.transactionId != null &&
+ chargingStation.getCoherentSession(status.transactionId) != null
+ )
+ })
+ const isNewLegacySharedObservation =
+ !hasCoherentTransaction &&
+ (evseStatus?.energyActiveImportRegisterLastUpdatedAt == null ||
+ meterValueTimestamp > evseStatus.energyActiveImportRegisterLastUpdatedAt)
+ const sharedEnergyOwnerConnectorId = usesSharedEvseRegister
+ ? hasCoherentTransaction
+ ? coherentSession != null || context !== OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ ? connectorId
+ : (connectors.find(([, status]) => {
+ return (
+ canAdvanceAlignedEnergy(status) &&
+ status.transactionId != null &&
+ chargingStation.getCoherentSession(status.transactionId) == null
+ )
+ })?.[0] ?? connectors.find(([, status]) => canAdvanceAlignedEnergy(status))?.[0])
+ : isNewLegacySharedObservation
+ ? connectorId
+ : undefined
+ : undefined
+ let sharedEnergyRegisterWh = usesSharedEvseRegister
+ ? connectors.reduce(
+ (total, [, status]) => total + Math.max(0, status.energyActiveImportRegisterValue ?? 0),
+ 0
)
- const startedMeterValue = buildMeterValue(
+ : undefined
+ const ownerTransactionId = connectors.find(
+ ([candidateConnectorId]) => candidateConnectorId === sharedEnergyOwnerConnectorId
+ )?.[1].transactionId
+ const ownerSession =
+ ownerTransactionId != null
+ ? chargingStation.getCoherentSession(ownerTransactionId)
+ : undefined
+ if (
+ usesSharedEvseRegister &&
+ connectorId === sharedEnergyOwnerConnectorId &&
+ context !== OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ ) {
+ const sampledAtMs = meterValueTimestamp.getTime()
+ if (ownerSession != null) {
+ const legacyOwner = connectors.find(([, status]) => {
+ return (
+ canContributeToSharedObservation(status, context) &&
+ status.transactionId != null &&
+ chargingStation.getCoherentSession(status.transactionId) == null
+ )
+ })
+ if (legacyOwner != null) {
+ const [legacyConnectorId, legacyConnectorStatus] = legacyOwner
+ const previousLegacyEnergyWh = Math.max(
+ 0,
+ legacyConnectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ )
+ const legacyObservationInterval = getTransactionObservationInterval(
+ legacyConnectorStatus,
+ meterValueTimestamp,
+ energyNominalInterval ?? interval
+ )
+ buildMeterValue(
+ chargingStation,
+ legacyConnectorStatus.transactionId,
+ interval,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ false,
+ {
+ advanceEnergy: false,
+ connectorId: legacyConnectorId,
+ deferEnergyInterval: true,
+ energyNominalInterval,
+ energyRegisterWhOverride: sharedEnergyRegisterWh,
+ evseId,
+ suppressSigning: true,
+ timestamp: meterValueTimestamp,
+ }
+ )
+ const legacyObservationEnergyWh = Math.max(
+ 0,
+ (legacyConnectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ previousLegacyEnergyWh
+ )
+ recordPendingSharedEnergy(ownerSession, legacyObservationEnergyWh)
+ for (const [otherLegacyConnectorId, otherLegacyStatus] of connectors) {
+ if (
+ otherLegacyConnectorId === legacyConnectorId ||
+ !canContributeToSharedObservation(otherLegacyStatus, context) ||
+ otherLegacyStatus.transactionId == null ||
+ chargingStation.getCoherentSession(otherLegacyStatus.transactionId) != null ||
+ (otherLegacyStatus.transactionEnergyActiveImportRegisterLastUpdatedAt != null &&
+ otherLegacyStatus.transactionEnergyActiveImportRegisterLastUpdatedAt >=
+ meterValueTimestamp)
+ ) {
+ continue
+ }
+ const peerObservationEnergyWh = prorateSharedObservationEnergy(
+ legacyObservationEnergyWh,
+ legacyObservationInterval,
+ otherLegacyStatus,
+ meterValueTimestamp,
+ energyNominalInterval ?? interval
+ )
+ otherLegacyStatus.transactionEnergyActiveImportRegisterValue =
+ Math.max(0, otherLegacyStatus.transactionEnergyActiveImportRegisterValue ?? 0) +
+ peerObservationEnergyWh
+ otherLegacyStatus.transactionEnergyActiveImportRegisterLastUpdatedAt =
+ meterValueTimestamp
+ }
+ }
+ }
+ for (const [otherConnectorId, otherConnectorStatus] of connectors) {
+ if (
+ otherConnectorId === connectorId ||
+ !canContributeToSharedObservation(otherConnectorStatus, context)
+ ) {
+ continue
+ }
+ const otherTransactionId = otherConnectorStatus.transactionId
+ const otherSession =
+ otherTransactionId != null
+ ? chargingStation.getCoherentSession(otherTransactionId)
+ : undefined
+ if (otherSession == null) continue
+ const sample = computeCoherentSampleAtTime(
+ chargingStation,
+ otherConnectorStatus,
+ otherSession,
+ {
+ intervalMs: energyNominalInterval ?? interval,
+ nowMs: sampledAtMs,
+ rootSeed: resolveRootSeed(chargingStation.stationInfo),
+ },
+ evseId
+ )
+ advanceTransactionEnergyRegister(otherConnectorStatus, sample.deltaEnergyWh)
+ const pendingSharedEnergyWh = consumePendingSharedEnergy(otherSession, sample.deltaEnergyWh)
+ if (ownerSession != null) {
+ recordPendingSharedEnergy(ownerSession, pendingSharedEnergyWh)
+ } else {
+ const ownerStatus = connectors.find(
+ ([candidateConnectorId]) => candidateConnectorId === sharedEnergyOwnerConnectorId
+ )?.[1]
+ advanceConnectorEnergyRegister(ownerStatus, pendingSharedEnergyWh)
+ advanceStationEnergyRegister(
+ chargingStation,
+ evseId,
+ otherSession.currentType,
+ MeterValueLocation.OUTLET,
+ pendingSharedEnergyWh
+ )
+ if (sharedEnergyRegisterWh != null) sharedEnergyRegisterWh += pendingSharedEnergyWh
+ }
+ }
+ }
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ const sharedRegisterBeforeRequestedBuild = sharedEnergyRegisterWh
+ const meterValue = buildMeterValue(
+ chargingStation,
+ transactionId,
+ interval,
+ measurandsKey,
+ context,
+ false,
+ {
+ connectorId,
+ energyNominalInterval,
+ ...(evseId != null && { evseId }),
+ timestamp: meterValueTimestamp,
+ ...(settlementOnly && { deferEnergyInterval: true, suppressSigning: true }),
+ ...(usesSharedEvseRegister && {
+ advanceEnergy: connectorId === sharedEnergyOwnerConnectorId,
+ ...(connectorId === sharedEnergyOwnerConnectorId &&
+ context !== OCPP20ReadingContextEnumType.TRANSACTION_BEGIN && {
+ energyElapsedInterval: sharedEnergyInterval,
+ }),
+ energyRegisterWhOverride: sharedEnergyRegisterWh,
+ }),
+ }
+ ) as OCPP20MeterValue
+ const sharedObservationEnergyWh =
+ usesSharedEvseRegister &&
+ context !== OCPP20ReadingContextEnumType.TRANSACTION_BEGIN &&
+ connectorId === sharedEnergyOwnerConnectorId &&
+ connectorStatus?.transactionEnergyActiveImportRegisterLastUpdatedAt?.getTime() ===
+ meterValueTimestamp.getTime()
+ ? Math.max(
+ 0,
+ connectors.reduce(
+ (total, [, status]) =>
+ total + Math.max(0, status.energyActiveImportRegisterValue ?? 0),
+ 0
+ ) - (sharedRegisterBeforeRequestedBuild ?? 0)
+ )
+ : undefined
+ if (sharedObservationEnergyWh != null) {
+ if (evseStatus != null && sharedObservationEnergyWh > 0) {
+ evseStatus.energyActiveImportRegisterValue =
+ Math.max(0, evseStatus.energyActiveImportRegisterValue ?? 0) + sharedObservationEnergyWh
+ }
+ const sharedObservationInterval = sharedEnergyInterval
+ if (
+ evseStatus != null &&
+ (previousSharedEnergyUpdate == null || meterValueTimestamp > previousSharedEnergyUpdate)
+ ) {
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = meterValueTimestamp
+ }
+ if (coherentSession == null) {
+ for (const [otherConnectorId, otherConnectorStatus] of connectors) {
+ if (
+ otherConnectorId === connectorId ||
+ otherConnectorStatus.transactionId == null ||
+ chargingStation.getCoherentSession(otherConnectorStatus.transactionId) != null ||
+ (!canAdvanceAlignedEnergy(otherConnectorStatus) &&
+ !(
+ context === OCPP20ReadingContextEnumType.TRANSACTION_END &&
+ hasOngoingTransaction(otherConnectorStatus)
+ )) ||
+ (otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt != null &&
+ otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt >=
+ meterValueTimestamp)
+ ) {
+ continue
+ }
+ const peerObservationEnergyWh = prorateSharedObservationEnergy(
+ sharedObservationEnergyWh,
+ sharedObservationInterval,
+ otherConnectorStatus,
+ meterValueTimestamp,
+ energyNominalInterval ?? interval
+ )
+ otherConnectorStatus.transactionEnergyActiveImportRegisterValue =
+ Math.max(0, otherConnectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) +
+ peerObservationEnergyWh
+ otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt =
+ meterValueTimestamp
+ }
+ }
+ }
+ return meterValue
+ }
+
+ /**
+ * Build meter values for the start of a transaction.
+ * @param chargingStation - Target charging station
+ * @param transactionId - Transaction identifier
+ * @param connectorId - Resolved connector identifier
+ * @param evseId - Resolved EVSE identifier
+ * @returns Array of OCPP 2.0.1 meter values at transaction begin
+ */
+ static buildTransactionStartedMeterValues (
+ chargingStation: ChargingStation,
+ transactionId: number | string,
+ connectorId?: number,
+ evseId?: number
+ ): OCPP20MeterValue[] {
+ try {
+ const resolvedConnectorId =
+ connectorId ?? chargingStation.getConnectorIdByTransactionId(transactionId)
+ const resolvedEvseId = evseId ?? chargingStation.getEvseIdByTransactionId(transactionId)
+ if (resolvedConnectorId == null || resolvedEvseId == null) return []
+
+ const timestamp = new Date()
+ const sampledMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
chargingStation,
+ resolvedConnectorId,
+ resolvedEvseId,
transactionId,
0,
- measurandsKey,
- OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
- ) as OCPP20MeterValue
- return isNotEmptyArray(startedMeterValue.sampledValue) ? [startedMeterValue] : []
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxStartedMeasurands
+ ),
+ OCPP20ReadingContextEnumType.TRANSACTION_BEGIN,
+ timestamp
+ )
+ const alignedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const alignedMeterValue =
+ getConfigurationKey(chargingStation, alignedMeasurandsKey)?.value != null ||
+ isEndedIntervalRecoverySigningRequired(chargingStation)
+ ? buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ advanceEnergy: false,
+ commitState: false,
+ connectorId: resolvedConnectorId,
+ evseId: resolvedEvseId,
+ timestamp,
+ transactionId,
+ },
+ 0,
+ alignedMeasurandsKey,
+ OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ )
+ : undefined
+ const sampledValue = [
+ ...sampledMeterValue.sampledValue,
+ ...(alignedMeterValue?.sampledValue ?? []),
+ ]
+ return isNotEmptyArray(sampledValue) ? [{ sampledValue, timestamp }] : []
} catch (error) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.buildTransactionStartedMeterValues: ${getErrorMessage(error)}`
}
}
+ /**
+ * Cancels unsent aligned MeterValues and every outstanding trigger reservation.
+ * @param chargingStation - Target charging station.
+ */
+ public static cancelClockAlignedMeterValuesRequests (chargingStation: ChargingStation): void {
+ const stationStates = OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.get(chargingStation)
+ for (const [evseId, state] of stationStates ?? []) {
+ if (state.pending != null) {
+ const pending = state.pending
+ delete state.pending
+ settlePendingClockAlignedRequest(chargingStation, state, pending, true)
+ }
+ delete state.triggerReserved
+ if (state.inFlight == null) stationStates?.delete(evseId)
+ }
+ if (stationStates != null && isEmpty(stationStates)) {
+ OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.delete(chargingStation)
+ }
+ for (const { connectorStatus } of chargingStation.iterateConnectors(true)) {
+ for (const queuedEvent of connectorStatus.transactionEventQueue ?? []) {
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(queuedEvent, false)
+ }
+ }
+ }
+
+ /**
+ * Cancels transaction terminations that still own connectors at the shutdown deadline.
+ * @param chargingStation - Station whose pending terminations must be released
+ */
+ public static cancelTransactionTerminations (chargingStation: ChargingStation): void {
+ const cancellationError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.cancelTransactionTerminations: Charging station shutdown finalized before transaction termination settled`,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ let queueStateChanged = false
+ for (const operation of [
+ ...(OCPP20ServiceUtils.stopTransactionOperationsByStation.get(chargingStation) ?? []),
+ ]) {
+ queueStateChanged = operation.cancel(cancellationError) || queueStateChanged
+ }
+ if (queueStateChanged) chargingStation.saveTransactionEventQueues()
+ }
+
/**
* Clean up connector state after a transaction has ended.
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier
* @param connectorStatus - Connector status to reset
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
+ * @param expectedTransactionId - Transaction that is allowed to own the connector cleanup
+ * @returns Whether the expected transaction was finalized
*/
public static async cleanupEndedTransaction (
chargingStation: ChargingStation,
connectorId: number,
- connectorStatus: ConnectorStatus
- ): Promise<void> {
+ connectorStatus: ConnectorStatus,
+ evseId?: number,
+ expectedTransactionId?: string
+ ): Promise<boolean> {
+ if (
+ expectedTransactionId != null &&
+ connectorStatus.transactionId?.toString() !== expectedTransactionId
+ ) {
+ return false
+ }
if (
connectorStatus.transactionStarted !== true &&
- connectorStatus.transactionPending !== true
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionStarting !== true &&
+ connectorStatus.transactionEnding !== true
) {
- return
+ return false
}
- // Snapshot transactionId BEFORE any mutation below deletes it, so the
- // coherent session (if any) can still be destroyed after the reset.
const txId = connectorStatus.transactionId
- OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
+ const saveTerminalState = (): void => {
+ try {
+ chargingStation.saveTransactionEventQueues()
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.cleanupEndedTransaction: Failed to persist terminal transaction state:`,
+ error
+ )
+ }
+ }
const postTransactionDelay = chargingStation.stationInfo?.postTransactionDelay ?? 0
- if (postTransactionDelay > 0) {
- delete connectorStatus.transactionId
- // Destroy the coherent session BEFORE sleeping so an intervening
- // stop cannot leak it. `destroyCoherentSession` is idempotent so the
- // post-sleep call remains valid.
+ try {
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
+ resetConnectorStatus(connectorStatus)
chargingStation.destroyCoherentSession(txId)
- await sleep(secondsToMilliseconds(postTransactionDelay))
- if (!chargingStation.started) {
- return
+ // Persist an unlocked terminal state: the in-memory lock only represents
+ // this process' unplug-delay timer and cannot be resumed after a restart.
+ connectorStatus.locked = false
+ const lifecycleAbortSignal = (chargingStation as { lifecycleAbortSignal?: AbortSignal })
+ .lifecycleAbortSignal
+ if (postTransactionDelay > 0 && !chargingStation.isStopping()) {
+ connectorStatus.postTransactionDelayTransactionId = txId
+ saveTerminalState()
+ // The connector remains occupied during the configured unplug delay.
+ connectorStatus.locked = true
+ if (lifecycleAbortSignal == null) {
+ await sleep(secondsToMilliseconds(postTransactionDelay))
+ } else {
+ await interruptibleSleep(
+ secondsToMilliseconds(postTransactionDelay),
+ lifecycleAbortSignal
+ )
+ }
+ }
+ if (
+ connectorStatus.postTransactionDelayTransactionId != null &&
+ connectorStatus.postTransactionDelayTransactionId !== txId
+ ) {
+ return true
+ }
+ if (connectorStatus.postTransactionDelayTransactionId === txId) {
+ delete connectorStatus.postTransactionDelayTransactionId
+ }
+ const currentConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ currentConnectorStatus !== connectorStatus ||
+ connectorStatus.transactionId != null ||
+ connectorStatus.transactionStarted === true ||
+ connectorStatus.transactionPending === true ||
+ connectorStatus.transactionStarting === true ||
+ connectorStatus.transactionEnding === true
+ ) {
+ return true
+ }
+ connectorStatus.locked = false
+ if (
+ !chargingStation.started ||
+ chargingStation.isStopping() ||
+ lifecycleAbortSignal?.aborted === true
+ ) {
+ connectorStatus.status =
+ chargingStation.isChargingStationAvailable() &&
+ connectorStatus.availability === AvailabilityType.Operative
+ ? OCPP20ConnectorStatusEnumType.Available
+ : OCPP20ConnectorStatusEnumType.Unavailable
+ return true
+ }
+ sendPostTransactionStatus(chargingStation, connectorId, evseId, {
+ responseTimeoutMs: OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS
+ ),
+ waitForResponse: false,
+ }).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.cleanupEndedTransaction: Failed to send post-transaction status:`,
+ error
+ )
+ })
+ return true
+ } finally {
+ const currentTransactionId = connectorStatus.transactionId?.toString()
+ const currentPostTransactionDelayId = connectorStatus.postTransactionDelayTransactionId
+ if (
+ (currentTransactionId == null || currentTransactionId === txId?.toString()) &&
+ (currentPostTransactionDelayId == null || currentPostTransactionDelayId === txId)
+ ) {
+ resetConnectorStatus(connectorStatus)
+ connectorStatus.locked = false
+ connectorStatus.status =
+ chargingStation.isChargingStationAvailable() &&
+ connectorStatus.availability === AvailabilityType.Operative
+ ? OCPP20ConnectorStatusEnumType.Available
+ : OCPP20ConnectorStatusEnumType.Unavailable
}
+ saveTerminalState()
}
- resetConnectorStatus(connectorStatus)
- chargingStation.destroyCoherentSession(txId)
- connectorStatus.locked = false
- await sendPostTransactionStatus(chargingStation, connectorId)
}
/**
{ schemaPath: string }
][] => createPayloadConfigs(OCPP20ServiceUtils.outgoingRequestSchemaNames, 'Response.json')
+ /**
+ * One tick of the station-scoped clock-aligned MeterValues sweep (#2011
+ * Category 2F, J01.FR.14/J01.FR.20/J01.FR.21/J01.FR.22). Called by the
+ * station-level aligned timer; every gate is re-read per tick so
+ * configuration changes take effect without re-arming:
+ * - `AlignedDataCtrlr.Interval <= 0` disables transmission (spec §2.2). Read
+ * raw (not via {@link OCPP20ServiceUtils.getAlignedDataInterval}) because
+ * `readVariableAsIntervalMs` clamps non-positive values to the default.
+ * - `AlignedDataCtrlr.Enabled=false` (the default) disables the feature.
+ * - The station-scoped `SendDuringIdle=true` suppresses the whole sweep
+ * while any transaction is ongoing (J01.FR.20).
+ * EVSEs without a transaction get one aggregated `MeterValuesRequest` with
+ * ReadingContext Sample.Clock while online. Each active connector reports
+ * its sample in `TransactionEvent(Updated, MeterValueClock)` and queues it
+ * while offline so transaction identity and sequence state remain attached.
+ * @param chargingStation - Target charging station
+ * @param timestamp - UTC slot timestamp shared by every message in this sweep
+ */
+ public static async emitClockAlignedMeterValues (
+ chargingStation: ChargingStation,
+ timestamp = new Date()
+ ): Promise<void> {
+ const alignedDataIntervalSeconds =
+ OCPP20ServiceUtils.readAlignedDataIntervalSeconds(chargingStation)
+ if (alignedDataIntervalSeconds == null || alignedDataIntervalSeconds === 0) {
+ return
+ }
+ const alignedDataEnabled = OCPP20ServiceUtils.readVariableAsBoolean(
+ chargingStation,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Enabled,
+ false
+ )
+ if (!alignedDataEnabled) {
+ return
+ }
+ const sendDuringIdle = OCPP20ServiceUtils.isAlignedDataSendDuringIdleEnabled(chargingStation)
+ // J01.FR.20: the station-scoped value suppresses the whole charging
+ // station. More specific EVSE values are applied in the EVSE sweep below.
+ // A pending remote start is not ongoing until its Started event is accepted.
+ if (
+ sendDuringIdle &&
+ chargingStation
+ .iterateConnectors(true)
+ .some(({ connectorStatus }) => hasOngoingTransaction(connectorStatus))
+ ) {
+ return
+ }
+ const responseTimeoutMs = OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS,
+ 'Default'
+ )
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ const alignedEnergySamples = hasConfiguredAlignedEnergyMeasurand(chargingStation)
+ const alignedIntervalEnergySamples =
+ getConfigurationKey(chargingStation, measurandsKey)
+ ?.value?.split(',')
+ .some(
+ measurand =>
+ measurand.trim() === (OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL as string)
+ ) === true
+ const alignedInterval = secondsToMilliseconds(alignedDataIntervalSeconds)
+ const alignedIntervalBaselineKey = measurandsKey
+ const stationIntervalBaselineKey = `station:${alignedIntervalBaselineKey}`
+ const stationIntervalBaselinesBeforeBuild = new Map(
+ [...chargingStation.iterateConnectors(true)].map(({ connectorStatus }) => [
+ connectorStatus,
+ connectorStatus.energyActiveImportIntervalBaselines?.[stationIntervalBaselineKey],
+ ])
+ )
+ const restoreStationIntervalBaseline = (connectorStatus: ConnectorStatus): void => {
+ const baseline = stationIntervalBaselinesBeforeBuild.get(connectorStatus)
+ if (baseline == null) {
+ const baselines = connectorStatus.energyActiveImportIntervalBaselines
+ if (baselines != null) {
+ const { [stationIntervalBaselineKey]: _removed, ...remainingBaselines } = baselines
+ connectorStatus.energyActiveImportIntervalBaselines = remainingBaselines
+ }
+ } else {
+ connectorStatus.energyActiveImportIntervalBaselines ??= {}
+ connectorStatus.energyActiveImportIntervalBaselines[stationIntervalBaselineKey] = baseline
+ }
+ }
+ const restoreStationIntervalBaselines = (): void => {
+ for (const connectorStatus of stationIntervalBaselinesBeforeBuild.keys()) {
+ restoreStationIntervalBaseline(connectorStatus)
+ }
+ }
+ const transactionEnergyNominalInterval = getTransactionEnergyNominalInterval(
+ chargingStation,
+ alignedInterval
+ )
+ const canSendNonTransactional =
+ chargingStation.isWebSocketConnectionOpened() && chargingStation.inAcceptedState()
+ const pendingRequests: { evseId: number; send: () => Promise<void> }[] = []
+ const physicalMeterValues: OCPP20MeterValue[] = []
+ const evses = [...chargingStation.iterateEvses()].sort(
+ ({ evseId: left }, { evseId: right }) => {
+ if (left === 0) return 1
+ if (right === 0) return -1
+ return left - right
+ }
+ )
+ for (const { evseId, evseStatus } of evses) {
+ let evseInTransaction = false
+ for (const connectorStatus of evseStatus.connectors.values()) {
+ if (hasOngoingTransaction(connectorStatus)) {
+ evseInTransaction = true
+ break
+ }
+ }
+ const usesEvseMeterTemplate = evseId !== 0 && isNotEmptyArray(evseStatus.MeterValues)
+ const connectors = [...evseStatus.connectors.entries()].sort(
+ ([leftConnectorId], [rightConnectorId]) => leftConnectorId - rightConnectorId
+ )
+ const evseIntervalBaselineKey = `evse:${evseId.toString()}:${alignedIntervalBaselineKey}`
+ const evseIntervalBaselinesBeforeBuild = new Map(
+ connectors.map(([, connectorStatus]) => [
+ connectorStatus,
+ connectorStatus.energyActiveImportIntervalBaselines?.[evseIntervalBaselineKey],
+ ])
+ )
+ const restoreEvseIntervalBaseline = (connectorStatus: ConnectorStatus): void => {
+ const baseline = evseIntervalBaselinesBeforeBuild.get(connectorStatus)
+ if (baseline == null) {
+ const baselines = connectorStatus.energyActiveImportIntervalBaselines
+ if (baselines != null) {
+ const { [evseIntervalBaselineKey]: _removed, ...remainingBaselines } = baselines
+ connectorStatus.energyActiveImportIntervalBaselines = remainingBaselines
+ }
+ } else {
+ connectorStatus.energyActiveImportIntervalBaselines ??= {}
+ connectorStatus.energyActiveImportIntervalBaselines[evseIntervalBaselineKey] = baseline
+ }
+ }
+ const restoreEvseIntervalBaselines = (): void => {
+ for (const connectorStatus of evseIntervalBaselinesBeforeBuild.keys()) {
+ restoreEvseIntervalBaseline(connectorStatus)
+ }
+ }
+ const hasConnectorStationBaselineAtStart = connectors.some(
+ ([, connectorStatus]) =>
+ connectorStatus.energyActiveImportIntervalBaselines?.[stationIntervalBaselineKey] != null
+ )
+ const evseIntervalBaselineBeforeBuild = evseStatus.energyActiveImportIntervalBaseline
+ const restoreEvseAggregateIntervalBaseline = (): void => {
+ if (evseIntervalBaselineBeforeBuild == null) {
+ delete evseStatus.energyActiveImportIntervalBaseline
+ } else {
+ evseStatus.energyActiveImportIntervalBaseline = evseIntervalBaselineBeforeBuild
+ }
+ }
+ const hasValidEvseIntervalBaseline =
+ typeof evseIntervalBaselineBeforeBuild === 'number' &&
+ Number.isFinite(evseIntervalBaselineBeforeBuild) &&
+ evseIntervalBaselineBeforeBuild >= 0
+ const useInitialActiveIntervalObservation =
+ usesEvseMeterTemplate &&
+ evseInTransaction &&
+ chargingStation.stationInfo?.meteringPerTransaction !== true &&
+ !hasConnectorStationBaselineAtStart &&
+ !hasValidEvseIntervalBaseline
+ const initialActiveIntervalSamples = new Map<string, OCPP20SampledValue>()
+ const initialCoherentIntervalTotals = new Map<string, number>()
+ const initialLegacyIntervalMaximums = new Map<string, number>()
+ let stationIntervalBaselinesSeeded = false
+ const stationIntervalInitialBaselines = new Map(
+ connectors.map(([connectorId, connectorStatus]) => {
+ const transactionRegister =
+ connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ const alignedTransactionBaseline =
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[
+ alignedIntervalBaselineKey
+ ] ?? 0
+ return [
+ connectorId,
+ Math.max(
+ 0,
+ (connectorStatus.energyActiveImportRegisterValue ?? 0) -
+ Math.max(0, transactionRegister - alignedTransactionBaseline)
+ ),
+ ]
+ })
+ )
+ const sharedEnergyInterval = getSharedEnergyObservationInterval(
+ connectors,
+ evseStatus.energyActiveImportRegisterLastUpdatedAt,
+ timestamp,
+ alignedInterval
+ )
+ const hasCoherentTransaction = connectors.some(([, connectorStatus]) => {
+ return (
+ canAdvanceAlignedEnergy(connectorStatus) &&
+ connectorStatus.transactionId != null &&
+ chargingStation.getCoherentSession(connectorStatus.transactionId) != null
+ )
+ })
+ const sharedEvseEnergyOwnerConnectorId =
+ usesEvseMeterTemplate &&
+ evseInTransaction &&
+ chargingStation.stationInfo?.meteringPerTransaction !== true
+ ? ((hasCoherentTransaction
+ ? connectors.find(([, connectorStatus]) => {
+ return (
+ canAdvanceAlignedEnergy(connectorStatus) &&
+ connectorStatus.transactionId != null &&
+ chargingStation.getCoherentSession(connectorStatus.transactionId) == null
+ )
+ })?.[0]
+ : undefined) ??
+ connectors.find(([, connectorStatus]) => canAdvanceAlignedEnergy(connectorStatus))?.[0])
+ : undefined
+ let evseEnergyActiveImportRegisterValue = usesEvseMeterTemplate
+ ? [...evseStatus.connectors.values()].reduce(
+ (total, connectorStatus) =>
+ total + Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ : undefined
+ const suppressEvseEmission =
+ evseId !== 0 &&
+ evseInTransaction &&
+ OCPP20ServiceUtils.isAlignedDataSendDuringIdleEnabled(chargingStation, evseId)
+ const meterValues: OCPP20MeterValue[] = []
+ const sampledValueTemplates: SampledValueTemplate[] = []
+ let idleMeterConnectorId: number | undefined
+ const physicalEvseRegisterSamples =
+ usesEvseMeterTemplate && evseInTransaction ? new Set<string>() : undefined
+ let sharedIntervalObservationRecorded = false
+ const sharedOwnerStatus =
+ sharedEvseEnergyOwnerConnectorId != null
+ ? evseStatus.connectors.get(sharedEvseEnergyOwnerConnectorId)
+ : undefined
+ const sharedOwnerTransactionId = sharedOwnerStatus?.transactionId
+ const sharedOwnerSession =
+ sharedOwnerTransactionId != null
+ ? chargingStation.getCoherentSession(sharedOwnerTransactionId)
+ : undefined
+ if (sharedEvseEnergyOwnerConnectorId != null) {
+ for (const [connectorId, connectorStatus] of connectors) {
+ if (
+ connectorId === sharedEvseEnergyOwnerConnectorId ||
+ !canAdvanceAlignedEnergy(connectorStatus)
+ ) {
+ continue
+ }
+ const transactionId = connectorStatus.transactionId
+ const session =
+ transactionId != null ? chargingStation.getCoherentSession(transactionId) : undefined
+ if (session == null) continue
+ const sample = computeCoherentSampleAtTime(
+ chargingStation,
+ connectorStatus,
+ session,
+ {
+ intervalMs: transactionEnergyNominalInterval ?? alignedInterval,
+ nowMs: timestamp.getTime(),
+ rootSeed: resolveRootSeed(chargingStation.stationInfo),
+ },
+ evseId
+ )
+ advanceTransactionEnergyRegister(connectorStatus, sample.deltaEnergyWh)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = timestamp
+ const pendingSharedEnergyWh = consumePendingSharedEnergy(session, sample.deltaEnergyWh)
+ if (sharedOwnerSession != null) {
+ recordPendingSharedEnergy(sharedOwnerSession, pendingSharedEnergyWh)
+ } else {
+ advanceConnectorEnergyRegister(sharedOwnerStatus, pendingSharedEnergyWh)
+ advanceStationEnergyRegister(
+ chargingStation,
+ evseId,
+ session.currentType,
+ MeterValueLocation.OUTLET,
+ pendingSharedEnergyWh
+ )
+ if (evseEnergyActiveImportRegisterValue != null) {
+ evseEnergyActiveImportRegisterValue += pendingSharedEnergyWh
+ }
+ }
+ }
+ }
+ const connectorsInBuildOrder =
+ sharedEvseEnergyOwnerConnectorId != null
+ ? [
+ ...connectors.filter(
+ ([connectorId]) => connectorId === sharedEvseEnergyOwnerConnectorId
+ ),
+ ...connectors.filter(
+ ([connectorId]) => connectorId !== sharedEvseEnergyOwnerConnectorId
+ ),
+ ]
+ : connectors
+ for (const [connectorId, connectorStatus] of connectorsInBuildOrder) {
+ if (!evseInTransaction && usesEvseMeterTemplate && idleMeterConnectorId != null) continue
+ // A transaction whose Ended delivery is in flight already reports as
+ // an idle meter point; it must not emit another Updated event.
+ // Only accepted Started transactions get TransactionEvent(Updated).
+ // A pending remote start remains idle until then and reports through
+ // the non-transactional MeterValues path.
+ if (connectorStatus.transactionRestored === true) continue
+ const transactionId =
+ canAdvanceAlignedEnergy(connectorStatus) && connectorStatus.transactionId != null
+ ? connectorStatus.transactionId
+ : undefined
+ if (evseInTransaction && transactionId == null && usesEvseMeterTemplate) continue
+ const transactionMeterValueDelivery =
+ transactionId != null && !suppressEvseEmission
+ ? TransactionMeterValueDeliveryBarrier.begin(connectorStatus, transactionId)
+ : undefined
+ if (
+ transactionId != null &&
+ !suppressEvseEmission &&
+ transactionMeterValueDelivery == null
+ ) {
+ throw new BaseError(
+ `Cannot reserve clock-aligned TransactionEvent delivery for transaction ${transactionId.toString()}`
+ )
+ }
+ if (
+ transactionId != null &&
+ (connectorStatus.transactionId?.toString() !== transactionId.toString() ||
+ !canAdvanceAlignedEnergy(connectorStatus))
+ ) {
+ transactionMeterValueDelivery?.settle(true)
+ continue
+ }
+ let evseIntervalBaselineConsumed = false
+ let stationIntervalBaselineConsumed = false
+ try {
+ let evseAlignedPhysicalIntervalEnergyWh: number | undefined
+ let sharedObservationEnergyWh: number | undefined
+ let stationAlignedPhysicalIntervalEnergyWh: number | undefined
+ if (
+ evseId !== 0 &&
+ transactionId == null &&
+ canSendNonTransactional &&
+ alignedIntervalEnergySamples
+ ) {
+ if (usesEvseMeterTemplate) {
+ const sharedEvseEnergy = connectors.reduce(
+ (total, [, physicalConnectorStatus]) =>
+ total + Math.max(0, physicalConnectorStatus.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ const evseIntervalBaseline = hasValidEvseIntervalBaseline
+ ? evseIntervalBaselineBeforeBuild
+ : sharedEvseEnergy
+ evseAlignedPhysicalIntervalEnergyWh = Math.max(
+ 0,
+ sharedEvseEnergy - evseIntervalBaseline
+ )
+ evseStatus.energyActiveImportIntervalBaseline = sharedEvseEnergy
+ } else {
+ evseAlignedPhysicalIntervalEnergyWh = consumeConnectorEnergySinceBaseline(
+ connectorStatus,
+ evseIntervalBaselineKey,
+ stationIntervalInitialBaselines.get(connectorId) ?? 0
+ )
+ }
+ evseIntervalBaselineConsumed = true
+ }
+ const stationBaseline =
+ evseId === 0
+ ? aggregateClockAlignedSamples(
+ physicalMeterValues,
+ chargingStation.getNumberOfPhases()
+ )
+ .filter(
+ sampledValue =>
+ sampledValue.measurand == null ||
+ GRID_METER_INAPPLICABLE_MEASURANDS[sampledValue.measurand] !== true
+ )
+ .sort(
+ (left, right) =>
+ Number(right.location === OCPP20LocationEnumType.Inlet) -
+ Number(left.location === OCPP20LocationEnumType.Inlet)
+ )
+ : undefined
+ const sharedRegisterBeforeBuild = evseEnergyActiveImportRegisterValue
+ const transactionIntervalCarryBeforeBuild =
+ Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ (connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[
+ alignedIntervalBaselineKey
+ ] ?? 0)
+ ) +
+ (connectorStatus.transactionEnergyActiveImportIntervalCarry?.[
+ alignedIntervalBaselineKey
+ ] ?? 0)
+ const transactionRegisterBeforeBuild =
+ connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ let meterValue = buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ connectorId,
+ deferEnergyInterval: suppressEvseEmission,
+ ...(evseAlignedPhysicalIntervalEnergyWh != null && {
+ energyIntervalWhOverride: evseAlignedPhysicalIntervalEnergyWh,
+ }),
+ suppressSigning: suppressEvseEmission,
+ ...(evseId !== 0 &&
+ (alignedEnergySamples ||
+ (transactionId != null &&
+ chargingStation.getCoherentSession(transactionId) != null)) &&
+ canAdvanceAlignedEnergy(connectorStatus) &&
+ transactionId != null && {
+ advanceEnergy:
+ sharedEvseEnergyOwnerConnectorId == null ||
+ connectorId === sharedEvseEnergyOwnerConnectorId,
+ ...(connectorId === sharedEvseEnergyOwnerConnectorId && {
+ energyElapsedInterval: sharedEnergyInterval,
+ }),
+ ...(transactionEnergyNominalInterval != null && {
+ energyNominalInterval: transactionEnergyNominalInterval,
+ }),
+ }),
+ ...(evseId === 0 && {
+ idle: !chargingStation
+ .iterateConnectors(true)
+ .some(({ connectorStatus }) => hasOngoingTransaction(connectorStatus)),
+ sampledValueBaseline: stationBaseline,
+ }),
+ ...(usesEvseMeterTemplate &&
+ (!evseInTransaction ||
+ chargingStation.stationInfo?.meteringPerTransaction !== true) && {
+ energyRegisterWhOverride: evseEnergyActiveImportRegisterValue,
+ }),
+ evseId,
+ timestamp,
+ ...(transactionId != null && { transactionId }),
+ },
+ alignedInterval,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ if (
+ usesEvseMeterTemplate &&
+ connectorId === sharedEvseEnergyOwnerConnectorId &&
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt?.getTime() ===
+ timestamp.getTime()
+ ) {
+ const currentSharedRegister = [...evseStatus.connectors.values()].reduce(
+ (total, status) => total + Math.max(0, status.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ sharedObservationEnergyWh = Math.max(
+ 0,
+ currentSharedRegister - (sharedRegisterBeforeBuild ?? 0)
+ )
+ if (sharedObservationEnergyWh > 0) {
+ evseStatus.energyActiveImportRegisterValue =
+ Math.max(0, evseStatus.energyActiveImportRegisterValue ?? 0) +
+ sharedObservationEnergyWh
+ }
+ const sharedObservationInterval = sharedEnergyInterval
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = timestamp
+ for (const [otherConnectorId, otherConnectorStatus] of connectors) {
+ if (
+ otherConnectorId === connectorId ||
+ !canAdvanceAlignedEnergy(otherConnectorStatus) ||
+ otherConnectorStatus.transactionId == null ||
+ chargingStation.getCoherentSession(otherConnectorStatus.transactionId) != null ||
+ (otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt != null &&
+ otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt >=
+ timestamp)
+ ) {
+ continue
+ }
+ const peerObservationEnergyWh = prorateSharedObservationEnergy(
+ sharedObservationEnergyWh,
+ sharedObservationInterval,
+ otherConnectorStatus,
+ timestamp,
+ transactionEnergyNominalInterval ?? alignedInterval
+ )
+ otherConnectorStatus.transactionEnergyActiveImportRegisterValue =
+ Math.max(0, otherConnectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) +
+ peerObservationEnergyWh
+ otherConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = timestamp
+ }
+ }
+ if (alignedEnergySamples && connectorId === sharedEvseEnergyOwnerConnectorId) {
+ evseEnergyActiveImportRegisterValue = [...evseStatus.connectors.values()].reduce(
+ (total, status) => total + Math.max(0, status.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ }
+ if (stationBaseline != null) {
+ meterValue = {
+ ...meterValue,
+ sampledValue: filterUnconvertibleDcStationSamples(
+ meterValue.sampledValue,
+ stationBaseline,
+ chargingStation.stationInfo?.currentOutType
+ ),
+ }
+ }
+ if (!isNotEmptyArray(meterValue.sampledValue)) {
+ transactionMeterValueDelivery?.settle(true)
+ if (evseId === 0) {
+ restoreStationIntervalBaselines()
+ } else if (usesEvseMeterTemplate) {
+ restoreEvseAggregateIntervalBaseline()
+ } else {
+ restoreEvseIntervalBaseline(connectorStatus)
+ }
+ continue
+ }
+ const configuredEfficiency =
+ chargingStation.stationInfo?.currentOutType === CurrentType.DC
+ ? (chargingStation.stationInfo.conversionEfficiency ?? 1)
+ : 1
+ const dcEfficiency = configuredEfficiency > 0 ? configuredEfficiency : 1
+ if (useInitialActiveIntervalObservation && transactionId != null) {
+ const normalizedMeterValue = normalizePhysicalMeterValueForStationAggregation(
+ meterValue,
+ chargingStation.stationInfo?.currentOutType,
+ dcEfficiency
+ )
+ for (const sampledValue of normalizedMeterValue.sampledValue) {
+ if (
+ sampledValue.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ) {
+ continue
+ }
+ const identity = JSON.stringify([
+ sampledValue.measurand,
+ sampledValue.context,
+ sampledValue.location,
+ sampledValue.phase,
+ canonicalizeCustomData(sampledValue.customData),
+ ])
+ const coherentSession = chargingStation.getCoherentSession(transactionId)
+ if (coherentSession != null) {
+ initialCoherentIntervalTotals.set(
+ identity,
+ (initialCoherentIntervalTotals.get(identity) ?? 0) + sampledValue.value
+ )
+ } else {
+ initialLegacyIntervalMaximums.set(
+ identity,
+ Math.max(initialLegacyIntervalMaximums.get(identity) ?? 0, sampledValue.value)
+ )
+ }
+ initialActiveIntervalSamples.set(identity, {
+ ...sampledValue,
+ value:
+ (initialCoherentIntervalTotals.get(identity) ?? 0) +
+ (initialLegacyIntervalMaximums.get(identity) ?? 0),
+ })
+ }
+ }
+ if (
+ canSendNonTransactional &&
+ alignedIntervalEnergySamples &&
+ meterValue.sampledValue.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ ) {
+ if (usesEvseMeterTemplate) {
+ // A persisted aggregate EVSE baseline predates connector-scoped station
+ // baselines. Use it once to seed every physical connector while the EVSE
+ // aggregate continues advancing independently.
+ if (
+ !hasConnectorStationBaselineAtStart &&
+ hasValidEvseIntervalBaseline &&
+ !stationIntervalBaselinesSeeded
+ ) {
+ const sharedEvseEnergy = connectors.reduce(
+ (total, [, physicalConnectorStatus]) =>
+ total +
+ Math.max(0, physicalConnectorStatus.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ stationAlignedPhysicalIntervalEnergyWh = Math.max(
+ 0,
+ sharedEvseEnergy - evseIntervalBaselineBeforeBuild
+ )
+ for (const [physicalConnectorId, physicalConnectorStatus] of connectors) {
+ consumeConnectorEnergySinceBaseline(
+ physicalConnectorStatus,
+ stationIntervalBaselineKey,
+ stationIntervalInitialBaselines.get(physicalConnectorId) ?? 0
+ )
+ }
+ stationIntervalBaselinesSeeded = true
+ } else if (useInitialActiveIntervalObservation) {
+ if (!stationIntervalBaselinesSeeded) {
+ for (const [physicalConnectorId, physicalConnectorStatus] of connectors) {
+ consumeConnectorEnergySinceBaseline(
+ physicalConnectorStatus,
+ stationIntervalBaselineKey,
+ stationIntervalInitialBaselines.get(physicalConnectorId) ?? 0
+ )
+ }
+ stationIntervalBaselinesSeeded = true
+ }
+ } else {
+ stationAlignedPhysicalIntervalEnergyWh = connectors.reduce(
+ (total, [physicalConnectorId, physicalConnectorStatus]) =>
+ total +
+ consumeConnectorEnergySinceBaseline(
+ physicalConnectorStatus,
+ stationIntervalBaselineKey,
+ stationIntervalInitialBaselines.get(physicalConnectorId) ?? 0
+ ),
+ 0
+ )
+ }
+ } else {
+ stationAlignedPhysicalIntervalEnergyWh = consumeConnectorEnergySinceBaseline(
+ connectorStatus,
+ stationIntervalBaselineKey,
+ stationIntervalInitialBaselines.get(connectorId) ?? 0
+ )
+ }
+ stationIntervalBaselineConsumed = true
+ }
+ if (
+ evseId !== 0 &&
+ !isNotEmptyArray(evseStatus.MeterValues) &&
+ isNotEmptyArray(connectorStatus.MeterValues)
+ ) {
+ sampledValueTemplates.push(...connectorStatus.MeterValues)
+ }
+ if (evseId !== 0) {
+ const transactionAlignedIntervalEnergyWh =
+ transactionIntervalCarryBeforeBuild +
+ Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ transactionRegisterBeforeBuild
+ )
+ const sharedIntervalSamples = (() => {
+ if (
+ stationAlignedPhysicalIntervalEnergyWh == null ||
+ (chargingStation.stationInfo?.meteringPerTransaction === true &&
+ transactionAlignedIntervalEnergyWh > 0)
+ ) {
+ return []
+ }
+ const samplesByIdentity = new Map<string, OCPP20SampledValue>()
+ for (const sample of meterValue.sampledValue) {
+ if (sample.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL) {
+ continue
+ }
+ const normalizedSample: OCPP20SampledValue = {
+ ...sample,
+ phase: undefined,
+ unitOfMeasure: {
+ multiplier: 0,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ },
+ value:
+ chargingStation.stationInfo?.currentOutType === CurrentType.DC &&
+ sample.location === OCPP20LocationEnumType.Inlet
+ ? stationAlignedPhysicalIntervalEnergyWh / dcEfficiency
+ : stationAlignedPhysicalIntervalEnergyWh,
+ }
+ const identity = JSON.stringify([
+ normalizedSample.measurand,
+ normalizedSample.context,
+ normalizedSample.location,
+ canonicalizeCustomData(normalizedSample.customData),
+ ])
+ if (!samplesByIdentity.has(identity)) {
+ samplesByIdentity.set(identity, normalizedSample)
+ }
+ }
+ return [...samplesByIdentity.values()]
+ })()
+ const stationIntervalRatio =
+ stationAlignedPhysicalIntervalEnergyWh != null &&
+ transactionAlignedIntervalEnergyWh > 0
+ ? stationAlignedPhysicalIntervalEnergyWh / transactionAlignedIntervalEnergyWh
+ : 1
+ const physicalMeterValueSource = useInitialActiveIntervalObservation
+ ? {
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.filter(
+ sample =>
+ sample.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ),
+ }
+ : sharedEvseEnergyOwnerConnectorId != null &&
+ connectorId !== sharedEvseEnergyOwnerConnectorId
+ ? {
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.filter(
+ sample =>
+ sample.measurand !== OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ),
+ }
+ : isNotEmptyArray(sharedIntervalSamples)
+ ? {
+ ...meterValue,
+ sampledValue: [
+ ...meterValue.sampledValue.filter(
+ sample =>
+ sample.measurand !==
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ),
+ ...sharedIntervalSamples,
+ ],
+ }
+ : transactionId != null
+ ? {
+ ...meterValue,
+ sampledValue: meterValue.sampledValue.map(sample =>
+ sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ? { ...sample, value: roundTo(sample.value * stationIntervalRatio, 2) }
+ : sample
+ ),
+ }
+ : meterValue
+ const physicalMeterValue = normalizePhysicalMeterValueForStationAggregation(
+ physicalMeterValueSource,
+ chargingStation.stationInfo?.currentOutType,
+ dcEfficiency
+ )
+ const stationMeterValue =
+ physicalEvseRegisterSamples != null
+ ? filterDuplicateSharedEvseSamples(
+ physicalMeterValue,
+ physicalEvseRegisterSamples,
+ transactionId != null &&
+ (chargingStation.getCoherentSession(transactionId) != null ||
+ (!isNotEmptyArray(sharedIntervalSamples) &&
+ !sharedIntervalObservationRecorded &&
+ chargingStation.stationInfo?.meteringPerTransaction === true))
+ )
+ : physicalMeterValue
+ if (isNotEmptyArray(stationMeterValue.sampledValue)) {
+ physicalMeterValues.push(stationMeterValue)
+ if (isNotEmptyArray(sharedIntervalSamples)) sharedIntervalObservationRecorded = true
+ }
+ }
+ if (transactionId != null) {
+ if (!suppressEvseEmission) {
+ pendingRequests.push({
+ evseId,
+ send: () =>
+ OCPP20ServiceUtils.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId.toString(),
+ { evseId, meterValue: [meterValue], timestamp },
+ {
+ responseTimeoutMs,
+ skipBufferingOnError: false,
+ throwError: true,
+ },
+ transactionMeterValueDelivery,
+ undefined,
+ true
+ )
+ .then(() => undefined)
+ .catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.emitClockAlignedMeterValues: Error sending clock-aligned '${OCPP20RequestCommand.TRANSACTION_EVENT}':`,
+ error
+ )
+ if (error instanceof TransactionEventQueueCapacityError) throw error
+ }),
+ })
+ }
+ } else {
+ idleMeterConnectorId ??= connectorId
+ meterValues.push(meterValue)
+ }
+ } catch (error: unknown) {
+ transactionMeterValueDelivery?.settle(true)
+ if (evseIntervalBaselineConsumed) {
+ if (usesEvseMeterTemplate) {
+ restoreEvseAggregateIntervalBaseline()
+ } else {
+ restoreEvseIntervalBaseline(connectorStatus)
+ }
+ }
+ if (stationIntervalBaselineConsumed) {
+ if (evseId === 0) {
+ restoreStationIntervalBaselines()
+ } else if (usesEvseMeterTemplate) {
+ for (const [, physicalConnectorStatus] of connectors) {
+ restoreStationIntervalBaseline(physicalConnectorStatus)
+ }
+ } else {
+ restoreStationIntervalBaseline(connectorStatus)
+ }
+ }
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.emitClockAlignedMeterValues: ${getErrorMessage(error)}`
+ )
+ }
+ }
+ if (!isEmpty(initialActiveIntervalSamples)) {
+ physicalMeterValues.push({
+ sampledValue: [...initialActiveIntervalSamples.values()],
+ timestamp,
+ })
+ }
+ if (
+ suppressEvseEmission ||
+ !canSendNonTransactional ||
+ evseInTransaction ||
+ !isNotEmptyArray(meterValues)
+ ) {
+ continue
+ }
+ let requestMeterValues = meterValues
+ if (evseId !== 0 && meterValues.length > 1 && idleMeterConnectorId != null) {
+ const energyRegisterWhOverride = [...evseStatus.connectors.values()].reduce(
+ (total, connectorStatus) =>
+ total + Math.max(0, connectorStatus.energyActiveImportRegisterValue ?? 0),
+ 0
+ )
+ requestMeterValues = [
+ buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ connectorId: idleMeterConnectorId,
+ energyRegisterWhOverride,
+ evseId,
+ idle: true,
+ sampledValueBaseline: aggregateClockAlignedSamples(
+ meterValues,
+ chargingStation.getNumberOfPhases()
+ ),
+ ...(isNotEmptyArray(sampledValueTemplates) && { sampledValueTemplates }),
+ timestamp,
+ },
+ alignedInterval,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ ),
+ ]
+ }
+ pendingRequests.push({
+ evseId,
+ send: () =>
+ OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ chargingStation,
+ evseId,
+ { evseId, meterValue: requestMeterValues },
+ responseTimeoutMs,
+ evseId === 0
+ ? [{ key: chargingStation, restore: restoreStationIntervalBaselines }]
+ : [
+ {
+ key: evseStatus,
+ restore: usesEvseMeterTemplate
+ ? restoreEvseAggregateIntervalBaseline
+ : restoreEvseIntervalBaselines,
+ },
+ ]
+ ),
+ })
+ }
+ await Promise.all(
+ pendingRequests
+ .sort(({ evseId: left }, { evseId: right }) => left - right)
+ .map(({ send }) => send())
+ )
+ }
+
/**
* Enforce ItemsPerMessage and BytesPerMessage limits on request data.
* @param chargingStation - Charging station providing log prefix
chargingStation,
OCPP20ComponentName.AlignedDataCtrlr,
OCPP20RequiredVariableName.AlignedDataInterval,
- 900
+ Constants.DEFAULT_ALIGNED_DATA_INTERVAL_SECONDS
)
}
/**
- * Retrieve the SampledDataCtrlr TxEndedInterval in milliseconds.
+ * Retrieve the OCPPCommCtrlr MessageTimeout in milliseconds.
* @param chargingStation - Target charging station
- * @returns Transaction ended meter values interval in milliseconds
+ * @returns General OCPP response timeout in milliseconds
*/
- public static getTxEndedInterval (chargingStation: ChargingStation): number {
+ public static getMessageTimeout (chargingStation: ChargingStation): number {
return OCPP20ServiceUtils.readVariableAsIntervalMs(
chargingStation,
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxEndedInterval,
- 0
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS,
+ 'Default'
)
}
- /**
- * Retrieve the SampledDataCtrlr TxUpdatedInterval in milliseconds.
- * @param chargingStation - Target charging station
- * @returns Transaction updated meter values interval in milliseconds
- */
- public static getTxUpdatedInterval (chargingStation: ChargingStation): number {
- return OCPP20ServiceUtils.readVariableAsIntervalMs(
- chargingStation,
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxUpdatedInterval,
- Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS
- )
+ public static getReplayedTransactionEventOwner (
+ request: OCPP20TransactionEventRequest
+ ): undefined | { connectorId: number; evseId: number } {
+ return OCPP20ServiceUtils.replayedTransactionEventOwners.get(request)
}
- /**
- * Read ItemsPerMessage and BytesPerMessage configuration limits
- * Extracts configuration-reading logic shared between handleRequestGetVariables
+ public static getTransactionEnergyMeteringConfiguration (chargingStation: ChargingStation):
+ | undefined
+ | {
+ context: OCPP20ReadingContextEnumType
+ interval: number
+ measurandsKey: ConfigurationKeyType
+ } {
+ const txUpdatedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+ const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
+ if (
+ txUpdatedInterval > 0 &&
+ hasConfiguredEnergyMeasurand(chargingStation, txUpdatedMeasurandsKey)
+ ) {
+ return {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ interval: txUpdatedInterval,
+ measurandsKey: txUpdatedMeasurandsKey,
+ }
+ }
+ const txEndedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const txEndedInterval = OCPP20ServiceUtils.getTxEndedInterval(chargingStation)
+ if (
+ txEndedInterval > 0 &&
+ hasConfiguredEnergyMeasurand(chargingStation, txEndedMeasurandsKey)
+ ) {
+ return {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ interval: txEndedInterval,
+ measurandsKey: txEndedMeasurandsKey,
+ }
+ }
+ const alignedInterval = getEnabledAlignedEnergyInterval(chargingStation)
+ if (alignedInterval == null) return undefined
+ return {
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ interval: alignedInterval,
+ measurandsKey: buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ ),
+ }
+ }
+
+ public static getTransactionEnergyNominalInterval (
+ chargingStation: ChargingStation,
+ fallbackInterval = 0,
+ overrides?: { txEndedInterval?: number; txUpdatedInterval?: number }
+ ): number | undefined {
+ return getTransactionEnergyNominalInterval(chargingStation, fallbackInterval, overrides)
+ }
+
+ /**
+ * Retrieve the SampledDataCtrlr TxEndedInterval in milliseconds.
+ * @param chargingStation - Target charging station
+ * @returns Transaction ended meter values interval in milliseconds
+ */
+ public static getTxEndedInterval (chargingStation: ChargingStation): number {
+ return OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedInterval,
+ 0
+ )
+ }
+
+ /**
+ * Retrieve the SampledDataCtrlr TxUpdatedInterval in milliseconds.
+ * @param chargingStation - Target charging station
+ * @returns Transaction updated meter values interval in milliseconds
+ */
+ public static getTxUpdatedInterval (chargingStation: ChargingStation): number {
+ return OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval,
+ Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS
+ )
+ }
+
+ /**
+ * Checks whether direct TransactionEvent delivery is pending for a connector.
+ * @param connectorStatus - Connector whose direct deliveries are queried
+ * @param transactionId - Optional transaction identifier to scope the query
+ * @returns Whether at least one matching direct delivery is pending
+ */
+ public static hasPendingTransactionEventDelivery (
+ connectorStatus: ConnectorStatus,
+ transactionId?: string
+ ): boolean {
+ const counts = OCPP20ServiceUtils.pendingTransactionEventDeliveryCounts.get(connectorStatus)
+ if (transactionId == null) return counts != null && !isEmpty(counts)
+ return (counts?.get(transactionId) ?? 0) > 0
+ }
+
+ /**
+ * Returns whether autonomous clock-aligned data generation is enabled.
+ * @param chargingStation - Target charging station
+ * @returns Whether clock-aligned data generation is enabled
+ */
+ public static isAlignedDataEnabled (chargingStation: ChargingStation): boolean {
+ return OCPP20ServiceUtils.readVariableAsBoolean(
+ chargingStation,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Enabled,
+ false
+ )
+ }
+
+ /**
+ * Resolves `AlignedDataCtrlr.SendDuringIdle`, optionally for one EVSE.
+ * EVSE-scoped values fall back to the station-wide value when absent.
+ * @param chargingStation - Target charging station
+ * @param evseId - Optional EVSE scope
+ * @returns The SendDuringIdle variable value
+ */
+ public static isAlignedDataSendDuringIdleEnabled (
+ chargingStation: ChargingStation,
+ evseId?: number
+ ): boolean {
+ return OCPP20ServiceUtils.readVariableAsBoolean(
+ chargingStation,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SendDuringIdle,
+ false,
+ evseId
+ )
+ }
+
+ /**
+ * Check whether the queue replay owner is currently delivering a request.
+ * @param request - TransactionEvent request handled by the response service
+ * @returns Whether queue replay owns response cleanup for this request
+ */
+ public static isReplayedTransactionEventRequest (request: OCPP20TransactionEventRequest): boolean {
+ return OCPP20ServiceUtils.replayedTransactionEventRequests.has(request)
+ }
+
+ /**
+ * Stops transaction meter timers before station shutdown while retaining
+ * enough state to re-arm them after a non-terminal restart.
+ * @param chargingStation - Target charging station
+ */
+ public static pauseTransactionMeterValues (chargingStation: ChargingStation): void {
+ if (
+ chargingStation.stationInfo?.ocppVersion !== OCPPVersion.VERSION_20 &&
+ chargingStation.stationInfo?.ocppVersion !== OCPPVersion.VERSION_201
+ ) {
+ return
+ }
+ for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors()) {
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
+ OCPP20ServiceUtils.stopEndedMeterValues(chargingStation, connectorId, evseId)
+ if (hasOngoingTransaction(connectorStatus)) connectorStatus.transactionRestored = true
+ }
+ }
+
+ public static readAlignedDataIntervalSeconds (
+ chargingStation: ChargingStation
+ ): number | undefined {
+ const value =
+ OCPP20ServiceUtils.readVariableValue(
+ chargingStation,
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.AlignedDataInterval
+ ) ?? Constants.DEFAULT_ALIGNED_DATA_INTERVAL_SECONDS.toString()
+ if (!/^[0-9]+$/.test(value)) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.readAlignedDataIntervalSeconds: Invalid integer '${value}' for AlignedDataCtrlr.Interval`
+ )
+ return
+ }
+ const intervalSeconds = Number(value)
+ if (!Number.isSafeInteger(intervalSeconds) || intervalSeconds > Constants.SECONDS_PER_DAY) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.readAlignedDataIntervalSeconds: Out-of-range value '${value}' for AlignedDataCtrlr.Interval`
+ )
+ return
+ }
+ return intervalSeconds
+ }
+
+ /**
+ * Read ItemsPerMessage and BytesPerMessage configuration limits
+ * Extracts configuration-reading logic shared between handleRequestGetVariables
* and handleRequestSetVariables to eliminate DRY violations.
* @param chargingStation - The charging station instance
* @returns Object with itemsLimit and bytesLimit (both fallback to 0 if not configured or invalid)
chargingStation: ChargingStation,
componentName: string,
variableName: string,
- defaultValue: boolean
+ defaultValue: boolean,
+ evseId?: number
): boolean {
- const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName)
+ const value = OCPP20ServiceUtils.readVariableValue(
+ chargingStation,
+ componentName,
+ variableName,
+ undefined,
+ evseId
+ )
return value != null ? convertToBoolean(value) : defaultValue
}
chargingStation: ChargingStation,
componentName: string,
variableName: string,
- defaultValue: number
+ defaultValue: number,
+ componentInstance?: string
): number {
- const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName)
+ const value = OCPP20ServiceUtils.readVariableValue(
+ chargingStation,
+ componentName,
+ variableName,
+ componentInstance
+ )
if (value != null) {
try {
return convertToInt(value)
public static readVariableValue (
chargingStation: ChargingStation,
componentName: string,
- variableName: string
+ variableName: string,
+ componentInstance?: string,
+ evseId?: number
): string | undefined {
const variableManager = OCPP20VariableManager.getInstance()
const results = variableManager.getVariables(chargingStation, [
{
- component: { name: componentName },
+ component: {
+ name: componentName,
+ ...(componentInstance != null && { instance: componentInstance }),
+ ...(evseId != null && { evse: { id: evseId } }),
+ },
variable: { name: variableName },
},
])
return undefined
}
+ /**
+ * Releases an accepted TriggerMessage reservation that will not be emitted.
+ * @param chargingStation - Target charging station.
+ * @param evseIds - EVSE identifiers whose trigger reservations are released.
+ */
+ public static releaseTriggeredMeterValuesRequests (
+ chargingStation: ChargingStation,
+ evseIds: readonly number[]
+ ): void {
+ const stationStates = OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.get(chargingStation)
+ if (stationStates == null) return
+ for (const evseId of new Set(evseIds)) {
+ const state = stationStates.get(evseId)
+ if (state == null) continue
+ delete state.triggerReserved
+ if (state.inFlight == null && state.pending == null) stationStates.delete(evseId)
+ }
+ if (isEmpty(stationStates)) {
+ OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.delete(chargingStation)
+ }
+ }
+
/**
* Deauthorize an active transaction per OCPP 2.0.1 E05 requirements.
* @param chargingStation - Target charging station
): Promise<OCPP20TransactionEventResponse> {
const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction(
chargingStation,
- connectorId
+ connectorId,
+ evseId
)
const stopTxOnInvalidId = OCPP20ServiceUtils.readVariableAsBoolean(
}
)
- return this.terminateTransaction(
+ return this.requestStopTransaction(
chargingStation,
connectorId,
- connectorStatus,
- transactionId,
+ evseId,
OCPP20TriggerReasonEnumType.Deauthorized,
OCPP20ReasonEnumType.DeAuthorized,
- evseId
+ transactionId
)
}
* @param evseId - Optional EVSE identifier
* @param triggerReason - Trigger reason for the stop event
* @param stoppedReason - Reason the transaction was stopped
+ * @param expectedTransactionId - Optional transaction identity required by the caller
* @returns Promise resolving to the TransactionEvent response
*/
- public static async requestStopTransaction (
+ public static requestStopTransaction (
chargingStation: ChargingStation,
connectorId: number,
evseId?: number,
triggerReason: OCPP20TriggerReasonEnumType = OCPP20TriggerReasonEnumType.RemoteStop,
- stoppedReason: OCPP20ReasonEnumType = OCPP20ReasonEnumType.Remote
+ stoppedReason: OCPP20ReasonEnumType = OCPP20ReasonEnumType.Remote,
+ expectedTransactionId?: string
): Promise<OCPP20TransactionEventResponse> {
- const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction(
- chargingStation,
- connectorId
- )
+ let connectorStatus: ConnectorStatus | undefined
+ try {
+ connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ } catch (error) {
+ return Promise.reject(ensureError(error))
+ }
+ const requestedTransactionId =
+ expectedTransactionId ?? connectorStatus?.transactionId?.toString()
+ const activeStopTransaction =
+ connectorStatus != null
+ ? OCPP20ServiceUtils.stopTransactionOperations.get(connectorStatus)
+ : undefined
+ if (activeStopTransaction != null) {
+ if (
+ activeStopTransaction.transactionId === requestedTransactionId ||
+ (expectedTransactionId == null &&
+ connectorStatus?.transactionEnding === true &&
+ requestedTransactionId == null)
+ ) {
+ return activeStopTransaction.promise
+ }
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.requestStopTransaction: Stop transaction ${activeStopTransaction.transactionId} is still in progress on connector ${connectorId.toString()}; cannot stop replacement transaction ${requestedTransactionId ?? 'unknown'}`,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ )
+ }
+
+ let activeTransaction: { connectorStatus: ConnectorStatus; transactionId: string }
+ try {
+ activeTransaction = OCPP20ServiceUtils.resolveActiveTransaction(
+ chargingStation,
+ connectorId,
+ evseId
+ )
+ } catch (error) {
+ return Promise.reject(ensureError(error))
+ }
+ if (
+ expectedTransactionId != null &&
+ activeTransaction.transactionId !== expectedTransactionId
+ ) {
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.requestStopTransaction: Expected transaction ${expectedTransactionId} does not match active transaction ${activeTransaction.transactionId} on connector ${connectorId.toString()}`,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ )
+ }
- return this.terminateTransaction(
+ const { promise, reject, resolve } = Promise.withResolvers<OCPP20TransactionEventResponse>()
+ const cancellationController = new AbortController()
+ const transactionEndingAtEntry = activeTransaction.connectorStatus.transactionEnding
+ const cancel = (error: OCPPError): boolean => {
+ if (
+ OCPP20ServiceUtils.stopTransactionOperations.get(activeTransaction.connectorStatus) !==
+ operation
+ ) {
+ return false
+ }
+ cancellationController.abort()
+ OCPP20ServiceUtils.stopTransactionOperations.delete(activeTransaction.connectorStatus)
+ let stillOwnsTransaction = false
+ try {
+ stillOwnsTransaction =
+ chargingStation.getConnectorStatus(connectorId, evseId) ===
+ activeTransaction.connectorStatus &&
+ activeTransaction.connectorStatus.transactionId?.toString() ===
+ activeTransaction.transactionId
+ } catch {
+ // A removed connector cannot still be owned by this operation.
+ }
+ let queueStateChanged = false
+ if (stillOwnsTransaction) {
+ const preDurableQueueRestored =
+ operation.durableEndedEvent == null &&
+ (operation.restorePreDurableEndedQueue?.() ?? false)
+ queueStateChanged = preDurableQueueRestored || queueStateChanged
+ delete operation.restorePreDurableEndedQueue
+ let hasDurableEnded = operation.durableEndedEvent != null
+ if (!preDurableQueueRestored) {
+ for (const queuedEvent of [
+ ...(activeTransaction.connectorStatus.transactionEventQueue ?? []),
+ ]) {
+ if (
+ queuedEvent.request.eventType !== OCPP20TransactionEventEnumType.Ended ||
+ queuedEvent.request.transactionInfo.transactionId !== activeTransaction.transactionId
+ ) {
+ continue
+ }
+ if (operation.durableEndedEvent === queuedEvent) {
+ setTransactionEventQueueStaged(activeTransaction.connectorStatus, queuedEvent, false)
+ hasDurableEnded = true
+ } else if (isTransactionEventQueueStaged(queuedEvent)) {
+ queueStateChanged =
+ removeBoundedTransactionEvent(activeTransaction.connectorStatus, queuedEvent) ||
+ queueStateChanged
+ }
+ }
+ }
+ if (!hasDurableEnded) {
+ if (operation.restoreRetrySensitiveState == null) {
+ activeTransaction.connectorStatus.transactionEnding = transactionEndingAtEntry
+ } else {
+ operation.restoreRetrySensitiveState()
+ }
+ activeTransaction.connectorStatus.transactionRestored = true
+ queueStateChanged = true
+ }
+ }
+ reject(error)
+ return queueStateChanged
+ }
+ const operation: StopTransactionOperation = {
+ cancel,
+ cancellationController,
+ promise,
+ transactionId: activeTransaction.transactionId,
+ }
+ OCPP20ServiceUtils.stopTransactionOperations.set(activeTransaction.connectorStatus, operation)
+ let stationOperations =
+ OCPP20ServiceUtils.stopTransactionOperationsByStation.get(chargingStation)
+ if (stationOperations == null) {
+ stationOperations = new Set<StopTransactionOperation>()
+ OCPP20ServiceUtils.stopTransactionOperationsByStation.set(chargingStation, stationOperations)
+ }
+ stationOperations.add(operation)
+ OCPP20ServiceUtils.terminateTransaction(
chargingStation,
connectorId,
- connectorStatus,
- transactionId,
+ activeTransaction.connectorStatus,
+ activeTransaction.transactionId,
triggerReason,
stoppedReason,
- evseId
+ evseId,
+ operation
)
+ .then(resolve)
+ .catch((error: unknown) => {
+ reject(ensureError(error))
+ })
+ promise
+ .finally(() => {
+ if (
+ OCPP20ServiceUtils.stopTransactionOperations.get(activeTransaction.connectorStatus) ===
+ operation
+ ) {
+ OCPP20ServiceUtils.stopTransactionOperations.delete(activeTransaction.connectorStatus)
+ }
+ stationOperations.delete(operation)
+ if (isEmpty(stationOperations)) {
+ OCPP20ServiceUtils.stopTransactionOperationsByStation.delete(chargingStation)
+ }
+ })
+ .catch(() => undefined)
+ return promise
+ }
+
+ /**
+ * Atomically reserves one triggered MeterValues slot for every target EVSE.
+ * Existing unsigned in-flight delivery may continue, but any exact in-flight
+ * delivery, pending request, or prior trigger reservation rejects the complete batch.
+ * @param chargingStation - Target charging station
+ * @param evseIds - Complete TriggerMessage target set
+ * @returns Whether every target EVSE was reserved
+ */
+ public static reserveTriggeredMeterValuesRequests (
+ chargingStation: ChargingStation,
+ evseIds: readonly number[]
+ ): boolean {
+ const targetEvseIds = [...new Set(evseIds)]
+ let stationStates = OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.get(chargingStation)
+ if (
+ stationStates != null &&
+ targetEvseIds.some(evseId => {
+ const state = stationStates?.get(evseId)
+ return (
+ state?.inFlightRequiresExactDelivery === true ||
+ state?.pending != null ||
+ state?.triggerReserved === true
+ )
+ })
+ ) {
+ return false
+ }
+ stationStates ??= new Map<number, ClockAlignedMeterValuesSendState>()
+ OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.set(chargingStation, stationStates)
+ for (const evseId of targetEvseIds) {
+ const state = stationStates.get(evseId) ?? {}
+ state.triggerReserved = true
+ stationStates.set(evseId, state)
+ }
+ return true
+ }
+
+ /**
+ * Reserves an exact transaction-delivery position before accepting a TriggerMessage request.
+ * Later transaction work waits behind the reservation until it is run or released.
+ * @param chargingStation - Target charging station.
+ * @param connectorId - Connector owning the transaction.
+ * @param evseId - EVSE owning the connector.
+ * @param transactionId - Exact transaction identity being reserved.
+ * @returns A single-use reservation, or undefined when the target is no longer available.
+ */
+ public static reserveTriggeredTransactionEventDelivery (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number,
+ transactionId: string
+ ): TriggeredTransactionEventDeliveryReservation | undefined {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ connectorStatus?.transactionStarted !== true ||
+ connectorStatus.transactionPending === true ||
+ connectorStatus.transactionRestored === true ||
+ isTransactionEnding(connectorStatus) ||
+ connectorStatus.transactionId?.toString() !== transactionId
+ ) {
+ return
+ }
+ let transactionIds =
+ OCPP20ServiceUtils.triggeredTransactionEventReservations.get(connectorStatus)
+ if (transactionIds?.has(transactionId) === true) return
+ transactionIds ??= new Set<string>()
+ transactionIds.add(transactionId)
+ OCPP20ServiceUtils.triggeredTransactionEventReservations.set(connectorStatus, transactionIds)
+
+ const previous = OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus)
+ const gate = Promise.withResolvers<undefined>()
+ const reservationBarrier = (async (): Promise<void> => {
+ if (previous != null) await previous.catch(() => undefined)
+ await gate.promise
+ })()
+ OCPP20ServiceUtils.transactionEventSendChains.set(connectorStatus, reservationBarrier)
+ reservationBarrier
+ .then(() => {
+ if (
+ OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus) === reservationBarrier
+ ) {
+ OCPP20ServiceUtils.transactionEventSendChains.delete(connectorStatus)
+ }
+ return undefined
+ })
+ .catch(() => undefined)
+
+ let claimed = false
+ let released = false
+ const release = (): void => {
+ if (released) return
+ released = true
+ transactionIds.delete(transactionId)
+ if (isEmpty(transactionIds)) {
+ OCPP20ServiceUtils.triggeredTransactionEventReservations.delete(connectorStatus)
+ }
+ gate.resolve(undefined)
+ }
+ return {
+ release: () => {
+ if (!claimed) release()
+ },
+ run: async operation => {
+ if (claimed || released) return
+ claimed = true
+ try {
+ if (previous != null) await previous.catch(() => undefined)
+ const currentStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ currentStatus !== connectorStatus ||
+ currentStatus.transactionStarted !== true ||
+ currentStatus.transactionPending === true ||
+ currentStatus.transactionRestored === true ||
+ hasQueuedEndedEvent(currentStatus) ||
+ currentStatus.transactionId?.toString() !== transactionId
+ ) {
+ return
+ }
+ await operation()
+ } finally {
+ release()
+ }
+ },
+ }
}
/**
* This also resets the EVSE and IdToken sent flags per E01.FR.16 and E03.FR.01.
* @param chargingStation - The charging station instance
* @param connectorId - The connector ID for which to reset the transaction state
+ * @param evseId - EVSE containing the connector when connector ids are EVSE-local
*/
public static resetTransactionSequenceNumber (
chargingStation: ChargingStation,
- connectorId: number
+ connectorId: number,
+ evseId?: number
): void {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (connectorStatus != null) {
connectorStatus.transactionSeqNo = undefined // Reset to undefined, will be set to 0 on first use
connectorStatus.transactionEvseSent = undefined // E01.FR.16: EVSE must be sent in first event of new transaction
}
/**
- * Send queued TransactionEvent requests accumulated while offline.
+ * Re-arms transaction meter timers restored from persistent connector state.
+ * The accounting baseline starts when the running process resumes, so process
+ * downtime is not simulated as delivered energy.
* @param chargingStation - Target charging station
- * @param connectorId - Connector identifier whose queue to drain
*/
- public static async sendQueuedTransactionEvents (
- chargingStation: ChargingStation,
- connectorId: number
- ): Promise<void> {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (
- connectorStatus?.transactionEventQueue == null ||
- !isNotEmptyArray(connectorStatus.transactionEventQueue)
- ) {
- return
- }
-
- const queueLength = connectorStatus.transactionEventQueue.length
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Sending ${queueLength.toString()} queued TransactionEvents for connector ${connectorId.toString()}`
- )
-
- const queue = [...connectorStatus.transactionEventQueue]
- connectorStatus.transactionEventQueue = []
-
- for (const queuedEvent of queue) {
- try {
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Sending queued event with seqNo=${queuedEvent.seqNo.toString()}`
- )
- await chargingStation.ocppRequestService.requestHandler<
- OCPP20TransactionEventRequest,
- OCPP20TransactionEventResponse
- >(chargingStation, OCPP20RequestCommand.TRANSACTION_EVENT, queuedEvent.request, {
- rawPayload: true,
- })
- if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
- await OCPP20ServiceUtils.cleanupEndedTransaction(
- chargingStation,
- connectorId,
- connectorStatus
+ public static resumeRestoredTransactionMeterValues (chargingStation: ChargingStation): void {
+ const resumedAt = new Date()
+ for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors()) {
+ if (
+ connectorStatus.transactionRestored !== true ||
+ connectorStatus.transactionStarted !== true ||
+ !hasOngoingTransaction(connectorStatus)
+ ) {
+ continue
+ }
+ const transactionId = connectorStatus.transactionId
+ if (transactionId == null) continue
+ const persistedEnergy = connectorStatus.transactionEnergyActiveImportRegisterValue
+ const persistedEnergyWh =
+ typeof persistedEnergy === 'number' &&
+ Number.isFinite(persistedEnergy) &&
+ persistedEnergy >= 0
+ ? persistedEnergy
+ : 0
+ connectorStatus.transactionEnergyActiveImportRegisterValue = persistedEnergyWh
+ const restoredSession =
+ chargingStation.getCoherentSession(transactionId) ??
+ chargingStation.createCoherentSession(transactionId, connectorId, evseId)
+ if (restoredSession != null) {
+ restoredSession.socPercent = Math.min(
+ Constants.SOC_MAXIMUM_PERCENT,
+ Math.max(
+ 0,
+ restoredSession.socPercent +
+ (persistedEnergyWh / restoredSession.profile.batteryCapacityWh) *
+ Constants.SOC_MAXIMUM_PERCENT
)
- }
- } catch (error) {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Failed to send queued TransactionEvent with seqNo=${queuedEvent.seqNo.toString()}:`,
- error
)
}
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = resumedAt
+ if (evseId != null && chargingStation.stationInfo?.meteringPerTransaction !== true) {
+ const evseStatus = chargingStation.getEvseStatus(evseId)
+ if (evseStatus != null) {
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = resumedAt
+ }
+ }
+ OCPP20ServiceUtils.startUpdatedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation),
+ evseId
+ )
+ OCPP20ServiceUtils.startEndedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxEndedInterval(chargingStation),
+ evseId
+ )
+ delete connectorStatus.transactionRestored
}
}
/**
- * Send a TransactionEvent request to the CSMS, or queue it if offline.
+ * Serializes non-transactional aligned MeterValues per EVSE. While a request
+ * is stalled, only the latest later unsigned autonomous sampling boundary is
+ * retained; additive interval energy from replaced boundaries is folded into
+ * that request. Triggered and signed requests are never coalesced.
+ * Replacement-only callers settle immediately so they do not accumulate
+ * continuations on the long-lived drain promise.
* @param chargingStation - Target charging station
- * @param eventType - Transaction event type (Started, Updated, Ended)
- * @param triggerReason - Reason that triggered the event
- * @param connectorId - Connector identifier
- * @param transactionId - Transaction identifier
+ * @param evseId - Meter point EVSE identifier
+ * @param request - Aligned MeterValues request for one sampling boundary
+ * @param responseTimeoutMs - Request response timeout in milliseconds
+ * @param restoreIntervalBaselines - Bounded per-owner baseline restorations after a failed send.
+ * @param triggerMessage - Whether the request was initiated by TriggerMessage.
+ * @param publicKeyDeliveryTokens - Signing reservations owned by this exact request.
+ * @param onSettled - Called after this request's delivery-dependent state has been resolved.
+ * @returns The drain operation for its first request, or an immediate acknowledgement when queued
+ */
+ public static sendClockAlignedMeterValuesRequest (
+ chargingStation: ChargingStation,
+ evseId: number,
+ request: OCPP20MeterValuesRequest,
+ responseTimeoutMs?: number,
+ restoreIntervalBaselines: readonly IntervalBaselineRestore[] = [],
+ triggerMessage = false,
+ publicKeyDeliveryTokens: readonly PublicKeyDeliveryToken[] = [],
+ onSettled?: (definitivelyRejected: boolean) => void
+ ): Promise<void> {
+ let stationStates = OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.get(chargingStation)
+ if (stationStates == null) {
+ stationStates = new Map<number, ClockAlignedMeterValuesSendState>()
+ OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.set(chargingStation, stationStates)
+ }
+ let state = stationStates.get(evseId)
+ if (state == null) {
+ state = {}
+ stationStates.set(evseId, state)
+ }
+ const incomingRestores = new Map(
+ restoreIntervalBaselines.map(restore => [restore.key, restore])
+ )
+ const rejectIncoming = (): Promise<void> => {
+ const rejected: PendingClockAlignedMeterValuesRequest = {
+ publicKeyDeliveryTokens: [...publicKeyDeliveryTokens],
+ request,
+ responseTimeoutMs: responseTimeoutMs ?? 0,
+ restoreIntervalBaselines: incomingRestores,
+ settleCallbacks: onSettled != null ? [onSettled] : [],
+ triggerMessage,
+ }
+ settlePendingClockAlignedRequest(chargingStation, state, rejected, true)
+ if (state.inFlight == null && state.pending == null && state.triggerReserved !== true) {
+ stationStates.delete(evseId)
+ }
+ return Promise.resolve()
+ }
+ if (triggerMessage) {
+ if (state.triggerReserved !== true) return rejectIncoming()
+ delete state.triggerReserved
+ } else if (state.triggerReserved === true) {
+ return rejectIncoming()
+ }
+
+ const incomingRequiresExactDelivery = clockAlignedRequestRequiresExactDelivery({
+ publicKeyDeliveryTokens: [...publicKeyDeliveryTokens],
+ request,
+ triggerMessage,
+ })
+ const previousPending = state.pending
+ if (
+ previousPending != null &&
+ (incomingRequiresExactDelivery || clockAlignedRequestRequiresExactDelivery(previousPending))
+ ) {
+ return rejectIncoming()
+ }
+ state.pending = {
+ publicKeyDeliveryTokens: [...publicKeyDeliveryTokens],
+ request:
+ previousPending == null
+ ? request
+ : coalesceClockAlignedMeterValuesRequests(previousPending.request, request),
+ responseTimeoutMs:
+ responseTimeoutMs ??
+ OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS,
+ 'Default'
+ ),
+ restoreIntervalBaselines: mergeIntervalBaselineRestores(
+ previousPending?.restoreIntervalBaselines ?? new Map(),
+ incomingRestores
+ ),
+ settleCallbacks: [
+ ...(previousPending?.settleCallbacks ?? []),
+ ...(onSettled != null ? [onSettled] : []),
+ ],
+ triggerMessage,
+ }
+ if (state.inFlight != null) return Promise.resolve()
+
+ const sendState = state
+ const restorePending = (failed: PendingClockAlignedMeterValuesRequest): void => {
+ const later = sendState.pending
+ if (later == null) {
+ sendState.pending = failed
+ return
+ }
+ if (clockAlignedRequestRequiresExactDelivery(failed)) {
+ settlePendingClockAlignedRequest(chargingStation, sendState, later, true)
+ sendState.pending = failed
+ return
+ }
+ if (clockAlignedRequestRequiresExactDelivery(later)) {
+ settlePendingClockAlignedRequest(chargingStation, sendState, failed, true)
+ return
+ }
+ sendState.pending = {
+ publicKeyDeliveryTokens: later.publicKeyDeliveryTokens,
+ request: coalesceClockAlignedMeterValuesRequests(failed.request, later.request),
+ responseTimeoutMs: later.responseTimeoutMs,
+ restoreIntervalBaselines: mergeIntervalBaselineRestores(
+ failed.restoreIntervalBaselines,
+ later.restoreIntervalBaselines
+ ),
+ settleCallbacks: [...failed.settleCallbacks, ...later.settleCallbacks],
+ triggerMessage: later.triggerMessage,
+ }
+ }
+ const drain = async (): Promise<void> => {
+ while (sendState.pending != null) {
+ const pending = sendState.pending
+ delete sendState.pending
+ const registrationAllowsSend =
+ chargingStation.inAcceptedState() ||
+ (pending.triggerMessage === true && chargingStation.inPendingState())
+ if (
+ !chargingStation.isWebSocketConnectionOpened() ||
+ !registrationAllowsSend ||
+ OCPP20ServiceUtils.isChargingStationStopping(chargingStation)
+ ) {
+ settlePendingClockAlignedRequest(chargingStation, sendState, pending, true)
+ break
+ }
+ sendState.inFlightRequiresExactDelivery = clockAlignedRequestRequiresExactDelivery(pending)
+ const responseState = {
+ confirmedRejected: false,
+ received: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ transportErrorClassified: false,
+ }
+ const lifecycleAbortSignal = chargingStation.lifecycleAbortSignal
+ try {
+ await chargingStation.ocppRequestService.requestHandler<
+ OCPP20MeterValuesRequest,
+ OCPP20MeterValuesResponse
+ >(chargingStation, OCPP20RequestCommand.METER_VALUES, pending.request, {
+ onError: (_error, isCallError) => {
+ if (isCallError) responseState.confirmedRejected = true
+ },
+ onMessageSent: () => {
+ responseState.sent = true
+ },
+ onResponseReceived: () => {
+ responseState.received = true
+ },
+ onTransportError: (_error, deliveryAmbiguous) => {
+ responseState.transportErrorAmbiguous ||= deliveryAmbiguous
+ responseState.transportErrorClassified = true
+ },
+ responseTimeoutMs: pending.responseTimeoutMs,
+ skipBufferingOnError: true,
+ throwError: true,
+ triggerMessage: pending.triggerMessage,
+ })
+ settlePendingClockAlignedRequest(chargingStation, sendState, pending, false)
+ } catch (error: unknown) {
+ const hasLaterPending = stationStates.get(evseId)?.pending != null
+ const unclassifiedCancellation =
+ !responseState.transportErrorClassified &&
+ (lifecycleAbortSignal.aborted ||
+ !chargingStation.isWebSocketConnectionOpened() ||
+ OCPP20ServiceUtils.isChargingStationStopping(chargingStation)) &&
+ !responseState.confirmedRejected
+ const definitelyNotDelivered =
+ !responseState.received &&
+ !unclassifiedCancellation &&
+ (responseState.confirmedRejected ||
+ (!responseState.sent && !responseState.transportErrorAmbiguous))
+ if (definitelyNotDelivered) {
+ if (hasLaterPending) {
+ restorePending(pending)
+ } else {
+ settlePendingClockAlignedRequest(chargingStation, sendState, pending, true)
+ }
+ } else {
+ settlePendingClockAlignedRequest(chargingStation, sendState, pending, false)
+ }
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendClockAlignedMeterValuesRequest: Error sending clock-aligned '${OCPP20RequestCommand.METER_VALUES}':`,
+ error
+ )
+ if (hasLaterPending) continue
+ break
+ } finally {
+ delete sendState.inFlightRequiresExactDelivery
+ }
+ }
+ }
+ const startDrain = async (): Promise<void> => {
+ const ownedDrain = drain()
+ sendState.inFlight = ownedDrain
+ try {
+ await ownedDrain
+ } finally {
+ if (sendState.inFlight === ownedDrain) {
+ delete sendState.inFlightRequiresExactDelivery
+ if (sendState.pending == null) {
+ delete sendState.inFlight
+ if (sendState.triggerReserved !== true) stationStates.delete(evseId)
+ } else {
+ await startDrain()
+ }
+ }
+ if (isEmpty(stationStates)) {
+ OCPP20ServiceUtils.clockAlignedMeterValuesSendStates.delete(chargingStation)
+ }
+ }
+ }
+ return startDrain()
+ }
+
+ /**
+ * Send the queued TransactionEvent generation present after acquiring the
+ * connector delivery lock. Events appended during replay remain queued for a
+ * later generation so reconnect resumption cannot be starved by producers.
+ * @param chargingStation - Target charging station
+ * @param connectorId - Connector identifier whose queue to drain
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
+ */
+ public static async sendQueuedTransactionEvents (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId?: number
+ ): Promise<void> {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (connectorStatus == null) return
+ const lifecycleAbortSignal = chargingStation.lifecycleAbortSignal
+ await OCPP20ServiceUtils.serializeTransactionEventDelivery(connectorStatus, () => {
+ const eligibleEvents = new Set(connectorStatus.transactionEventQueue ?? [])
+ return OCPP20ServiceUtils.drainQueuedTransactionEvents(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ lifecycleAbortSignal,
+ evseId,
+ eligibleEvents
+ )
+ })
+ }
+
+ /**
+ * Send a TransactionEvent request to the CSMS, or queue it if offline.
+ * @param chargingStation - Target charging station
+ * @param eventType - Transaction event type (Started, Updated, Ended)
+ * @param triggerReason - Reason that triggered the event
+ * @param connectorId - Connector identifier
+ * @param transactionId - Transaction identifier
* @param options - Additional transaction event options
+ * @param requestParams - Optional transport behavior overrides
+ * @param transactionMeterValueDelivery - Optional transaction MeterValues serialization owner
+ * @param startedOwnership - Provisional state required before a Started delivery
+ * @param queueBeforeDelivery - Whether to durably admit this event before attempting transport
* @returns Promise resolving to the TransactionEvent response
*/
- public static async sendTransactionEvent (
+ public static sendTransactionEvent (
chargingStation: ChargingStation,
eventType: OCPP20TransactionEventEnumType,
triggerReason: OCPP20TriggerReasonEnumType,
connectorId: number,
transactionId: string,
- options: Omit<OCPP20TransactionEventOptions, 'eventType'> = {}
+ options: Omit<OCPP20TransactionEventOptions, 'eventType'> = {},
+ requestParams?: RequestParams,
+ transactionMeterValueDelivery?: TransactionMeterValueDelivery,
+ startedOwnership?: 'pending' | 'starting',
+ queueBeforeDelivery = false
): Promise<OCPP20TransactionEventResponse> {
- try {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus == null) {
- const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}`
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: ${errorMsg}`
- )
- throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
- }
-
- // Offline: build and queue pre-built payload (sent as-is via rawPayload on reconnect)
- if (!chargingStation.isWebSocketConnectionOpened()) {
- // E04.FR.03: offline flag SHALL be TRUE for any TransactionEventRequest that occurred while offline
- const transactionEventRequest = buildTransactionEvent(chargingStation, {
- connectorId,
- eventType,
- transactionId,
- triggerReason,
- ...options,
- offline: true,
- })
- logger.info(
- `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Station offline, queueing TransactionEvent with seqNo=${transactionEventRequest.seqNo.toString()}`
- )
- connectorStatus.transactionEventQueue ??= []
- connectorStatus.transactionEventQueue.push({
- request: transactionEventRequest,
- seqNo: transactionEventRequest.seqNo,
- timestamp: new Date(),
- })
- return { idTokenInfo: undefined }
- }
-
- // Online: minimal params → requestHandler → buildRequestPayload
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Sending TransactionEvent for trigger ${triggerReason}`
- )
-
- const response = await chargingStation.ocppRequestService.requestHandler<
- OCPP20TransactionEventOptions,
- OCPP20TransactionEventResponse
- >(chargingStation, OCPP20RequestCommand.TRANSACTION_EVENT, {
- connectorId,
- eventType,
- transactionId,
- triggerReason,
- ...options,
- })
-
- return response
- } catch (error) {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Failed to send TransactionEvent:`,
- error
- )
- throw error
- }
+ return OCPP20ServiceUtils.sendTransactionEventWithPredecessors(
+ chargingStation,
+ eventType,
+ triggerReason,
+ connectorId,
+ transactionId,
+ options,
+ requestParams,
+ undefined,
+ transactionMeterValueDelivery,
+ undefined,
+ undefined,
+ startedOwnership,
+ queueBeforeDelivery
+ )
}
/**
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier
* @param interval - Collection interval in milliseconds
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
*/
public static startEndedMeterValues (
chargingStation: ChargingStation,
connectorId: number,
- interval: number
+ interval: number,
+ evseId?: number
): void {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (connectorStatus == null) {
return
}
- connectorStatus.transactionEndedMeterValues = []
+ connectorStatus.transactionEndedMeterValues ??= []
if (interval <= 0) {
return
}
if (connectorStatus.transactionEndedMeterValuesSetInterval != null) {
- OCPP20ServiceUtils.stopEndedMeterValues(chargingStation, connectorId)
+ OCPP20ServiceUtils.stopEndedMeterValues(chargingStation, connectorId, evseId)
}
+ let lastSampleAt = Date.now()
+ let elapsedIntervalCarry = 0
connectorStatus.transactionEndedMeterValuesSetInterval = setInterval(() => {
- const cs = chargingStation.getConnectorStatus(connectorId)
- if (cs?.transactionStarted === true && cs.transactionId != null) {
- const measurandsKey = buildConfigKey(
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxEndedMeasurands
+ ;(async () => {
+ const activeConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ activeConnectorStatus?.transactionStarted !== true ||
+ activeConnectorStatus.transactionEnding === true ||
+ activeConnectorStatus.transactionId == null
+ ) {
+ return
+ }
+ elapsedIntervalCarry += interval
+ const transactionId = activeConnectorStatus.transactionId.toString()
+ const delivery = TransactionMeterValueDeliveryBarrier.beginIfIdle(
+ activeConnectorStatus,
+ transactionId
)
- const meterValue = buildMeterValue(
- chargingStation,
- cs.transactionId,
- interval,
- measurandsKey
- ) as OCPP20MeterValue
- if (isNotEmptyArray(meterValue.sampledValue)) {
- cs.transactionEndedMeterValues?.push(meterValue)
+ if (delivery == null) return
+ const deliveryTurn = delivery.waitForTurn()
+ if (deliveryTurn != null) await deliveryTurn
+ const currentConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ currentConnectorStatus !== activeConnectorStatus ||
+ currentConnectorStatus.transactionStarted !== true ||
+ currentConnectorStatus.transactionEnding === true ||
+ currentConnectorStatus.transactionId?.toString() !== transactionId
+ ) {
+ delivery.settle(true)
+ return
}
- }
+ try {
+ const sampleAt = Date.now()
+ const elapsedInterval = Math.max(elapsedIntervalCarry, sampleAt - lastSampleAt)
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ chargingStation,
+ connectorId,
+ evseId,
+ transactionId,
+ elapsedInterval,
+ measurandsKey
+ )
+ if (isNotEmptyArray(meterValue.sampledValue)) {
+ appendBoundedCollectedEndedMeterValue(currentConnectorStatus, meterValue)
+ }
+ lastSampleAt = sampleAt
+ elapsedIntervalCarry = 0
+ delivery.settle()
+ } catch (error) {
+ delivery.settle(true)
+ throw error
+ }
+ })().catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.startEndedMeterValues: Error collecting terminal MeterValues:`,
+ error
+ )
+ })
}, clampToSafeTimerValue(interval))
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.startEndedMeterValues: TxEndedInterval started every ${formatDurationMilliSeconds(interval)}`
idTag?: string
): Promise<StartTransactionResult> {
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ if (connectorStatus?.transactionPending === true && connectorStatus.remoteStartId != null) {
+ return { accepted: false }
+ }
let transactionId = connectorStatus?.transactionId as string | undefined
if (transactionId == null) {
transactionId = generateUUID()
connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue
}
let response
+ if (connectorStatus != null) connectorStatus.transactionStarting = true
try {
response = await OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
idToken:
idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 } : undefined,
...(isNotEmptyArray(startedMeterValues) && { meterValue: startedMeterValues }),
- }
+ },
+ undefined,
+ undefined,
+ 'starting'
)
} catch (error) {
- // Symmetric with OCPP 1.6 `resetConnectorOnStartTransactionError`: if
- // the Started TransactionEvent fails to send, the coherent session
- // (created at line 898 for the physics-consistent begin MeterValue)
- // must not leak. `destroyCoherentSession` is idempotent.
- chargingStation.destroyCoherentSession(transactionId)
+ if (error instanceof TransactionEventDeliveryError && error.outcome === 'ownership-lost') {
+ return { accepted: false }
+ }
+ const durableStarted = connectorStatus?.transactionEventQueue?.find(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Started &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ )
+ if (connectorStatus != null && durableStarted != null) {
+ OCPP20ServiceUtils.restoreQueuedStartedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ durableStarted,
+ transactionId,
+ chargingStation.getEvseIdByConnectorId(connectorId)
+ )
+ } else {
+ // A failed Started delivery without a durable replay record must not
+ // leak identity or sequence state into the next transaction attempt.
+ resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(transactionId)
+ }
throw error
+ } finally {
+ if (connectorStatus?.transactionId?.toString() === transactionId) {
+ connectorStatus.transactionStarting = false
+ }
}
- return {
- accepted:
- response.idTokenInfo == null ||
- response.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted,
+ const accepted =
+ response.idTokenInfo == null ||
+ response.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted
+ if (
+ accepted &&
+ chargingStation.started &&
+ !OCPP20ServiceUtils.isChargingStationStopping(chargingStation) &&
+ connectorStatus != null &&
+ connectorStatus.transactionStarted !== true
+ ) {
+ const evseId = chargingStation.getEvseIdByConnectorId(connectorId)
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionIdTag ??= idTag
+ connectorStatus.transactionStart ??= new Date()
+ connectorStatus.transactionEnergyActiveImportRegisterValue ??= 0
+ connectorStatus.locked = true
+ connectorStatus.status = OCPP20ConnectorStatusEnumType.Occupied
+ OCPP20ServiceUtils.startUpdatedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation),
+ evseId
+ )
+ OCPP20ServiceUtils.startEndedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxEndedInterval(chargingStation),
+ evseId
+ )
}
+ return { accepted }
}
/**
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier
* @param interval - Sending interval in milliseconds
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
*/
public static startUpdatedMeterValues (
chargingStation: ChargingStation,
connectorId: number,
- interval: number
+ interval: number,
+ evseId?: number
): void {
- const initialConnectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const initialConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (initialConnectorStatus == null) {
logger.error(
`${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Connector ${connectorId.toString()} not found`
)
return
}
+ delete initialConnectorStatus.transactionRestored
if (initialConnectorStatus.transactionUpdatedMeterValuesSetInterval != null) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: TxUpdatedInterval already started, stopping first`
)
- OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
}
+ let lastSampleAt = Date.now()
+ let elapsedIntervalCarry = 0
initialConnectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(() => {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
+ ;(async () => {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (
+ connectorStatus?.transactionStarted !== true ||
+ connectorStatus.transactionEnding === true ||
+ connectorStatus.transactionId == null
+ ) {
+ return
+ }
if (
connectorStatus.transactionDeauthorized === true &&
connectorStatus.transactionDeauthorizedEnergyWh != null
const currentEnergy = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
const energySinceDeauth = currentEnergy - connectorStatus.transactionDeauthorizedEnergyWh
if (maxEnergy > 0 && energySinceDeauth >= maxEnergy) {
- const evseId = chargingStation.getEvseIdByConnectorId(connectorId)
- OCPP20ServiceUtils.terminateTransaction(
+ const resolvedEvseId = evseId ?? chargingStation.getEvseIdByConnectorId(connectorId)
+ await OCPP20ServiceUtils.requestStopTransaction(
chargingStation,
connectorId,
- connectorStatus,
- connectorStatus.transactionId.toString(),
+ resolvedEvseId,
OCPP20TriggerReasonEnumType.Deauthorized,
OCPP20ReasonEnumType.DeAuthorized,
- evseId
- ).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error terminating deauthorized transaction:`,
- error
- )
- })
+ connectorStatus.transactionId.toString()
+ )
return
}
}
- const meterValue = buildMeterValue(
- chargingStation,
- connectorStatus.transactionId,
- interval,
- buildConfigKey(
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxUpdatedMeasurands
+ elapsedIntervalCarry += interval
+ const transactionId = connectorStatus.transactionId.toString()
+ const delivery = TransactionMeterValueDeliveryBarrier.beginIfIdle(
+ connectorStatus,
+ transactionId
+ )
+ if (delivery == null) return
+ const deliveryTurn = delivery.waitForTurn()
+ if (deliveryTurn != null) await deliveryTurn
+ if (
+ chargingStation.getConnectorStatus(connectorId, evseId)?.transactionStarted !== true ||
+ Boolean(connectorStatus.transactionEnding) ||
+ connectorStatus.transactionId.toString() !== transactionId
+ ) {
+ delivery.settle(true)
+ return
+ }
+ try {
+ const sampleAt = Date.now()
+ const elapsedInterval = Math.max(elapsedIntervalCarry, sampleAt - lastSampleAt)
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ chargingStation,
+ connectorId,
+ evseId,
+ transactionId,
+ elapsedInterval,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
)
- ) as OCPP20MeterValue
- // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while
- // `TransactionEventRequest.meterValue` is `0..*`: when `TxUpdatedMeasurands`
- // yields no sampled values, omit the `meterValue` field entirely rather
- // than send an empty-wrapper schema violation.
- const eventPayload = isNotEmptyArray(meterValue.sampledValue)
- ? { meterValue: [meterValue] }
- : {}
- OCPP20ServiceUtils.sendTransactionEvent(
- chargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- connectorStatus.transactionId as string,
- eventPayload
- ).catch((error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error sending periodic TransactionEvent:`,
- error
+ lastSampleAt = sampleAt
+ elapsedIntervalCarry = 0
+ // OCPP 2.0.1 `MeterValueType.sampledValue` cardinality is `1..*`, while
+ // `TransactionEventRequest.meterValue` is `0..*`: when `TxUpdatedMeasurands`
+ // yields no sampled values, omit the `meterValue` field entirely rather
+ // than send an empty-wrapper schema violation.
+ const eventPayload = {
+ ...(isNotEmptyArray(meterValue.sampledValue) && { meterValue: [meterValue] }),
+ ...(evseId != null && { evseId }),
+ }
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ eventPayload,
+ undefined,
+ delivery
)
- })
- }
+ } catch (error) {
+ delivery.settle(true)
+ throw error
+ }
+ })().catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: Error sending periodic TransactionEvent:`,
+ error
+ )
+ })
}, clampToSafeTimerValue(interval))
logger.info(
`${chargingStation.logPrefix()} ${moduleName}.startUpdatedMeterValues: TxUpdatedInterval started every ${formatDurationMilliSeconds(interval)}`
* Stop periodic TxEnded meter value collection for a connector.
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
*/
- public static stopEndedMeterValues (chargingStation: ChargingStation, connectorId: number): void {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ public static stopEndedMeterValues (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId?: number
+ ): void {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (connectorStatus?.transactionEndedMeterValuesSetInterval != null) {
clearInterval(connectorStatus.transactionEndedMeterValuesSetInterval)
delete connectorStatus.transactionEndedMeterValuesSetInterval
* Stop periodic TransactionEvent(Updated) sending for a connector.
* @param chargingStation - Target charging station
* @param connectorId - Connector identifier
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
*/
public static stopUpdatedMeterValues (
chargingStation: ChargingStation,
- connectorId: number
+ connectorId: number,
+ evseId?: number
): void {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (connectorStatus?.transactionUpdatedMeterValuesSetInterval != null) {
clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval)
delete connectorStatus.transactionUpdatedMeterValuesSetInterval
}
}
+ /**
+ * Waits until all transaction-event work currently serialized for a connector has settled.
+ * New work chained while waiting is included before this method resolves.
+ * @param connectorStatus - Connector whose delivery chain must settle
+ */
+ public static async waitForTransactionEventDelivery (
+ connectorStatus: ConnectorStatus
+ ): Promise<void> {
+ let pending = OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus)
+ while (pending != null) {
+ await pending.catch(() => undefined)
+ const next = OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus)
+ if (next === pending) return
+ pending = next
+ }
+ }
+
+ /**
+ * Waits for every registered transaction termination currently owned by this station.
+ * Operations registered while waiting are included, and rejection does not prevent a
+ * later stopAllTransactions pass from retrying the active transaction.
+ * @param chargingStation - Station whose connector terminations must settle
+ */
+ public static async waitForTransactionTerminations (
+ chargingStation: ChargingStation
+ ): Promise<void> {
+ for (;;) {
+ const pending: Promise<OCPP20TransactionEventResponse>[] = []
+ const seen = new Set<Promise<OCPP20TransactionEventResponse>>()
+ for (const { connectorStatus } of chargingStation.iterateConnectors(true)) {
+ const promise = OCPP20ServiceUtils.stopTransactionOperations.get(connectorStatus)?.promise
+ if (promise != null && !seen.has(promise)) {
+ seen.add(promise)
+ pending.push(promise)
+ }
+ }
+ if (isEmpty(pending)) return
+ await Promise.allSettled(pending)
+ }
+ }
+
private static buildTransactionEndedMeterValues (
chargingStation: ChargingStation,
connectorId: number,
- transactionId: number | string
+ transactionId: number | string,
+ evseId?: number
): OCPP20MeterValue[] {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
const endedMeterValues = (connectorStatus?.transactionEndedMeterValues ??
[]) as OCPP20MeterValue[]
const beginMeterValue = connectorStatus?.transactionBeginMeterValue as
OCPP20MeterValue | undefined
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const alignedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ const historicalBaselineWh =
+ connectorStatus?.transactionEnergyActiveImportIntervalBaselines?.[measurandsKey]
+ const historicalCarry =
+ connectorStatus?.transactionEnergyActiveImportIntervalCarry?.[measurandsKey]
+ const historicalCarryWh =
+ typeof historicalCarry === 'number' &&
+ Number.isFinite(historicalCarry) &&
+ historicalCarry >= 0
+ ? historicalCarry
+ : 0
+ const historicalRepresentedEnergyWh = OCPP20ServiceUtils.getRepresentedEndedIntervalEnergyWh(
+ chargingStation,
+ endedMeterValues,
+ evseId
+ )
+ const representedIntervals: EnergyInterval[] = []
+ let unmappedRepresentedEnergyWh = 0
+ if (
+ typeof historicalBaselineWh === 'number' &&
+ Number.isFinite(historicalBaselineWh) &&
+ historicalRepresentedEnergyWh > 0
+ ) {
+ const historicalRepresentedIntervalEndWh = historicalBaselineWh - historicalCarryWh
+ representedIntervals.push({
+ end: historicalRepresentedIntervalEndWh,
+ start: historicalRepresentedIntervalEndWh - historicalRepresentedEnergyWh,
+ })
+ } else {
+ unmappedRepresentedEnergyWh += historicalRepresentedEnergyWh
+ }
+ const boundaryTimestamp = new Date()
+ let finalMeterValue: OCPP20MeterValue = { sampledValue: [], timestamp: boundaryTimestamp }
+ let alignedFinalMeterValue: OCPP20MeterValue = {
+ sampledValue: [],
+ timestamp: boundaryTimestamp,
+ }
try {
- const measurandsKey = buildConfigKey(
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20RequiredVariableName.TxEndedMeasurands
- )
- const finalMeterValue = buildMeterValue(
+ finalMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
chargingStation,
+ connectorId,
+ evseId,
transactionId,
- 0,
+ getEnabledAlignedEnergyInterval(chargingStation) ?? 0,
measurandsKey,
- OCPP20ReadingContextEnumType.TRANSACTION_END
- ) as OCPP20MeterValue
- if (isNotEmptyArray(finalMeterValue.sampledValue)) {
- return [
- ...(beginMeterValue != null ? [beginMeterValue] : []),
- ...endedMeterValues,
- finalMeterValue,
- ]
- }
+ OCPP20ReadingContextEnumType.TRANSACTION_END,
+ boundaryTimestamp
+ )
} catch (error) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEndedMeterValues: ${getErrorMessage(error)}`
)
}
- const meterValues: OCPP20MeterValue[] = [
- ...(beginMeterValue != null ? [beginMeterValue] : []),
- ...endedMeterValues,
- ]
- return isNotEmptyArray(meterValues) ? meterValues : []
- }
+ if (
+ getConfigurationKey(chargingStation, alignedMeasurandsKey)?.value != null ||
+ isEndedIntervalRecoverySigningRequired(chargingStation)
+ ) {
+ try {
+ alignedFinalMeterValue = buildClockAlignedConnectorMeterValue(
+ chargingStation,
+ {
+ advanceEnergy: false,
+ commitState: false,
+ connectorId,
+ evseId: evseId ?? connectorId,
+ timestamp: boundaryTimestamp,
+ transactionId,
+ },
+ getEnabledAlignedEnergyInterval(chargingStation) ?? 0,
+ alignedMeasurandsKey,
+ OCPP20ReadingContextEnumType.TRANSACTION_END
+ )
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEndedMeterValues: Failed to build aligned terminal evidence: ${getErrorMessage(error)}`
+ )
+ }
+ }
- private static readVariableAsIntervalMs (
- chargingStation: ChargingStation,
- componentName: string,
+ const finalRepresentedEnergyWh = OCPP20ServiceUtils.getRepresentedEndedIntervalEnergyWh(
+ chargingStation,
+ [finalMeterValue],
+ evseId
+ )
+ const finalBaselineWh =
+ connectorStatus?.transactionEnergyActiveImportIntervalBaselines?.[measurandsKey]
+ if (
+ typeof finalBaselineWh === 'number' &&
+ Number.isFinite(finalBaselineWh) &&
+ finalRepresentedEnergyWh > 0
+ ) {
+ representedIntervals.push({
+ end: finalBaselineWh,
+ start: finalBaselineWh - finalRepresentedEnergyWh,
+ })
+ } else {
+ unmappedRepresentedEnergyWh += finalRepresentedEnergyWh
+ }
+ const terminalMeterValues = [finalMeterValue, alignedFinalMeterValue].filter(meterValue =>
+ isNotEmptyArray(meterValue.sampledValue)
+ )
+ const meterValues = [
+ ...(beginMeterValue != null ? [beginMeterValue] : []),
+ ...endedMeterValues,
+ ...terminalMeterValues,
+ ]
+ const endedIntervalEnergyCoverage =
+ connectorStatus != null
+ ? buildEndedIntervalEnergyCoverage(
+ connectorStatus,
+ representedIntervals,
+ unmappedRepresentedEnergyWh
+ )
+ : undefined
+ const recoverableIntervalEnergy = endedIntervalEnergyCoverage?.recoveryEnergyWh ?? 0
+ if (recoverableIntervalEnergy > 0) {
+ if (isEndedIntervalRecoverySigningRequired(chargingStation)) {
+ if (!hasSignedTerminalImportRegister(terminalMeterValues)) {
+ throw new TransactionEventSignedEvidenceError()
+ }
+ } else {
+ finalMeterValue.sampledValue.push({
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: recoverableIntervalEnergy,
+ })
+ if (!meterValues.includes(finalMeterValue)) {
+ meterValues.push(finalMeterValue)
+ }
+ }
+ }
+ const endedRequestMeterValues = meterValues
+ if (endedIntervalEnergyCoverage != null && isNotEmptyArray(endedRequestMeterValues)) {
+ OCPP20ServiceUtils.endedIntervalEnergyCoverages.set(
+ endedRequestMeterValues,
+ endedIntervalEnergyCoverage
+ )
+ }
+ return endedRequestMeterValues
+ }
+
+ private static consumeEndedIntervalEnergyCarry (
+ connectorStatus: ConnectorStatus,
+ meterValues: readonly OCPP20MeterValue[] | undefined
+ ): void {
+ const carry = connectorStatus.transactionEnergyActiveImportIntervalCarry
+ if (carry == null || meterValues == null) return
+ const coverage = OCPP20ServiceUtils.endedIntervalEnergyCoverages.get(meterValues)
+ if (coverage == null) return
+ OCPP20ServiceUtils.endedIntervalEnergyCoverages.delete(meterValues)
+ const baselines = connectorStatus.transactionEnergyActiveImportIntervalBaselines ?? {}
+ for (const [key, value] of Object.entries(carry)) {
+ if (!Number.isFinite(value) || value <= 0) {
+ Reflect.deleteProperty(carry, key)
+ continue
+ }
+ const baseline = baselines[key]
+ const coveredEnergyWh =
+ typeof baseline === 'number' && Number.isFinite(baseline)
+ ? coverage.coveredDebtIntervals.reduce(
+ (total, coveredInterval) =>
+ total +
+ getEnergyIntervalOverlap(
+ { end: baseline, start: baseline - value },
+ coveredInterval
+ ),
+ 0
+ )
+ : coverage.legacyCoveredEnergyWh
+ const remaining = Math.max(0, value - Math.min(value, coveredEnergyWh))
+ if (remaining > 0) {
+ carry[key] = remaining
+ } else {
+ Reflect.deleteProperty(carry, key)
+ }
+ }
+ if (isEmpty(carry)) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ }
+ }
+
+ private static decrementPendingTransactionEventDelivery (
+ connectorStatus: ConnectorStatus,
+ transactionId: string
+ ): void {
+ const counts = OCPP20ServiceUtils.pendingTransactionEventDeliveryCounts.get(connectorStatus)
+ if (counts == null) return
+ const remaining = (counts.get(transactionId) ?? 0) - 1
+ if (remaining > 0) {
+ counts.set(transactionId, remaining)
+ } else {
+ counts.delete(transactionId)
+ if (isEmpty(counts)) {
+ OCPP20ServiceUtils.pendingTransactionEventDeliveryCounts.delete(connectorStatus)
+ }
+ }
+ }
+
+ private static async drainQueuedTransactionEvents (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ lifecycleAbortSignal?: AbortSignal,
+ evseId?: number,
+ eligibleEvents?: ReadonlySet<QueuedTransactionEvent>
+ ): Promise<void> {
+ const queue: QueuedTransactionEvent[] = connectorStatus.transactionEventQueue ?? []
+ if (!isNotEmptyArray<QueuedTransactionEvent>(queue)) return
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Sending ${queue.length.toString()} queued TransactionEvents for connector ${connectorId.toString()}`
+ )
+
+ const responseTimeoutMs = OCPP20ServiceUtils.readVariableAsIntervalMs(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS,
+ 'Default'
+ )
+ let queueChanged = false
+ while (isNotEmptyArray<QueuedTransactionEvent>(queue)) {
+ if (!OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)) {
+ break
+ }
+ const queuedEvent = queue[0]
+ const eventConnectorId = queuedEvent.ownerConnectorId ?? connectorId
+ const eventEvseId = queuedEvent.ownerEvseId ?? evseId
+ if (
+ isTransactionEventQueueBlocked(queuedEvent) ||
+ isTransactionEventQueueStaged(queuedEvent)
+ ) {
+ break
+ }
+ if (eligibleEvents != null && !eligibleEvents.has(queuedEvent)) break
+ let publicKeyDeliveryToken: PublicKeyDeliveryToken | undefined
+ let deliveryAttemptedByThisInvocation = false
+ const exhaustedStartedTransactionId = connectorStatus.transactionStartedExhaustedTransactionId
+ if (
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Started &&
+ exhaustedStartedTransactionId != null &&
+ exhaustedStartedTransactionId !== queuedEvent.request.transactionInfo.transactionId
+ ) {
+ OCPP20ServiceUtils.restoreNextQueuedStartedTransaction(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ exhaustedStartedTransactionId,
+ eventEvseId
+ )
+ delete connectorStatus.transactionStartedExhaustedTransactionId
+ }
+ const wasQueuedStartedEvent =
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Started
+ try {
+ if (
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.meterValuePredecessorsPending === true &&
+ queuedEvent.deliveryAttempted !== true
+ ) {
+ OCPP20ServiceUtils.rebuildQueuedEndedMeterValues(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ queuedEvent,
+ eventEvseId
+ )
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ } catch (error: unknown) {
+ throw new TransactionEventDeliveryError('write-ahead-failed', error, false, true)
+ }
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ break
+ }
+ }
+ publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ queuedEvent.request.transactionInfo.transactionId,
+ queuedEvent.request,
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ )
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Sending queued event with seqNo=${queuedEvent.seqNo.toString()}`
+ )
+ deliveryAttemptedByThisInvocation = markTransactionEventQueueDeliveryAttempted(
+ connectorStatus,
+ queuedEvent
+ )
+ if (deliveryAttemptedByThisInvocation) {
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ } catch (error: unknown) {
+ throw new TransactionEventDeliveryError('write-ahead-failed', error, false, true)
+ }
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ break
+ }
+ }
+ OCPP20ServiceUtils.replayedTransactionEventRequests.add(queuedEvent.request)
+ OCPP20ServiceUtils.replayedTransactionEventOwners.set(queuedEvent.request, {
+ connectorId: eventConnectorId,
+ evseId: eventEvseId ?? eventConnectorId,
+ })
+ setTransactionEventQueueInFlight(connectorStatus, queuedEvent)
+ try {
+ await OCPP20ServiceUtils.sendBuiltTransactionEvent(
+ chargingStation,
+ queuedEvent.request,
+ {
+ responseTimeoutMs,
+ skipBufferingOnError: true,
+ },
+ lifecycleAbortSignal
+ )
+ } finally {
+ if (
+ OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ clearTransactionEventQueueInFlight(connectorStatus, queuedEvent)
+ OCPP20ServiceUtils.replayedTransactionEventOwners.delete(queuedEvent.request)
+ OCPP20ServiceUtils.replayedTransactionEventRequests.delete(queuedEvent.request)
+ }
+ }
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ break
+ }
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ if (queue[0] === queuedEvent) {
+ shiftBoundedTransactionEvent(connectorStatus)
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(queuedEvent, false)
+ queueChanged = true
+ if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ const transactionFinalized = await OCPP20ServiceUtils.cleanupEndedTransaction(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ eventEvseId,
+ queuedEvent.request.transactionInfo.transactionId
+ )
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ break
+ }
+ if (!transactionFinalized) chargingStation.saveTransactionEventQueues()
+ queueChanged = false
+ } else if (
+ await OCPP20ServiceUtils.reconcileExhaustedStartedTransaction(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ queuedEvent.request.transactionInfo.transactionId,
+ eventEvseId
+ )
+ ) {
+ queueChanged = false
+ }
+ }
+ } catch (error) {
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ break
+ }
+ if (
+ error instanceof TransactionEventDeliveryError &&
+ error.definitelyUnsent &&
+ (error.outcome === 'write-ahead-failed' || error.outcome === 'pre-send-failed')
+ ) {
+ const rollbackPersisted =
+ await OCPP20ServiceUtils.rollbackDefinitelyUnsentTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ queuedEvent,
+ publicKeyDeliveryToken,
+ deliveryAttemptedByThisInvocation,
+ lifecycleAbortSignal
+ )
+ if (!rollbackPersisted) break
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ if (error.outcome === 'write-ahead-failed') continue
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Local pre-send failure for queued TransactionEvent with seqNo=${queuedEvent.seqNo.toString()}; retaining it for a later replay:`,
+ error
+ )
+ break
+ }
+ const preserveForReplay =
+ error instanceof TransactionEventDeliveryError
+ ? error.outcome === 'aborted' ||
+ error.outcome === 'offline' ||
+ error.outcome === 'response-handling-failed' ||
+ (error.outcome === 'exhausted' && !error.confirmedRejected && !error.definitelyUnsent)
+ : true
+ if (preserveForReplay) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Delivery interrupted for queued TransactionEvent with seqNo=${queuedEvent.seqNo.toString()}, preserving it and the remaining queue:`,
+ error
+ )
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ // Release every eager (queued-before-delivery) awaiter now that the queue is safely
+ // buffered for a later replay: the autonomous aligned emitter must not block on a
+ // reconnect that may never happen. Events stay queued and deliver on reconnect.
+ for (const preservedEvent of connectorStatus.transactionEventQueue ?? []) {
+ if (preservedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated) {
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(preservedEvent, false)
+ }
+ }
+ chargingStation.saveTransactionEventQueues()
+ queueChanged = false
+ break
+ }
+ const deliveryError = error as TransactionEventDeliveryError
+ const preserveIntervalEnergy =
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Updated &&
+ (deliveryError.outcome === 'pre-send-failed' ||
+ (deliveryError.outcome === 'exhausted' &&
+ (deliveryError.confirmedRejected || deliveryError.definitelyUnsent)))
+ if (queue[0] === queuedEvent) {
+ const failedTransactionId = queuedEvent.request.transactionInfo.transactionId
+ shiftBoundedTransactionEvent(connectorStatus, preserveIntervalEnergy)
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(queuedEvent, true)
+ OCPP20ServiceUtils.transferRejectedTransactionEventPublicKey(
+ connectorStatus,
+ queuedEvent.request,
+ publicKeyDeliveryToken
+ )
+ queueChanged = true
+ const disposalReason =
+ deliveryError.outcome === 'pre-send-failed'
+ ? 'after a local pre-send failure'
+ : 'after configured delivery attempts'
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendQueuedTransactionEvents: Discarding queued TransactionEvent with seqNo=${queuedEvent.seqNo.toString()} ${disposalReason}:`,
+ error
+ )
+ if (queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ const transactionFinalized = await OCPP20ServiceUtils.cleanupEndedTransaction(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ eventEvseId,
+ failedTransactionId
+ )
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ break
+ }
+ if (!transactionFinalized) chargingStation.saveTransactionEventQueues()
+ queueChanged = false
+ } else if (
+ await OCPP20ServiceUtils.reconcileExhaustedStartedTransaction(
+ chargingStation,
+ eventConnectorId,
+ connectorStatus,
+ failedTransactionId,
+ eventEvseId,
+ wasQueuedStartedEvent
+ )
+ ) {
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ break
+ }
+ queueChanged = false
+ }
+ }
+ continue
+ }
+ }
+ if (
+ queueChanged &&
+ OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ ) {
+ chargingStation.saveTransactionEventQueues()
+ }
+ }
+
+ private static enqueueTransactionEvent (
+ chargingStation: ChargingStation,
+ connectorStatus: ConnectorStatus,
+ request: OCPP20TransactionEventRequest,
+ markOffline = false,
+ publicKeyDeliveryToken?: PublicKeyDeliveryToken,
+ ownerConnectorId = 1,
+ ownerEvseId = ownerConnectorId,
+ meterValuePredecessorsPending = false
+ ): QueuedTransactionEvent | undefined {
+ if (markOffline) request.offline = true
+ const isUpdatedEvent = request.eventType === OCPP20TransactionEventEnumType.Updated
+ const queuedEvent: QueuedTransactionEvent = {
+ deliveryAttempted: false,
+ ownerConnectorId,
+ ownerEvseId,
+ ...(meterValuePredecessorsPending && { meterValuePredecessorsPending: true }),
+ request,
+ seqNo: request.seqNo,
+ timestamp: new Date(),
+ ...buildPersistentTransactionEnergyIntervalState(
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ ),
+ ...(getTransactionIntervalConsumptions(request.meterValue) != null && {
+ transactionEnergyActiveImportIntervalConsumption: getTransactionIntervalConsumptions(
+ request.meterValue
+ ),
+ }),
+ ...(connectorStatus.transactionEnergyActiveImportRegisterValue != null && {
+ transactionEnergyActiveImportRegisterValue:
+ connectorStatus.transactionEnergyActiveImportRegisterValue,
+ }),
+ }
+ const result = enqueueBoundedTransactionEvent(connectorStatus, queuedEvent)
+ if (!result.inserted) {
+ if (isUpdatedEvent) {
+ restoreRejectedTransactionEventIntervalCarry(connectorStatus, queuedEvent)
+ OCPP20ServiceUtils.transferRejectedTransactionEventPublicKey(
+ connectorStatus,
+ request,
+ publicKeyDeliveryToken,
+ false
+ )
+ }
+ if (result.changed) chargingStation.saveTransactionEventQueues()
+ if (result.capacityRejected === true) throw new TransactionEventQueueCapacityError()
+ return undefined
+ }
+ if (
+ (isNotEmptyArray(result.removedEvents) || result.overLimit) &&
+ !OCPP20ServiceUtils.saturatedTransactionEventQueues.has(connectorStatus)
+ ) {
+ OCPP20ServiceUtils.saturatedTransactionEventQueues.add(connectorStatus)
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.enqueueTransactionEvent: TransactionEvent queue reached its compaction target; ${result.overLimit ? 'retaining required lifecycle records above the target' : 'decimating intermediate updates or Ended meter data'}`
+ )
+ } else if (!result.overLimit && !isNotEmptyArray(result.removedEvents)) {
+ OCPP20ServiceUtils.saturatedTransactionEventQueues.delete(connectorStatus)
+ }
+ for (const removedEvent of result.removedEvents) {
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(removedEvent, true)
+ }
+ chargingStation.saveTransactionEventQueues(isUpdatedEvent)
+ return queuedEvent
+ }
+
+ private static finalizeEndedIntervalEnergyCoverage (
+ chargingStation: ChargingStation,
+ connectorStatus: ConnectorStatus,
+ request: OCPP20TransactionEventRequest,
+ queuedEvent: QueuedTransactionEvent
+ ): void {
+ const existingMeterValues = request.meterValue
+ const existingCoverage =
+ existingMeterValues == null
+ ? undefined
+ : OCPP20ServiceUtils.endedIntervalEnergyCoverages.get(existingMeterValues)
+ const extendedCoverage = buildEndedIntervalEnergyCoverage(
+ connectorStatus,
+ [],
+ 0,
+ existingCoverage
+ )
+ if (extendedCoverage.recoveryEnergyWh <= 0) {
+ if (existingMeterValues != null) {
+ OCPP20ServiceUtils.endedIntervalEnergyCoverages.set(existingMeterValues, extendedCoverage)
+ }
+ return
+ }
+ if (isEndedIntervalRecoverySigningRequired(chargingStation)) {
+ if (!hasSignedTerminalImportRegister(existingMeterValues)) {
+ throw new TransactionEventSignedEvidenceError()
+ }
+ if (existingMeterValues != null) {
+ OCPP20ServiceUtils.endedIntervalEnergyCoverages.set(existingMeterValues, extendedCoverage)
+ }
+ return
+ }
+
+ const recoverySample: OCPP20SampledValue = {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: extendedCoverage.recoveryEnergyWh,
+ }
+ const nextMeterValues: OCPP20MeterValue[] = [
+ ...(existingMeterValues ?? []),
+ { sampledValue: [recoverySample], timestamp: request.timestamp },
+ ]
+ const candidateEvent: QueuedTransactionEvent = {
+ ...queuedEvent,
+ request: { ...request, meterValue: nextMeterValues },
+ }
+ const candidateQueue = (connectorStatus.transactionEventQueue ?? []).map(event =>
+ event === queuedEvent ? candidateEvent : event
+ )
+ if (
+ candidateQueue.length > Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH ||
+ getTransactionEventQueueBytes(candidateQueue) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ ) {
+ throw new TransactionEventQueueCapacityError()
+ }
+ request.meterValue = nextMeterValues
+ OCPP20ServiceUtils.endedIntervalEnergyCoverages.set(nextMeterValues, extendedCoverage)
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ }
+
+ private static getRepresentedEndedIntervalEnergyWh (
+ chargingStation: ChargingStation,
+ meterValues: readonly OCPP20MeterValue[] | undefined,
+ evseId?: number
+ ): number {
+ if (meterValues == null) return 0
+ const inletToOutputEfficiency = resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency,
+ evseId
+ )
+ let representedEnergyWh = 0
+ for (const meterValue of meterValues) {
+ const hasTransactionBeginSample = meterValue.sampledValue.some(
+ sampledValue => sampledValue.context === OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ )
+ const representedMeterValue = hasTransactionBeginSample
+ ? {
+ sampledValue: meterValue.sampledValue.filter(
+ sampledValue =>
+ sampledValue.context !== OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ ),
+ }
+ : meterValue
+ representedEnergyWh += getRepresentedTransactionIntervalEnergyWh(
+ representedMeterValue,
+ chargingStation.getNumberOfPhases(),
+ inletToOutputEfficiency
+ )
+ }
+ return representedEnergyWh
+ }
+
+ private static incrementPendingTransactionEventDelivery (
+ connectorStatus: ConnectorStatus,
+ transactionId: string
+ ): void {
+ let counts = OCPP20ServiceUtils.pendingTransactionEventDeliveryCounts.get(connectorStatus)
+ if (counts == null) {
+ counts = new Map<string, number>()
+ OCPP20ServiceUtils.pendingTransactionEventDeliveryCounts.set(connectorStatus, counts)
+ }
+ counts.set(transactionId, (counts.get(transactionId) ?? 0) + 1)
+ }
+
+ private static isChargingStationStopping (chargingStation: ChargingStation): boolean {
+ return chargingStation.isStopping()
+ }
+
+ private static isLifecycleGenerationCurrent (
+ chargingStation: ChargingStation,
+ lifecycleAbortSignal?: AbortSignal
+ ): boolean {
+ return (
+ lifecycleAbortSignal == null ||
+ (chargingStation.lifecycleAbortSignal === lifecycleAbortSignal &&
+ !lifecycleAbortSignal.aborted)
+ )
+ }
+
+ private static preserveDiscardedUpdatedIntervalEnergy (
+ connectorStatus: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ): void {
+ if (
+ request.eventType !== OCPP20TransactionEventEnumType.Updated ||
+ connectorStatus.transactionId?.toString() !== request.transactionInfo.transactionId
+ ) {
+ return
+ }
+ const intervalConsumption = getTransactionIntervalConsumptions(request.meterValue)
+ transferDiscardedTransactionEventIntervalEnergy(connectorStatus, {
+ request,
+ seqNo: request.seqNo,
+ timestamp: new Date(),
+ ...(intervalConsumption != null && {
+ transactionEnergyActiveImportIntervalConsumption: intervalConsumption,
+ }),
+ })
+ }
+
+ /**
+ * Reads an integer and clamps it to its canonical Device Model bounds.
+ * @param chargingStation - Target charging station
+ * @param componentName - Device Model component name
+ * @param variableName - Device Model variable name
+ * @param defaultValue - Fallback value
+ * @param componentInstance - Optional component instance
+ * @returns The bounded integer value
+ */
+ private static readBoundedVariableAsInteger (
+ chargingStation: ChargingStation,
+ componentName: string,
+ variableName: string,
+ defaultValue: number,
+ componentInstance?: string
+ ): number {
+ const value = OCPP20ServiceUtils.readVariableAsInteger(
+ chargingStation,
+ componentName,
+ variableName,
+ defaultValue,
+ componentInstance
+ )
+ const metadata = getVariableMetadata(componentName, variableName, componentInstance)
+ return Math.min(metadata?.max ?? value, Math.max(metadata?.min ?? value, value))
+ }
+
+ private static readVariableAsIntervalMs (
+ chargingStation: ChargingStation,
+ componentName: string,
variableName: string,
- defaultSeconds: number
+ defaultSeconds: number,
+ componentInstance?: string
): number {
- const intervalSeconds = OCPP20ServiceUtils.readVariableAsInteger(
+ const configuredSeconds = OCPP20ServiceUtils.readBoundedVariableAsInteger(
chargingStation,
componentName,
variableName,
- defaultSeconds
+ defaultSeconds,
+ componentInstance
)
- return intervalSeconds > 0
- ? secondsToMilliseconds(intervalSeconds)
- : secondsToMilliseconds(defaultSeconds)
+ const intervalSeconds =
+ Number.isFinite(configuredSeconds) && configuredSeconds > 0
+ ? configuredSeconds
+ : defaultSeconds
+ return clampToSafeTimerValue(secondsToMilliseconds(intervalSeconds))
}
- private static resolveActiveTransaction (
- chargingStation: ChargingStation,
- connectorId: number
- ): { connectorStatus: ConnectorStatus; transactionId: string } {
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
- if (
- (connectorStatus?.transactionStarted === true ||
- connectorStatus?.transactionPending === true) &&
- connectorStatus.transactionId != null
- ) {
- let transactionId: string
- if (typeof connectorStatus.transactionId === 'string') {
- transactionId = connectorStatus.transactionId
- } else {
- transactionId = connectorStatus.transactionId.toString()
- logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.resolveActiveTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0.1`
+ private static rebuildQueuedEndedMeterValues (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent,
+ evseId?: number
+ ): OCPP20TransactionEventRequest {
+ const transactionId = queuedEvent.request.transactionInfo.transactionId
+ const endedEventCarriedPublicKey =
+ queuedEvent.request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ if (endedEventCarriedPublicKey && connectorStatus.transactionId?.toString() === transactionId) {
+ connectorStatus.publicKeySentInTransaction = false
+ }
+ const meterValue = OCPP20ServiceUtils.buildTransactionEndedMeterValues(
+ chargingStation,
+ connectorId,
+ transactionId,
+ evseId
+ )
+ const request: OCPP20TransactionEventRequest = {
+ ...queuedEvent.request,
+ meterValue: isNotEmptyArray(meterValue) ? meterValue : undefined,
+ }
+ queuedEvent.request = request
+ delete queuedEvent.transactionEnergyActiveImportIntervalBaselines
+ delete queuedEvent.transactionEnergyActiveImportIntervalConsumption
+ delete queuedEvent.transactionEnergyActiveImportRegisterValue
+ Object.assign(
+ queuedEvent,
+ buildPersistentTransactionEnergyIntervalState(
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ )
+ )
+ const intervalConsumption = getTransactionIntervalConsumptions(meterValue)
+ if (intervalConsumption != null) {
+ queuedEvent.transactionEnergyActiveImportIntervalConsumption = intervalConsumption
+ }
+ if (connectorStatus.transactionEnergyActiveImportRegisterValue != null) {
+ queuedEvent.transactionEnergyActiveImportRegisterValue =
+ connectorStatus.transactionEnergyActiveImportRegisterValue
+ }
+ delete queuedEvent.meterValuePredecessorsPending
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ return request
+ }
+
+ private static async reconcileExhaustedStartedTransaction (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ transactionId: string,
+ evseId?: number,
+ markExhausted = false
+ ): Promise<boolean> {
+ if (connectorStatus.transactionStartedExhaustedTransactionId !== transactionId) {
+ if (!markExhausted) return false
+ connectorStatus.transactionStartedExhaustedTransactionId = transactionId
+ }
+ const hasRemainingTransactionEvents = connectorStatus.transactionEventQueue?.some(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === transactionId
+ )
+ if (hasRemainingTransactionEvents === true) {
+ chargingStation.saveTransactionEventQueues()
+ return true
+ }
+
+ const currentConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ const stillOwnsTransaction =
+ currentConnectorStatus === connectorStatus &&
+ connectorStatus.transactionId?.toString() === transactionId
+ if (stillOwnsTransaction && connectorStatus.transactionEnding === true) {
+ chargingStation.saveTransactionEventQueues()
+ return true
+ }
+ delete connectorStatus.transactionStartedExhaustedTransactionId
+ if (stillOwnsTransaction) {
+ const replacementRestored = OCPP20ServiceUtils.restoreNextQueuedStartedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ transactionId,
+ evseId
+ )
+ if (!replacementRestored) {
+ const transactionFinalized = await OCPP20ServiceUtils.cleanupEndedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId,
+ transactionId
+ )
+ if (!transactionFinalized) {
+ chargingStation.destroyCoherentSession(transactionId)
+ chargingStation.saveTransactionEventQueues()
+ }
+ }
+ } else {
+ chargingStation.destroyCoherentSession(transactionId)
+ chargingStation.saveTransactionEventQueues()
+ }
+ return true
+ }
+
+ private static resolveActiveTransaction (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId?: number
+ ): { connectorStatus: ConnectorStatus; transactionId: string } {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ const transactionId = connectorStatus?.transactionId?.toString()
+ const hasDurableStarted = connectorStatus?.transactionEventQueue?.some(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Started &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ )
+ if (
+ connectorStatus != null &&
+ !isTransactionEnding(connectorStatus) &&
+ (connectorStatus.transactionStarted === true ||
+ connectorStatus.transactionPending === true ||
+ connectorStatus.transactionStarting === true ||
+ (connectorStatus.transactionRestored === true && hasDurableStarted === true)) &&
+ transactionId != null
+ ) {
+ if (typeof connectorStatus.transactionId !== 'string') {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.resolveActiveTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0.1`
+ )
+ }
+ return { connectorStatus, transactionId }
+ }
+ throw new OCPPError(
+ ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+ `No active transaction on connector ${connectorId.toString()}`
+ )
+ }
+
+ private static restoreNextQueuedStartedTransaction (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ failedTransactionId: string,
+ evseId?: number
+ ): boolean {
+ const queuedStarted = connectorStatus.transactionEventQueue?.[0]
+ if (queuedStarted?.request.eventType !== OCPP20TransactionEventEnumType.Started) return false
+
+ return OCPP20ServiceUtils.restoreQueuedStartedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ queuedStarted,
+ failedTransactionId,
+ evseId
+ )
+ }
+
+ private static restoreQueuedStartedTransaction (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ queuedStarted: QueuedTransactionEvent,
+ discardedTransactionId: string,
+ evseId?: number
+ ): boolean {
+ if (queuedStarted.request.eventType !== OCPP20TransactionEventEnumType.Started) return false
+
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
+ OCPP20ServiceUtils.stopEndedMeterValues(chargingStation, connectorId, evseId)
+ setTransactionEventQueueStaged(connectorStatus, queuedStarted, false)
+ resetConnectorStatus(connectorStatus)
+ chargingStation.destroyCoherentSession(discardedTransactionId)
+ const replacementTransactionId = queuedStarted.request.transactionInfo.transactionId
+ const replacementEvents = (connectorStatus.transactionEventQueue ?? []).filter(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === replacementTransactionId
+ )
+ const replacementEnergyWh = replacementEvents.reduce((latestEnergyWh, queuedEvent) => {
+ const energyWh = queuedEvent.transactionEnergyActiveImportRegisterValue
+ return typeof energyWh === 'number' && Number.isFinite(energyWh) && energyWh >= 0
+ ? energyWh
+ : latestEnergyWh
+ }, 0)
+ const replacementIntervalBaselines =
+ replacementEvents.at(-1)?.transactionEnergyActiveImportIntervalBaselines
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionIdTag = replacementEvents.find(
+ queuedEvent => queuedEvent.request.idToken != null
+ )?.request.idToken?.idToken
+ connectorStatus.transactionBeginMeterValue = queuedStarted.request.meterValue?.[0]
+ if (replacementIntervalBaselines != null) {
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ ...replacementIntervalBaselines,
+ }
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterValue = Math.max(0, replacementEnergyWh)
+ connectorStatus.transactionSeqNo = Math.max(
+ queuedStarted.seqNo,
+ ...replacementEvents.map(queuedEvent => queuedEvent.seqNo)
+ )
+ connectorStatus.transactionStart = queuedStarted.request.timestamp
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ if (replacementEvents.some(queuedEvent => queuedEvent.request.evse != null)) {
+ connectorStatus.transactionEvseSent = true
+ }
+ if (replacementEvents.some(queuedEvent => queuedEvent.request.idToken != null)) {
+ connectorStatus.transactionIdTokenSent = true
+ }
+ if (
+ replacementEvents.some(queuedEvent =>
+ queuedTransactionEventHasPublicKey(queuedEvent, replacementTransactionId)
+ )
+ ) {
+ connectorStatus.publicKeySentInTransaction = true
+ }
+ chargingStation.saveTransactionEventQueues()
+ return true
+ }
+
+ private static async rollbackDefinitelyUnsentTransactionEvent (
+ chargingStation: ChargingStation,
+ connectorStatus: ConnectorStatus,
+ queuedEvent: QueuedTransactionEvent,
+ publicKeyDeliveryToken: PublicKeyDeliveryToken | undefined,
+ clearDeliveryAttempted: boolean,
+ lifecycleAbortSignal?: AbortSignal
+ ): Promise<boolean> {
+ if (clearDeliveryAttempted) {
+ queuedEvent.deliveryAttempted = false
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ }
+ setTransactionEventQueueStaged(connectorStatus, queuedEvent, false)
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ return OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)
+ } catch (rollbackError: unknown) {
+ if (!OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)) {
+ return false
+ }
+ restoreRejectedTransactionEventIntervalCarry(connectorStatus, queuedEvent)
+ if (removeBoundedTransactionEvent(connectorStatus, queuedEvent)) {
+ OCPP20ServiceUtils.settleQueuedTransactionMeterValueDelivery(queuedEvent, true)
+ }
+ OCPP20ServiceUtils.transferRejectedTransactionEventPublicKey(
+ connectorStatus,
+ queuedEvent.request,
+ publicKeyDeliveryToken
+ )
+ chargingStation.saveTransactionEventQueues()
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.rollbackDefinitelyUnsentTransactionEvent: Failed to persist retryable state; discarded the unverifiable in-memory event after preserving its evidence:`,
+ rollbackError
+ )
+ throw rollbackError
+ }
+ }
+
+ private static scheduleTransactionEventQueueDrain (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ connectorStatus: ConnectorStatus,
+ evseId?: number
+ ): void {
+ if (
+ OCPP20ServiceUtils.isChargingStationStopping(chargingStation) ||
+ OCPP20ServiceUtils.transactionEventQueueDrains.has(connectorStatus)
+ ) {
+ return
+ }
+ OCPP20ServiceUtils.transactionEventQueueDrains.add(connectorStatus)
+ const initialQueueHead = connectorStatus.transactionEventQueue?.[0]
+ OCPP20ServiceUtils.sendQueuedTransactionEvents(chargingStation, connectorId, evseId)
+ .finally(() => {
+ OCPP20ServiceUtils.transactionEventQueueDrains.delete(connectorStatus)
+ if (
+ connectorStatus.transactionEventQueue?.[0] !== initialQueueHead &&
+ isNotEmptyArray(connectorStatus.transactionEventQueue) &&
+ !OCPP20ServiceUtils.isChargingStationStopping(chargingStation) &&
+ chargingStation.isWebSocketConnectionOpened() &&
+ chargingStation.inAcceptedState()
+ ) {
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ }
+ })
+ .catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.scheduleTransactionEventQueueDrain: Error draining queued TransactionEvents:`,
+ error
+ )
+ })
+ }
+
+ /**
+ * Sends one pre-built TransactionEvent and applies the OCPP E13 retry policy
+ * without rebuilding it, preserving its timestamp and sequence number.
+ * @param chargingStation - Target charging station
+ * @param request - Immutable TransactionEvent payload to send
+ * @param requestParams - Transport behavior overrides
+ * @param lifecycleAbortSignal - Lifecycle generation governing this delivery
+ * @param deliveryIsCurrent - Whether the delivery still owns its transaction context
+ * @returns The TransactionEvent response
+ */
+ private static async sendBuiltTransactionEvent (
+ chargingStation: ChargingStation,
+ request: OCPP20TransactionEventRequest,
+ requestParams: RequestParams = {},
+ lifecycleAbortSignal?: AbortSignal,
+ deliveryIsCurrent?: () => boolean
+ ): Promise<OCPP20TransactionEventResponse> {
+ const maximumAttempts = OCPP20ServiceUtils.readBoundedVariableAsInteger(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageAttempts,
+ 3,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const retryIntervalMs = secondsToMilliseconds(
+ OCPP20ServiceUtils.readBoundedVariableAsInteger(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageAttemptInterval,
+ 5,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ )
+ const responseTimeoutMs =
+ requestParams.responseTimeoutMs ??
+ secondsToMilliseconds(
+ OCPP20ServiceUtils.readBoundedVariableAsInteger(
+ chargingStation,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS,
+ 'Default'
+ )
+ )
+ let hadAmbiguousSentAttempt = false
+ let hadConfirmedRejectedAttempt = false
+ for (let attempt = 1; attempt <= maximumAttempts; attempt++) {
+ if (isAbortSignalAborted(lifecycleAbortSignal) || deliveryIsCurrent?.() === false) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent delivery aborted before send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ ),
+ hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt,
+ !hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt
+ )
+ }
+ const deliveryState = {
+ confirmedRejected: false,
+ responseReceived: false,
+ sent: false,
+ transportErrorAmbiguous: false,
+ transportErrorReported: false,
+ }
+ try {
+ return await chargingStation.ocppRequestService.requestHandler<
+ OCPP20TransactionEventRequest,
+ OCPP20TransactionEventResponse
+ >(chargingStation, OCPP20RequestCommand.TRANSACTION_EVENT, request, {
+ ...requestParams,
+ onError: (error, isCallError) => {
+ if (isCallError) deliveryState.confirmedRejected = true
+ requestParams.onError?.(error, isCallError)
+ },
+ onMessageSent: () => {
+ deliveryState.sent = true
+ requestParams.onMessageSent?.()
+ },
+ onResponseReceived: () => {
+ deliveryState.responseReceived = true
+ requestParams.onResponseReceived?.()
+ },
+ onTransportError: (error, deliveryAmbiguous) => {
+ deliveryState.transportErrorAmbiguous ||= deliveryAmbiguous
+ deliveryState.transportErrorReported = true
+ requestParams.onTransportError?.(error, deliveryAmbiguous)
+ },
+ rawPayload: true,
+ responseTimeoutMs,
+ skipBufferingOnError: requestParams.skipBufferingOnError ?? false,
+ throwError: true,
+ })
+ } catch (error) {
+ if (deliveryState.confirmedRejected) hadConfirmedRejectedAttempt = true
+ const webSocketOpen = chargingStation.isWebSocketConnectionOpened()
+ const stationAccepted = chargingStation.inAcceptedState()
+ if (deliveryState.responseReceived) {
+ throw new TransactionEventDeliveryError('response-handling-failed', error)
+ }
+ const interruptedConfirmedRejected =
+ hadConfirmedRejectedAttempt &&
+ !hadAmbiguousSentAttempt &&
+ !deliveryState.transportErrorAmbiguous
+ const interruptedDefinitelyUnsent =
+ !deliveryState.sent &&
+ !deliveryState.transportErrorAmbiguous &&
+ !hadConfirmedRejectedAttempt &&
+ !hadAmbiguousSentAttempt
+ if (
+ isAbortSignalAborted(lifecycleAbortSignal) ||
+ deliveryIsCurrent?.() === false ||
+ OCPP20ServiceUtils.isChargingStationStopping(chargingStation)
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ error,
+ interruptedConfirmedRejected,
+ interruptedDefinitelyUnsent
+ )
+ }
+ if (!webSocketOpen || !stationAccepted) {
+ throw new TransactionEventDeliveryError(
+ 'offline',
+ error,
+ interruptedConfirmedRejected,
+ interruptedDefinitelyUnsent
+ )
+ }
+ if (
+ !deliveryState.sent &&
+ !deliveryState.transportErrorReported &&
+ !deliveryState.confirmedRejected &&
+ !hadAmbiguousSentAttempt &&
+ !hadConfirmedRejectedAttempt
+ ) {
+ throw new TransactionEventDeliveryError('pre-send-failed', error, false, true)
+ }
+ if (
+ deliveryState.transportErrorAmbiguous ||
+ (deliveryState.sent && !deliveryState.confirmedRejected)
+ ) {
+ hadAmbiguousSentAttempt = true
+ }
+ if (attempt >= maximumAttempts) {
+ const confirmedRejected = hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt
+ const definitelyUnsent =
+ deliveryState.transportErrorReported &&
+ !deliveryState.transportErrorAmbiguous &&
+ !hadAmbiguousSentAttempt &&
+ !hadConfirmedRejectedAttempt &&
+ !deliveryState.sent
+ throw new TransactionEventDeliveryError(
+ 'exhausted',
+ error,
+ confirmedRejected,
+ definitelyUnsent
+ )
+ }
+ const retryDelayMs = clampToSafeTimerValue(retryIntervalMs * attempt)
+ try {
+ if (lifecycleAbortSignal == null) {
+ await sleep(retryDelayMs)
+ } else {
+ await interruptibleSleep(retryDelayMs, lifecycleAbortSignal)
+ }
+ } catch {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ error,
+ hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt,
+ !hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt
+ )
+ }
+ if (isAbortSignalAborted(lifecycleAbortSignal) || deliveryIsCurrent?.() === false) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ error,
+ hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt,
+ !hadConfirmedRejectedAttempt && !hadAmbiguousSentAttempt
+ )
+ }
+ }
+ }
+ throw new TransactionEventDeliveryError(
+ 'exhausted',
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent retry loop exhausted unexpectedly',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ )
+ }
+
+ private static async sendTransactionEventWithPredecessors (
+ chargingStation: ChargingStation,
+ eventType: OCPP20TransactionEventEnumType,
+ triggerReason: OCPP20TriggerReasonEnumType,
+ connectorId: number,
+ transactionId: string,
+ options: Omit<OCPP20TransactionEventOptions, 'eventType'> = {},
+ requestParams?: RequestParams,
+ endedPredecessors?: EndedTransactionEventPredecessors,
+ preparedMeterValueDelivery?: TransactionMeterValueDelivery,
+ terminalDeliveryIsCurrent?: () => boolean,
+ terminalDeliveryAbortSignal?: AbortSignal,
+ startedOwnership?: 'pending' | 'starting',
+ queueBeforeDelivery = false
+ ): Promise<OCPP20TransactionEventResponse> {
+ const lifecycleAbortSignal = chargingStation.lifecycleAbortSignal
+ let publicKeyDeliveryToken: PublicKeyDeliveryToken | undefined
+ let transactionMeterValueDelivery = preparedMeterValueDelivery
+ try {
+ const evseId = typeof options.evseId === 'number' ? options.evseId : undefined
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ if (connectorStatus == null) {
+ const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}`
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: ${errorMsg}`
+ )
+ throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
+ }
+ const optionMeterValues = (options as { meterValue?: OCPP20MeterValue[] }).meterValue
+ const optionMeterValuesCarryPublicKey =
+ optionMeterValues?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ optionMeterValues ?? options,
+ optionMeterValuesCarryPublicKey
+ )
+ const isReservedTriggeredDelivery =
+ eventType === OCPP20TransactionEventEnumType.Updated &&
+ triggerReason === OCPP20TriggerReasonEnumType.Trigger &&
+ OCPP20ServiceUtils.triggeredTransactionEventReservations
+ .get(connectorStatus)
+ ?.has(transactionId) === true
+ const queuedBehindTransactionEventDelivery =
+ !isReservedTriggeredDelivery &&
+ eventType === OCPP20TransactionEventEnumType.Updated &&
+ OCPP20ServiceUtils.transactionEventSendChains.has(connectorStatus)
+ if (
+ eventType === OCPP20TransactionEventEnumType.Updated &&
+ isTransactionEnding(connectorStatus) &&
+ !isReservedTriggeredDelivery
+ ) {
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Dropping TransactionEvent(Updated) after transaction ending started`
+ )
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ if (queueBeforeDelivery) {
+ transactionMeterValueDelivery?.settle(true)
+ transactionMeterValueDelivery = undefined
+ }
+ return { idTokenInfo: undefined }
+ }
+ if (
+ eventType === OCPP20TransactionEventEnumType.Updated &&
+ isNotEmptyArray(optionMeterValues) &&
+ transactionMeterValueDelivery == null
+ ) {
+ transactionMeterValueDelivery = TransactionMeterValueDeliveryBarrier.begin(
+ connectorStatus,
+ transactionId
+ )
+ }
+
+ if (queueBeforeDelivery) {
+ if (
+ eventType !== OCPP20TransactionEventEnumType.Updated ||
+ transactionMeterValueDelivery == null
+ ) {
+ throw new BaseError('Only metered TransactionEvent(Updated) may be queued eagerly')
+ }
+ const eagerDelivery = transactionMeterValueDelivery
+ const previousSeqNo = connectorStatus.transactionSeqNo
+ const previousEvseSent = connectorStatus.transactionEvseSent
+ const previousIdTokenSent = connectorStatus.transactionIdTokenSent
+ try {
+ const webSocketOpen = chargingStation.isWebSocketConnectionOpened()
+ const transactionEventRequest = buildTransactionEvent(chargingStation, {
+ connectorId,
+ eventType,
+ transactionId,
+ ...options,
+ triggerReason,
+ ...(!webSocketOpen && { offline: true }),
+ })
+ publicKeyDeliveryToken =
+ transferPublicKeyDelivery(publicKeyDeliveryToken, transactionEventRequest) ??
+ claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ transactionEventRequest,
+ transactionEventRequest.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ )
+ const queuedEvent = OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ transactionEventRequest.offline === true,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId
+ )
+ if (queuedEvent == null) {
+ throw new TransactionEventQueueCapacityError()
+ }
+ OCPP20ServiceUtils.transactionMeterValueDeliveries.set(queuedEvent, eagerDelivery)
+ const settlement = eagerDelivery.waitForSettlement()
+ eagerDelivery.markBuffered()
+ transactionMeterValueDelivery = undefined
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ if (webSocketOpen && chargingStation.inAcceptedState()) {
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ await settlement
+ }
+ return { idTokenInfo: undefined }
+ } catch (error: unknown) {
+ if (previousSeqNo == null) delete connectorStatus.transactionSeqNo
+ else connectorStatus.transactionSeqNo = previousSeqNo
+ if (previousEvseSent == null) delete connectorStatus.transactionEvseSent
+ else connectorStatus.transactionEvseSent = previousEvseSent
+ if (previousIdTokenSent == null) delete connectorStatus.transactionIdTokenSent
+ else connectorStatus.transactionIdTokenSent = previousIdTokenSent
+ eagerDelivery.settle(true)
+ transactionMeterValueDelivery = undefined
+ throw error
+ }
+ }
+
+ const ensureTerminalDeliveryIsCurrent = (definitelyUnsent = true): void => {
+ if (terminalDeliveryIsCurrent?.() !== false) return
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent terminal operation was cancelled',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ ),
+ false,
+ definitelyUnsent
+ )
+ }
+ const persistTerminalEventQueue = async (): Promise<void> => {
+ const persistence = chargingStation.persistTransactionEventQueues()
+ if (terminalDeliveryAbortSignal == null) {
+ await persistence
+ return
+ }
+ if (terminalDeliveryAbortSignal.aborted) ensureTerminalDeliveryIsCurrent()
+ const cancelled = Promise.withResolvers<undefined>()
+ const onAbort = (): void => {
+ cancelled.resolve(undefined)
+ }
+ terminalDeliveryAbortSignal.addEventListener('abort', onAbort, { once: true })
+ try {
+ await Promise.race([persistence, cancelled.promise])
+ ensureTerminalDeliveryIsCurrent()
+ } finally {
+ terminalDeliveryAbortSignal.removeEventListener('abort', onAbort)
+ }
+ }
+ const performDelivery = async (
+ previousDelivery?: Promise<unknown>
+ ): Promise<OCPP20TransactionEventResponse> => {
+ const waitForPreviousDelivery = async (): Promise<void> => {
+ if (previousDelivery != null) await previousDelivery.catch(() => undefined)
+ }
+ ensureTerminalDeliveryIsCurrent()
+ if (eventType === OCPP20TransactionEventEnumType.Started && startedOwnership != null) {
+ let currentConnectorStatus: ConnectorStatus | undefined
+ try {
+ currentConnectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ } catch {
+ // A removed connector cannot still own this Started event.
+ }
+ if (
+ currentConnectorStatus !== connectorStatus ||
+ connectorStatus.transactionId?.toString() !== transactionId ||
+ (startedOwnership === 'pending'
+ ? connectorStatus.transactionPending !== true ||
+ connectorStatus.transactionStarting === true
+ : connectorStatus.transactionStarting !== true) ||
+ connectorStatus.transactionStarted === true ||
+ isTransactionEnding(connectorStatus)
+ ) {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ throw new TransactionEventDeliveryError(
+ 'ownership-lost',
+ new BaseError('TransactionEvent(Started) no longer owns its pending transaction'),
+ false,
+ true
+ )
+ }
+ }
+ const transactionOwnedWhenQueued =
+ connectorStatus.transactionId?.toString() === transactionId
+ if (queuedBehindTransactionEventDelivery) await Promise.resolve()
+ const webSocketOpen = chargingStation.isWebSocketConnectionOpened()
+ const canSend = webSocketOpen && chargingStation.inAcceptedState()
+ if (!canSend && requestParams?.skipBufferingOnError === true) {
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Dropping non-buffered TransactionEvent while the station cannot send transaction messages`
+ )
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ return { idTokenInfo: undefined }
+ }
+ let transactionEventRequest = buildTransactionEvent(chargingStation, {
+ connectorId,
+ eventType,
+ transactionId,
+ ...options,
+ triggerReason,
+ ...(!webSocketOpen && { offline: true }),
+ })
+ publicKeyDeliveryToken =
+ transferPublicKeyDelivery(publicKeyDeliveryToken, transactionEventRequest) ??
+ claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ transactionEventRequest,
+ transactionEventRequest.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ )
+ let predecessorReconciliationReady = true
+ let predecessorSettlement: Promise<void> | undefined
+ const blockUntilPredecessorsSettle = (
+ queuedEvent: QueuedTransactionEvent
+ ): Promise<void> | undefined => {
+ if (endedPredecessors == null || isEmpty(endedPredecessors.dependencies)) return
+ predecessorReconciliationReady = false
+ setTransactionEventQueueBlocked(queuedEvent, true)
+ queuedEvent.meterValuePredecessorsPending = true
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ predecessorSettlement ??= Promise.all(
+ endedPredecessors.dependencies.map(dependency => dependency.waitForSettlement())
+ )
+ .then(async rejectionResults => {
+ if (connectorStatus.transactionEventQueue?.includes(queuedEvent) !== true) {
+ predecessorReconciliationReady = true
+ return
+ }
+ if (rejectionResults.some(Boolean) && queuedEvent.deliveryAttempted !== true) {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ publicKeyDeliveryToken = undefined
+ transactionEventRequest = OCPP20ServiceUtils.rebuildQueuedEndedMeterValues(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ queuedEvent,
+ evseId
+ )
+ }
+ delete queuedEvent.meterValuePredecessorsPending
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ await chargingStation.persistTransactionEventQueues()
+ ensureTerminalDeliveryIsCurrent()
+ predecessorReconciliationReady = true
+ return undefined
+ })
+ .catch((error: unknown) => {
+ if (connectorStatus.transactionEventQueue?.includes(queuedEvent) !== true) return
+ queuedEvent.meterValuePredecessorsPending = true
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Failed to reconcile an Ended event after predecessor settlement:`,
+ error
+ )
+ })
+ .finally(() => {
+ setTransactionEventQueueBlocked(queuedEvent, false)
+ if (connectorStatus.transactionEventQueue?.includes(queuedEvent) !== true) return
+ if (!predecessorReconciliationReady) {
+ setTransactionEventQueueStaged(connectorStatus, queuedEvent, false)
+ }
+ if (
+ chargingStation.started &&
+ !chargingStation.isStopping() &&
+ chargingStation.isWebSocketConnectionOpened() &&
+ chargingStation.inAcceptedState()
+ ) {
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ }
+ })
+ return predecessorSettlement
+ }
+ const transactionEventQueueSnapshot =
+ eventType === OCPP20TransactionEventEnumType.Ended
+ ? captureTransactionEventQueueSnapshot(connectorStatus)
+ : undefined
+ let queueSnapshotRestored = false
+ const restorePreDurableQueue = (): boolean => {
+ if (transactionEventQueueSnapshot == null || queueSnapshotRestored) return false
+ queueSnapshotRestored = true
+ restoreTransactionEventQueueSnapshot(connectorStatus, transactionEventQueueSnapshot)
+ return true
+ }
+ if (transactionEventQueueSnapshot != null) {
+ endedPredecessors?.onStaging?.(restorePreDurableQueue)
+ }
+ if (!canSend) {
+ const queuedEvent = OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ transactionEventRequest.offline === true,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId,
+ endedPredecessors != null && isNotEmptyArray(endedPredecessors.dependencies)
+ )
+ if (queuedEvent != null) predecessorSettlement = blockUntilPredecessorsSettle(queuedEvent)
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Station ${webSocketOpen ? 'not accepted' : 'offline'}, queueing TransactionEvent with seqNo=${transactionEventRequest.seqNo.toString()}`
+ )
+ if (eventType === OCPP20TransactionEventEnumType.Ended) {
+ if (queuedEvent != null) {
+ setTransactionEventQueueStaged(connectorStatus, queuedEvent, true)
+ }
+ try {
+ await persistTerminalEventQueue()
+ } catch (error: unknown) {
+ if (queuedEvent != null) {
+ setTransactionEventQueueStaged(connectorStatus, queuedEvent, false)
+ setTransactionEventQueueBlocked(queuedEvent, false)
+ }
+ if (restorePreDurableQueue()) endedPredecessors?.onRolledBack?.()
+ if (error instanceof TransactionEventDeliveryError && error.outcome === 'aborted') {
+ throw error
+ }
+ throw new TransactionEventDeliveryError('write-ahead-failed', error, false, true)
+ }
+ await waitForPreviousDelivery()
+ ensureTerminalDeliveryIsCurrent()
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed'),
+ false,
+ true
+ )
+ }
+ if (queuedEvent != null) {
+ endedPredecessors?.onPersisted?.(queuedEvent)
+ setTransactionEventQueueStaged(connectorStatus, queuedEvent, false)
+ }
+ }
+ return { idTokenInfo: undefined }
+ }
+ let deleteEmptyStagedQueue = transactionEventQueueSnapshot?.queue == null
+ let stagedEvent: QueuedTransactionEvent | undefined
+ let deliveryAttemptedByThisInvocation = false
+ if (eventType === OCPP20TransactionEventEnumType.Ended) {
+ stagedEvent = OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ false,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId,
+ endedPredecessors != null && isNotEmptyArray(endedPredecessors.dependencies)
+ )
+ if (stagedEvent != null) {
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, true)
+ predecessorSettlement = blockUntilPredecessorsSettle(stagedEvent)
+ try {
+ await persistTerminalEventQueue()
+ } catch (error: unknown) {
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, false)
+ setTransactionEventQueueBlocked(stagedEvent, false)
+ if (restorePreDurableQueue()) endedPredecessors?.onRolledBack?.()
+ if (error instanceof TransactionEventDeliveryError && error.outcome === 'aborted') {
+ throw error
+ }
+ throw new TransactionEventDeliveryError('write-ahead-failed', error, false, true)
+ }
+ ensureTerminalDeliveryIsCurrent()
+ endedPredecessors?.onPersisted?.(stagedEvent)
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed'),
+ false,
+ true
+ )
+ }
+ }
+ await waitForPreviousDelivery()
+ }
+ const removeStagedEvent = (persistRemoval = true): void => {
+ if (stagedEvent == null) return
+ const queue = connectorStatus.transactionEventQueue
+ let removed = false
+ if (queue?.at(0) === stagedEvent) {
+ removed = shiftBoundedTransactionEvent(connectorStatus) === stagedEvent
+ } else {
+ const stagedIndex = queue?.indexOf(stagedEvent) ?? -1
+ if (stagedIndex >= 0) {
+ queue?.splice(stagedIndex, 1)
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ removed = true
+ }
+ }
+ if (removed) {
+ if (queue != null && isEmpty(queue) && deleteEmptyStagedQueue) {
+ delete connectorStatus.transactionEventQueue
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ }
+ if (persistRemoval) chargingStation.saveTransactionEventQueues()
+ }
+ }
+ if (
+ !isReservedTriggeredDelivery &&
+ eventType === OCPP20TransactionEventEnumType.Updated &&
+ queuedBehindTransactionEventDelivery
+ ) {
+ OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ false,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId
+ )
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ return { idTokenInfo: undefined }
+ }
+
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Sending TransactionEvent for trigger ${triggerReason}`
+ )
+ let deliveryPending = true
+ const finishPendingDelivery = (): void => {
+ if (!deliveryPending) return
+ deliveryPending = false
+ OCPP20ServiceUtils.decrementPendingTransactionEventDelivery(
+ connectorStatus,
+ transactionId
+ )
+ }
+ OCPP20ServiceUtils.incrementPendingTransactionEventDelivery(connectorStatus, transactionId)
+ try {
+ const deliver = async (): Promise<OCPP20TransactionEventResponse> => {
+ if (predecessorSettlement != null && stagedEvent != null) {
+ if (!chargingStation.isStopping()) {
+ const aborted = Promise.withResolvers<undefined>()
+ const onAbort = (): void => {
+ aborted.resolve(undefined)
+ }
+ lifecycleAbortSignal.addEventListener('abort', onAbort, { once: true })
+ await Promise.race([predecessorSettlement, aborted.promise])
+ lifecycleAbortSignal.removeEventListener('abort', onAbort)
+ }
+ ensureTerminalDeliveryIsCurrent()
+ if (!predecessorReconciliationReady || isTransactionEventQueueBlocked(stagedEvent)) {
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, false)
+ return { idTokenInfo: undefined }
+ }
+ }
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed'),
+ false,
+ true
+ )
+ }
+ const queuedEventsBeforeRequest = new Set(
+ (connectorStatus.transactionEventQueue ?? []).filter(
+ queuedEvent =>
+ queuedEvent.request.transactionInfo.transactionId !== transactionId ||
+ queuedEvent.seqNo < transactionEventRequest.seqNo
+ )
+ )
+ try {
+ if (!isEmpty(queuedEventsBeforeRequest)) {
+ await OCPP20ServiceUtils.drainQueuedTransactionEvents(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ lifecycleAbortSignal,
+ evseId,
+ queuedEventsBeforeRequest
+ )
+ ensureTerminalDeliveryIsCurrent()
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ OCPP20ServiceUtils.preserveDiscardedUpdatedIntervalEnergy(
+ connectorStatus,
+ transactionEventRequest
+ )
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed')
+ )
+ }
+ }
+ if (
+ connectorStatus.transactionEventQueue?.some(queuedEvent =>
+ queuedEventsBeforeRequest.has(queuedEvent)
+ ) === true ||
+ !chargingStation.isWebSocketConnectionOpened() ||
+ !chargingStation.inAcceptedState()
+ ) {
+ if (stagedEvent == null) {
+ OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ transactionEventRequest.offline === true,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId
+ )
+ }
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ return { idTokenInfo: undefined }
+ }
+ if (
+ transactionOwnedWhenQueued &&
+ eventType !== OCPP20TransactionEventEnumType.Started &&
+ connectorStatus.transactionId?.toString() !== transactionId
+ ) {
+ removeStagedEvent()
+ OCPP20ServiceUtils.transferRejectedTransactionEventPublicKey(
+ connectorStatus,
+ transactionEventRequest,
+ publicKeyDeliveryToken
+ )
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Dropping stale ${eventType} for transaction ${transactionId}`
+ )
+ return { idTokenInfo: undefined }
+ }
+ if (eventType === OCPP20TransactionEventEnumType.Ended && stagedEvent != null) {
+ OCPP20ServiceUtils.finalizeEndedIntervalEnergyCoverage(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ stagedEvent
+ )
+ }
+ if (!isReservedTriggeredDelivery && stagedEvent == null) {
+ deleteEmptyStagedQueue = connectorStatus.transactionEventQueue == null
+ OCPP20ServiceUtils.enqueueTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ transactionEventRequest,
+ false,
+ publicKeyDeliveryToken,
+ connectorId,
+ evseId ?? connectorId
+ )
+ stagedEvent = connectorStatus.transactionEventQueue?.find(
+ queuedEvent => queuedEvent.request === transactionEventRequest
+ )
+ if (stagedEvent == null) {
+ throw new OCPPError(
+ ErrorType.INTERNAL_ERROR,
+ 'TransactionEvent could not be staged before delivery',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ }
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, true)
+ }
+ publicKeyDeliveryToken = claimPublicKeyDelivery(
+ connectorStatus,
+ transactionId,
+ transactionEventRequest,
+ transactionEventRequest.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(sampledValue =>
+ isNotEmptyString(getRawSignedMeterValuePublicKey(sampledValue))
+ )
+ ) === true
+ )
+ if (
+ (connectorStatus.transactionEventQueue?.length ?? 0) >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH ||
+ getTransactionEventQueueBytes(connectorStatus.transactionEventQueue ?? []) >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ ) {
+ throw new TransactionEventQueueCapacityError()
+ }
+ if (stagedEvent != null) {
+ deliveryAttemptedByThisInvocation = markTransactionEventQueueDeliveryAttempted(
+ connectorStatus,
+ stagedEvent
+ )
+ }
+ if (deliveryAttemptedByThisInvocation) {
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ } catch (error: unknown) {
+ throw new TransactionEventDeliveryError('write-ahead-failed', error, false, true)
+ }
+ ensureTerminalDeliveryIsCurrent()
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed')
+ )
+ }
+ if (eventType === OCPP20TransactionEventEnumType.Ended) {
+ OCPP20ServiceUtils.consumeEndedIntervalEnergyCarry(
+ connectorStatus,
+ transactionEventRequest.meterValue
+ )
+ }
+ }
+ ensureTerminalDeliveryIsCurrent()
+ const response = await OCPP20ServiceUtils.sendBuiltTransactionEvent(
+ chargingStation,
+ transactionEventRequest,
+ {
+ ...requestParams,
+ onError: (error, isCallError) => {
+ requestParams?.onError?.(error, isCallError)
+ },
+ onMessageSent: requestParams?.onMessageSent,
+ onResponseReceived: () => {
+ finishPendingDelivery()
+ requestParams?.onResponseReceived?.()
+ },
+ skipBufferingOnError: true,
+ },
+ lifecycleAbortSignal,
+ terminalDeliveryIsCurrent
+ )
+ removeStagedEvent()
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw new TransactionEventDeliveryError(
+ 'aborted',
+ new BaseError('TransactionEvent lifecycle generation was sealed')
+ )
+ }
+ return response
+ } catch (error) {
+ if (terminalDeliveryIsCurrent?.() === false) throw error
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw error
+ }
+ const definitelyUnsent =
+ error instanceof TransactionEventDeliveryError && error.definitelyUnsent
+ const retainForLater =
+ error instanceof TransactionEventDeliveryError &&
+ (error.outcome === 'aborted' ||
+ error.outcome === 'offline' ||
+ error.outcome === 'response-handling-failed' ||
+ error.outcome === 'write-ahead-failed' ||
+ definitelyUnsent)
+ if (retainForLater && stagedEvent != null) {
+ if (definitelyUnsent) {
+ const rollbackPersisted =
+ await OCPP20ServiceUtils.rollbackDefinitelyUnsentTransactionEvent(
+ chargingStation,
+ connectorStatus,
+ stagedEvent,
+ publicKeyDeliveryToken,
+ deliveryAttemptedByThisInvocation,
+ lifecycleAbortSignal
+ )
+ if (!rollbackPersisted) {
+ if (
+ !OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ throw error
+ }
+ return { idTokenInfo: undefined }
+ }
+ if (
+ error.outcome === 'write-ahead-failed' &&
+ chargingStation.isWebSocketConnectionOpened() &&
+ chargingStation.inAcceptedState()
+ ) {
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ }
+ } else {
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, false)
+ chargingStation.saveTransactionEventQueues()
+ }
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ logger.info(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Delivery interrupted, retaining TransactionEvent with seqNo=${transactionEventRequest.seqNo.toString()} for replay`
+ )
+ return { idTokenInfo: undefined }
+ }
+ removeStagedEvent()
+ if (
+ error instanceof TransactionEventDeliveryError &&
+ (error.outcome === 'pre-send-failed' ||
+ (error.outcome === 'exhausted' && error.confirmedRejected))
+ ) {
+ OCPP20ServiceUtils.preserveDiscardedUpdatedIntervalEnergy(
+ connectorStatus,
+ transactionEventRequest
+ )
+ chargingStation.saveTransactionEventQueues()
+ }
+ const definitelyRejected =
+ error instanceof TransactionEventQueueCapacityError ||
+ error instanceof TransactionEventSignedEvidenceError ||
+ (error instanceof TransactionEventDeliveryError &&
+ (error.outcome === 'pre-send-failed' ||
+ error.confirmedRejected ||
+ error.definitelyUnsent))
+ if (definitelyRejected) {
+ OCPP20ServiceUtils.transferRejectedTransactionEventPublicKey(
+ connectorStatus,
+ transactionEventRequest,
+ publicKeyDeliveryToken
+ )
+ chargingStation.saveTransactionEventQueues()
+ } else {
+ retainPublicKeyDelivery(publicKeyDeliveryToken)
+ }
+ throw error
+ } finally {
+ if (
+ stagedEvent != null &&
+ terminalDeliveryIsCurrent?.() !== false &&
+ connectorStatus.transactionEventQueue?.includes(stagedEvent) === true &&
+ OCPP20ServiceUtils.isLifecycleGenerationCurrent(
+ chargingStation,
+ lifecycleAbortSignal
+ )
+ ) {
+ setTransactionEventQueueStaged(connectorStatus, stagedEvent, false)
+ }
+ }
+ }
+ return await deliver()
+ } finally {
+ finishPendingDelivery()
+ }
+ }
+ return isReservedTriggeredDelivery || queuedBehindTransactionEventDelivery
+ ? await performDelivery()
+ : await OCPP20ServiceUtils.serializeTransactionEventDelivery(
+ connectorStatus,
+ performDelivery,
+ eventType === OCPP20TransactionEventEnumType.Ended
)
+ } catch (error) {
+ if (OCPP20ServiceUtils.isLifecycleGenerationCurrent(chargingStation, lifecycleAbortSignal)) {
+ releasePublicKeyDelivery(publicKeyDeliveryToken)
}
- return { connectorStatus, transactionId }
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.sendTransactionEvent: Failed to send TransactionEvent:`,
+ error
+ )
+ throw error
+ } finally {
+ transactionMeterValueDelivery?.settle()
}
- throw new OCPPError(
- ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
- `No active transaction on connector ${connectorId.toString()}`
- )
+ }
+
+ private static async serializeTransactionEventDelivery<T>(
+ connectorStatus: ConnectorStatus,
+ operation: (previous?: Promise<unknown>) => Promise<T>,
+ stageBeforePrevious = false
+ ): Promise<T> {
+ const previous = OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus)
+ const { promise, resolve } = Promise.withResolvers<undefined>()
+ OCPP20ServiceUtils.transactionEventSendChains.set(connectorStatus, promise)
+ try {
+ if (!stageBeforePrevious && previous != null) {
+ await previous.catch(() => undefined)
+ }
+ return await operation(stageBeforePrevious ? previous : undefined)
+ } finally {
+ resolve(undefined)
+ if (OCPP20ServiceUtils.transactionEventSendChains.get(connectorStatus) === promise) {
+ OCPP20ServiceUtils.transactionEventSendChains.delete(connectorStatus)
+ }
+ }
+ }
+
+ private static settleQueuedTransactionMeterValueDelivery (
+ queuedEvent: QueuedTransactionEvent,
+ definitivelyRejected: boolean
+ ): void {
+ const delivery = OCPP20ServiceUtils.transactionMeterValueDeliveries.get(queuedEvent)
+ if (delivery == null) return
+ OCPP20ServiceUtils.transactionMeterValueDeliveries.delete(queuedEvent)
+ delivery.settle(definitivelyRejected)
}
private static async terminateTransaction (
transactionId: string,
triggerReason: OCPP20TriggerReasonEnumType,
stoppedReason: OCPP20ReasonEnumType,
- evseId?: number
+ evseId: number | undefined,
+ operation: StopTransactionOperation
): Promise<OCPP20TransactionEventResponse> {
- this.stopEndedMeterValues(chargingStation, connectorId)
- const endedMeterValues = this.buildTransactionEndedMeterValues(
- chargingStation,
- connectorId,
+ const transactionEndingAtEntry = connectorStatus.transactionEnding
+ const transactionUpdatedSamplerRunning =
+ connectorStatus.transactionUpdatedMeterValuesSetInterval != null
+ connectorStatus.transactionEnding = true
+ OCPP20ServiceUtils.stopUpdatedMeterValues(chargingStation, connectorId, evseId)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+ const meterValueDependencies = await TransactionMeterValueDeliveryBarrier.wait(
+ connectorStatus,
transactionId
)
+ const operationOwnsTransaction = (): boolean => {
+ if (
+ operation.cancellationController.signal.aborted ||
+ chargingStation.lifecycleAbortSignal.aborted
+ ) {
+ return false
+ }
+ try {
+ return (
+ chargingStation.getConnectorStatus(connectorId, evseId) === connectorStatus &&
+ connectorStatus.transactionId?.toString() === transactionId &&
+ OCPP20ServiceUtils.stopTransactionOperations.get(connectorStatus) === operation
+ )
+ } catch {
+ return false
+ }
+ }
+ if (!operationOwnsTransaction()) {
+ throw new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${chargingStation.logPrefix()} ${moduleName}.terminateTransaction: Transaction ${transactionId} no longer owns connector ${connectorId.toString()}`,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ }
+ const checkpoint = {
+ publicKeySentInTransaction: connectorStatus.publicKeySentInTransaction,
+ transactionEndedMeterValues:
+ connectorStatus.transactionEndedMeterValues == null
+ ? undefined
+ : clone(connectorStatus.transactionEndedMeterValues),
+ transactionEndedSamplerRunning:
+ connectorStatus.transactionEndedMeterValuesSetInterval != null,
+ transactionEnding: transactionEndingAtEntry,
+ transactionEnergyActiveImportIntervalBaselines:
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalBaselines },
+ transactionEnergyActiveImportIntervalCarry:
+ connectorStatus.transactionEnergyActiveImportIntervalCarry == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalCarry },
+ transactionEvseSent: connectorStatus.transactionEvseSent,
+ transactionIdTokenSent: connectorStatus.transactionIdTokenSent,
+ transactionSeqNo: connectorStatus.transactionSeqNo,
+ transactionUpdatedSamplerRunning,
+ }
+ let preparedIntervalCarry: Record<string, number> | undefined
+ let preparedPublicKeySentInTransaction: boolean | undefined
+ let terminalPayloadPrepared = false
+ const restoreRetrySensitiveState = (restartSamplers = true): void => {
+ connectorStatus.publicKeySentInTransaction =
+ terminalPayloadPrepared &&
+ connectorStatus.publicKeySentInTransaction !== preparedPublicKeySentInTransaction
+ ? connectorStatus.publicKeySentInTransaction
+ : checkpoint.publicKeySentInTransaction
+ connectorStatus.transactionEnding = checkpoint.transactionEnding
+ connectorStatus.transactionEvseSent = checkpoint.transactionEvseSent
+ connectorStatus.transactionIdTokenSent = checkpoint.transactionIdTokenSent
+ connectorStatus.transactionSeqNo = checkpoint.transactionSeqNo
+ if (checkpoint.transactionEndedMeterValues == null) {
+ delete connectorStatus.transactionEndedMeterValues
+ } else {
+ connectorStatus.transactionEndedMeterValues = clone(checkpoint.transactionEndedMeterValues)
+ }
+ if (checkpoint.transactionEnergyActiveImportIntervalBaselines == null) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ ...checkpoint.transactionEnergyActiveImportIntervalBaselines,
+ }
+ }
+ const retryIntervalCarry = {
+ ...(checkpoint.transactionEnergyActiveImportIntervalCarry ?? {}),
+ }
+ if (preparedIntervalCarry != null) {
+ for (const [key, currentValue] of Object.entries(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry ?? {}
+ )) {
+ const addedAfterPreparation = currentValue - (preparedIntervalCarry[key] ?? 0)
+ if (Number.isFinite(addedAfterPreparation) && addedAfterPreparation > 0) {
+ retryIntervalCarry[key] = (retryIntervalCarry[key] ?? 0) + addedAfterPreparation
+ }
+ }
+ }
+ if (isEmpty(retryIntervalCarry)) {
+ delete connectorStatus.transactionEnergyActiveImportIntervalCarry
+ } else {
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = retryIntervalCarry
+ }
+ if (
+ restartSamplers &&
+ checkpoint.transactionEndedSamplerRunning &&
+ connectorStatus.transactionEndedMeterValuesSetInterval == null
+ ) {
+ OCPP20ServiceUtils.startEndedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxEndedInterval(chargingStation),
+ evseId
+ )
+ }
+ if (
+ restartSamplers &&
+ checkpoint.transactionUpdatedSamplerRunning &&
+ connectorStatus.transactionUpdatedMeterValuesSetInterval == null
+ ) {
+ OCPP20ServiceUtils.startUpdatedMeterValues(
+ chargingStation,
+ connectorId,
+ OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation),
+ evseId
+ )
+ }
+ }
+ operation.restoreRetrySensitiveState = () => {
+ restoreRetrySensitiveState(false)
+ }
- const response = await this.sendTransactionEvent(
- chargingStation,
- OCPP20TransactionEventEnumType.Ended,
- triggerReason,
- connectorId,
- transactionId,
- {
- evseId,
- meterValue: isNotEmptyArray(endedMeterValues) ? endedMeterValues : undefined,
- stoppedReason,
+ let response: OCPP20TransactionEventResponse
+ try {
+ this.stopEndedMeterValues(chargingStation, connectorId, evseId)
+ const endedMeterValues = this.buildTransactionEndedMeterValues(
+ chargingStation,
+ connectorId,
+ transactionId,
+ evseId
+ )
+ terminalPayloadPrepared = true
+ preparedIntervalCarry =
+ connectorStatus.transactionEnergyActiveImportIntervalCarry == null
+ ? undefined
+ : { ...connectorStatus.transactionEnergyActiveImportIntervalCarry }
+ preparedPublicKeySentInTransaction = connectorStatus.publicKeySentInTransaction
+ response = await this.sendTransactionEventWithPredecessors(
+ chargingStation,
+ OCPP20TransactionEventEnumType.Ended,
+ triggerReason,
+ connectorId,
+ transactionId,
+ {
+ evseId,
+ meterValue: isNotEmptyArray(endedMeterValues) ? endedMeterValues : undefined,
+ stoppedReason,
+ },
+ undefined,
+ {
+ dependencies: meterValueDependencies,
+ onPersisted: queuedEvent => {
+ if (!operationOwnsTransaction()) return
+ delete operation.restorePreDurableEndedQueue
+ operation.durableEndedEvent = queuedEvent
+ },
+ onRolledBack: () => {
+ delete operation.restorePreDurableEndedQueue
+ },
+ onStaging: restoreQueue => {
+ if (operationOwnsTransaction()) operation.restorePreDurableEndedQueue = restoreQueue
+ },
+ },
+ undefined,
+ operationOwnsTransaction,
+ operation.cancellationController.signal
+ )
+ } catch (error) {
+ if (
+ !terminalPayloadPrepared ||
+ error instanceof TransactionEventQueueCapacityError ||
+ error instanceof TransactionEventSignedEvidenceError ||
+ (error instanceof TransactionEventDeliveryError && error.outcome === 'write-ahead-failed')
+ ) {
+ if (operationOwnsTransaction()) {
+ restoreRetrySensitiveState()
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ } catch (persistenceError: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.terminateTransaction: Failed to persist restored transaction state:`,
+ persistenceError
+ )
+ }
+ }
+ throw error
}
- )
+ if (operationOwnsTransaction()) {
+ await OCPP20ServiceUtils.cleanupEndedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId,
+ transactionId
+ )
+ }
+ if (hasQueuedEndedTransactionEvent(connectorStatus, transactionId)) {
+ try {
+ await chargingStation.persistTransactionEventQueues()
+ if (chargingStation.isWebSocketConnectionOpened() && chargingStation.inAcceptedState()) {
+ OCPP20ServiceUtils.scheduleTransactionEventQueueDrain(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId
+ )
+ }
+ } catch (persistenceError: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.terminateTransaction: Failed to persist retained Ended TransactionEvent:`,
+ persistenceError
+ )
+ }
+ }
+ throw error
+ }
+
+ if (
+ isNotEmptyArray(meterValueDependencies) &&
+ hasQueuedEndedTransactionEvent(connectorStatus, transactionId)
+ ) {
+ return response
+ }
- await OCPP20ServiceUtils.cleanupEndedTransaction(chargingStation, connectorId, connectorStatus)
+ if (operationOwnsTransaction()) {
+ await OCPP20ServiceUtils.cleanupEndedTransaction(
+ chargingStation,
+ connectorId,
+ connectorStatus,
+ evseId,
+ transactionId
+ )
+ }
return response
}
+
+ /**
+ * Preserves a definitively rejected OncePerTransaction public key exactly once: an
+ * existing queued copy wins, otherwise the next valid signed event receives it. Malformed
+ * signing metadata is never copied; its active transaction reservation is reopened instead.
+ * @param connectorStatus - Connector owning the transaction queue and reservation
+ * @param rejectedRequest - Rejected event that carried the reserved public key
+ * @param token - Exact active-transaction delivery ownership, if one exists
+ * @param allowPayloadMutation - Whether the key may be copied into a queued payload
+ */
+ private static transferRejectedTransactionEventPublicKey (
+ connectorStatus: ConnectorStatus,
+ rejectedRequest: OCPP20TransactionEventRequest,
+ token: PublicKeyDeliveryToken | undefined,
+ allowPayloadMutation = true
+ ): void {
+ const rejectedSamples =
+ rejectedRequest.meterValue?.flatMap(meterValue => meterValue.sampledValue) ?? []
+ const rawPublicKey = rejectedSamples
+ .map(getRawSignedMeterValuePublicKey)
+ .find(key => isNotEmptyString(key))
+ const publicKey = rejectedSamples
+ .map(sampledValue => getMutableSignedMeterValue(sampledValue)?.publicKey)
+ .find(key => isNotEmptyString(key))
+ if (rawPublicKey == null || publicKey == null) {
+ releasePublicKeyDelivery(token)
+ return
+ }
+
+ const transactionId = rejectedRequest.transactionInfo.transactionId
+ const reservationIsActive = connectorStatus.transactionId?.toString() === transactionId
+ const remainingEvents = (connectorStatus.transactionEventQueue ?? []).filter(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === transactionId
+ )
+ const retainedCarrier = remainingEvents.find(queuedEvent =>
+ queuedTransactionEventHasPublicKey(queuedEvent, transactionId)
+ )?.request
+ if (retainedCarrier != null) {
+ if (token != null) {
+ retainPublicKeyDelivery(transferPublicKeyDelivery(token, retainedCarrier))
+ }
+ return
+ }
+ if (!allowPayloadMutation) {
+ releasePublicKeyDelivery(token)
+ return
+ }
+
+ for (const queuedEvent of remainingEvents) {
+ const stagedTransferTarget =
+ isTransactionEventQueueStaged(queuedEvent) &&
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.deliveryAttempted !== true
+ if (
+ queuedEvent.deliveryAttempted === true ||
+ (isTransactionEventQueueStaged(queuedEvent) && !stagedTransferTarget)
+ ) {
+ continue
+ }
+ const nextSignedMeterValue = queuedEvent.request.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(getMutableSignedMeterValue)
+ .find(signedMeterValue => signedMeterValue != null)
+ if (nextSignedMeterValue == null) continue
+ if (reservationIsActive && token == null) return
+ const replacementToken = transferPublicKeyDelivery(token, queuedEvent.request)
+ if (token != null && replacementToken == null) return
+ nextSignedMeterValue.publicKey = publicKey
+ invalidateTransactionEventQueueAccounting(connectorStatus)
+ retainPublicKeyDelivery(replacementToken)
+ return
+ }
+ releasePublicKeyDelivery(token)
+ }
}
/**
: OCPP20TriggerReasonEnumType.Authorized
const triggerReason = commandParams.triggerReason ?? defaultTriggerReason
const inputEvse = commandParams.evse
- const connectorId = commandParams.connectorId ?? inputEvse?.connectorId ?? inputEvse?.id ?? 1
- const transactionId =
- commandParams.transactionId ??
- (eventType === OCPP20TransactionEventEnumType.Ended
- ? (chargingStation.getConnectorStatus(connectorId)?.transactionId?.toString() ??
- generateUUID())
- : generateUUID())
-
- if (!validateIdentifierString(transactionId, 36)) {
- const errorMsg = `Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}`
+ if (
+ commandParams.evseId != null &&
+ inputEvse?.id != null &&
+ commandParams.evseId !== inputEvse.id
+ ) {
+ const errorMsg = `Conflicting EVSE IDs ${commandParams.evseId.toString()} and ${inputEvse.id.toString()}`
logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`)
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
-
- const evseId = commandParams.evseId ?? chargingStation.getEvseIdByConnectorId(connectorId)
- if (evseId == null) {
- const errorMsg = `Cannot find EVSE ID for connector ${connectorId.toString()}`
+ if (
+ commandParams.connectorId != null &&
+ inputEvse?.connectorId != null &&
+ commandParams.connectorId !== inputEvse.connectorId
+ ) {
+ const errorMsg = `Conflicting connector IDs ${commandParams.connectorId.toString()} and ${inputEvse.connectorId.toString()}`
logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`)
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const suppliedEvseId = commandParams.evseId ?? inputEvse?.id
+ const suppliedConnectorId = commandParams.connectorId ?? inputEvse?.connectorId
+ let connectorId: number
+ let evseId: number
+ let connectorStatus: ConnectorStatus | undefined
+ if (chargingStation.hasEvses) {
+ if (suppliedEvseId != null) {
+ evseId = suppliedEvseId
+ } else if (suppliedConnectorId != null) {
+ const matchingEvseIds = [
+ ...new Set(
+ chargingStation
+ .iterateConnectors()
+ .filter(candidate => candidate.connectorId === suppliedConnectorId)
+ .map(candidate => candidate.evseId)
+ .filter((candidateEvseId): candidateEvseId is number => candidateEvseId != null)
+ ),
+ ]
+ if (matchingEvseIds.length !== 1) {
+ const errorMsg = isEmpty(matchingEvseIds)
+ ? `Cannot find EVSE ID for connector ${suppliedConnectorId.toString()}`
+ : `Connector ${suppliedConnectorId.toString()} is ambiguous without an EVSE ID`
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`
+ )
+ throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
+ }
+ evseId = matchingEvseIds[0]
+ } else {
+ evseId = 1
+ }
+ const resolvedConnectorId =
+ suppliedConnectorId ?? chargingStation.getConnectorIdByEvseId(evseId)
+ if (resolvedConnectorId == null) {
+ const errorMsg = `Cannot find a connector for EVSE ${evseId.toString()}`
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`
+ )
+ throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
+ }
+ connectorId = resolvedConnectorId
+ connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ } else {
+ connectorId = suppliedConnectorId ?? suppliedEvseId ?? 1
+ evseId = suppliedEvseId ?? connectorId
+ connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ }
if (connectorStatus == null) {
- const errorMsg = `Cannot find connector status for connector ${connectorId.toString()}`
+ const errorMsg = `Cannot find connector status for connector ${connectorId.toString()} on EVSE ${evseId.toString()}`
logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`)
throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
}
- if (connectorStatus.transactionSeqNo == null) {
- connectorStatus.transactionSeqNo = 0
- } else {
- connectorStatus.transactionSeqNo = connectorStatus.transactionSeqNo + 1
+ const transactionId =
+ commandParams.transactionId ??
+ (eventType === OCPP20TransactionEventEnumType.Ended
+ ? (connectorStatus.transactionId?.toString() ?? generateUUID())
+ : generateUUID())
+
+ if (!validateIdentifierString(transactionId, 36)) {
+ const errorMsg = `Invalid transaction ID format (must be non-empty string ≤36 characters): ${transactionId}`
+ logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`)
+ throw new OCPPError(ErrorType.PROPERTY_CONSTRAINT_VIOLATION, errorMsg)
+ }
+
+ const currentSeqNo = connectorStatus.transactionSeqNo
+ if (
+ currentSeqNo != null &&
+ (!Number.isSafeInteger(currentSeqNo) ||
+ currentSeqNo < 0 ||
+ currentSeqNo === Number.MAX_SAFE_INTEGER ||
+ (currentSeqNo === Number.MAX_SAFE_INTEGER - 1 &&
+ eventType !== OCPP20TransactionEventEnumType.Ended))
+ ) {
+ const errorMsg = `Cannot allocate a safe transaction sequence number after ${currentSeqNo.toString()}${eventType !== OCPP20TransactionEventEnumType.Ended ? ' while reserving the final value for Ended' : ''}`
+ logger.error(`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEvent: ${errorMsg}`)
+ throw new OCPPError(ErrorType.INTERNAL_ERROR, errorMsg)
}
+ connectorStatus.transactionSeqNo = currentSeqNo == null ? 0 : currentSeqNo + 1
// E01.FR.16: only include EVSE in first TransactionEvent
let evse: OCPP20EVSEType | undefined
const transactionEventRequest: OCPP20TransactionEventRequest = {
eventType,
seqNo: connectorStatus.transactionSeqNo,
- timestamp: new Date(),
+ timestamp: commandParams.timestamp ?? new Date(),
transactionInfo,
triggerReason,
}
-import { millisecondsToSeconds } from 'date-fns'
+import { millisecondsToSeconds, secondsToMilliseconds } from 'date-fns'
import { BaseError } from '../../../exception/index.js'
import {
const computeConfigurationKeyName = (variableMetadata: VariableMetadata): string =>
buildConfigKey(variableMetadata.component, variableMetadata.variable, variableMetadata.instance)
+const isEvseScopedVariable = (componentName: string, variableName: string): boolean =>
+ componentName.toLowerCase() === OCPP20ComponentName.AlignedDataCtrlr.toLowerCase() &&
+ variableName.toLowerCase() === OCPP20OptionalVariableName.SendDuringIdle.toLowerCase()
+
+const buildScopedVariableKey = (component: ComponentType, variable: VariableType): string => {
+ const baseKey = buildCaseInsensitiveCompositeKey(
+ component.name,
+ component.instance,
+ variable.name
+ )
+ if (component.evse == null || !isEvseScopedVariable(component.name, variable.name)) return baseKey
+ return `${baseKey}.evse.${component.evse.id.toString()}`
+}
+
+const computeScopedConfigurationKeyName = (
+ variableMetadata: VariableMetadata,
+ component: ComponentType
+): string => {
+ if (
+ component.evse == null ||
+ !isEvseScopedVariable(variableMetadata.component, variableMetadata.variable)
+ ) {
+ return computeConfigurationKeyName(variableMetadata)
+ }
+ const evseScope = `EVSE.${component.evse.id.toString()}`
+ return buildConfigKey(
+ variableMetadata.component,
+ variableMetadata.variable,
+ [variableMetadata.instance, evseScope].filter(value => value != null).join('.')
+ )
+}
+
export class OCPP20VariableManager {
private static instance: null | OCPP20VariableManager = null
return OCPP20VariableManager.instance
}
+ /**
+ * Enumerates every supported component scope for a registry variable.
+ * Station-scoped variables return one component; EVSE-scoped variables also
+ * return one component per physical EVSE.
+ * @param chargingStation - Station whose Device Model topology is reported.
+ * @param variableMetadata - Canonical registry metadata.
+ * @returns Supported component descriptors in stable station/EVSE order.
+ */
+ public getSupportedComponents (
+ chargingStation: ChargingStation,
+ variableMetadata: VariableMetadata
+ ): ComponentType[] {
+ const component: ComponentType = { name: variableMetadata.component }
+ if (!isEvseScopedVariable(variableMetadata.component, variableMetadata.variable)) {
+ return [component]
+ }
+ const components = [component]
+ for (const { evseId } of chargingStation.iterateEvses()) {
+ if (evseId > 0) components.push({ evse: { id: evseId }, name: variableMetadata.component })
+ }
+ return components
+ }
+
public getVariables (
chargingStation: ChargingStation,
getVariableData: OCPP20GetVariableDataType[]
*/
public resolveConfigurationKeyName (
name: string
- ): undefined | { component: string; instance?: string; variable: string } {
- return this.#configurationKeyNameToVariable.get(name)
+ ): undefined | { component: string; evseId?: number; instance?: string; variable: string } {
+ const directMatch = this.#configurationKeyNameToVariable.get(name)
+ if (directMatch != null) return directMatch
+
+ const scopedMatch = /^(.*)\.EVSE\.(\d+)$/.exec(name)
+ if (scopedMatch == null) return undefined
+ const baseMatch = this.#configurationKeyNameToVariable.get(scopedMatch[1])
+ if (baseMatch == null || !isEvseScopedVariable(baseMatch.component, baseMatch.variable)) {
+ return undefined
+ }
+ return { ...baseMatch, evseId: Number(scopedMatch[2]) }
}
public setVariables (
if (resolvedAttr !== AttributeEnumType.MinSet && resolvedAttr !== AttributeEnumType.MaxSet) {
continue
}
- const varKey = buildCaseInsensitiveCompositeKey(
- variableData.component.name,
- variableData.component.instance,
- variableData.variable.name
- )
+ const varKey = buildScopedVariableKey(variableData.component, variableData.variable)
const pairedBoundsEntry = pairedBounds.get(varKey) ?? {}
if (resolvedAttr === AttributeEnumType.MinSet) {
pairedBoundsEntry.minValue = variableData.attributeValue
) {
continue
}
- const itemKey = buildCaseInsensitiveCompositeKey(
- data.component.name,
- data.component.instance,
- data.variable.name
- )
+ const itemKey = buildScopedVariableKey(data.component, data.variable)
if (itemKey !== varKey) continue
if (
resolvedAttr === AttributeEnumType.MinSet &&
)
}
- const variableKey = buildCaseInsensitiveCompositeKey(
- component.name,
- component.instance,
- variable.name
- )
+ const variableKey = buildScopedVariableKey(component, variable)
if (invalidVariables.has(variableKey)) {
return this.rejectGet(
variable,
}
}
- private isComponentValid (_chargingStation: ChargingStation, component: ComponentType): boolean {
- return this.#validComponentNames.has(component.name)
+ private isComponentValid (chargingStation: ChargingStation, component: ComponentType): boolean {
+ if (!this.#validComponentNames.has(component.name)) return false
+ if (component.evse?.id === 0) return false
+ if (component.evse == null) return true
+ const evseStatus = chargingStation.getEvseStatus(component.evse.id)
+ if (evseStatus == null) return false
+ return (
+ component.evse.connectorId == null || evseStatus.connectors.has(component.evse.connectorId)
+ )
}
private isVariableSupported (component: ComponentType, variable: VariableType): boolean {
+ if (
+ component.name.toLowerCase() === OCPP20ComponentName.AlignedDataCtrlr.toLowerCase() &&
+ component.evse != null &&
+ (!isEvseScopedVariable(component.name, variable.name) || component.evse.connectorId != null)
+ ) {
+ return false
+ }
return (
getVariableMetadata(component.name, variable.name, variable.instance ?? component.instance) !=
null || getVariableMetadata(component.name, variable.name) != null
)
if (!variableMetadata) return ''
- const compositeKey = buildCaseInsensitiveCompositeKey(
- component.name,
- component.instance,
- variable.name
- )
+ const compositeKey = buildScopedVariableKey(component, variable)
let value = resolveValue(chargingStation, variableMetadata)
if (isPersistent(variableMetadata) && !isWriteOnly(variableMetadata)) {
- const configurationKeyName = computeConfigurationKeyName(variableMetadata)
+ const evseScoped = isEvseScopedVariable(variableMetadata.component, variableMetadata.variable)
+ const configurationKeyName = computeScopedConfigurationKeyName(variableMetadata, component)
let cfg = getConfigurationKey(chargingStation, configurationKeyName)
- if (cfg == null) {
+ if (cfg == null && evseScoped) {
+ cfg = getConfigurationKey(chargingStation, computeConfigurationKeyName(variableMetadata))
+ }
+ if (cfg == null && !evseScoped) {
addConfigurationKey(
chargingStation,
configurationKeyName,
- value, // Use the resolved default value
+ value,
{
readonly: isReadOnly(variableMetadata),
reboot: variableMetadata.rebootRequired === true,
)
}
- const variableKey = buildCaseInsensitiveCompositeKey(
- component.name,
- component.instance,
- variable.name
- )
+ const variableKey = buildScopedVariableKey(component, variable)
if (invalidVariables.has(variableKey) && resolvedAttributeType === AttributeEnumType.Actual) {
if (!isWriteOnly(variableMetadata)) {
return this.rejectSet(
}
let rebootRequired = false
- const configurationKeyName = computeConfigurationKeyName(variableMetadata)
- const previousValue = getConfigurationKey(chargingStation, configurationKeyName)?.value
+ const configurationKeyName = computeScopedConfigurationKeyName(variableMetadata, component)
+ const previousValue =
+ this.getRuntimeOverrides(stationId).get(variableKey) ??
+ getConfigurationKey(chargingStation, configurationKeyName)?.value
+
+ const componentName = component.name.toLowerCase()
+ const variableName = variable.name.toLowerCase()
+ const changesEnergyMeasurands =
+ previousValue !== attributeValue &&
+ ((componentName === OCPP20ComponentName.SampledDataCtrlr.toLowerCase() &&
+ (variableName === OCPP20RequiredVariableName.TxUpdatedMeasurands.toLowerCase() ||
+ variableName === OCPP20RequiredVariableName.TxEndedMeasurands.toLowerCase())) ||
+ (componentName === OCPP20ComponentName.AlignedDataCtrlr.toLowerCase() &&
+ variableName === OCPP20RequiredVariableName.Measurands.toLowerCase()))
+ if (changesEnergyMeasurands) chargingStation.settleTransactionEnergyMeterValues()
if (isPersistent(variableMetadata) && !isWriteOnly(variableMetadata)) {
const configKey = getConfigurationKey(chargingStation, configurationKeyName)
readonly: isReadOnly(variableMetadata),
reboot: variableMetadata.rebootRequired === true,
},
- { overwrite: false }
+ { overwrite: false, save: true }
)
} else if (configKey.value !== attributeValue) {
setConfigurationKeyValue(chargingStation, configurationKeyName, attributeValue)
) {
chargingStation.restartWebSocketPing()
}
+ if (component.name.toLowerCase() === OCPP20ComponentName.AlignedDataCtrlr.toLowerCase()) {
+ const variableName = variable.name.toLowerCase()
+ if (variableName === OCPP20RequiredVariableName.AlignedDataInterval.toLowerCase()) {
+ const alignedDataInterval = convertToIntOrNaN(attributeValue)
+ const previousAlignedDataInterval = convertToIntOrNaN(previousValue)
+ if (
+ !Number.isNaN(alignedDataInterval) &&
+ alignedDataInterval >= 0 &&
+ previousValue !== attributeValue
+ ) {
+ chargingStation.restartAlignedMeterValues(
+ !Number.isNaN(previousAlignedDataInterval) && previousAlignedDataInterval > 0
+ ? secondsToMilliseconds(previousAlignedDataInterval)
+ : undefined
+ )
+ }
+ } else if (variableName === OCPP20RequiredVariableName.Enabled.toLowerCase()) {
+ if (attributeValue.trim().toLowerCase() === 'true') {
+ if (previousValue?.trim().toLowerCase() !== 'true') {
+ chargingStation.restartAlignedMeterValues()
+ }
+ } else if (previousValue?.trim().toLowerCase() === 'true') {
+ chargingStation.restartAlignedMeterValues(undefined, true)
+ } else {
+ chargingStation.stopAlignedMeterValues()
+ }
+ }
+ }
// Apply volatile runtime override generically (single location)
if (isVolatile(variableMetadata)) {
this.getRuntimeOverrides(stationId).set(variableKey, attributeValue)
}
+ if (component.name.toLowerCase() === OCPP20ComponentName.SampledDataCtrlr.toLowerCase()) {
+ const variableName = variable.name.toLowerCase()
+ const intervalSeconds = convertToIntOrNaN(attributeValue)
+ const previousIntervalSeconds = convertToIntOrNaN(
+ previousValue ??
+ (variableName === OCPP20RequiredVariableName.TxUpdatedInterval.toLowerCase()
+ ? Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS.toString()
+ : '0')
+ )
+ if (
+ !Number.isNaN(intervalSeconds) &&
+ intervalSeconds >= 0 &&
+ previousValue !== attributeValue
+ ) {
+ const previousInterval =
+ !Number.isNaN(previousIntervalSeconds) && previousIntervalSeconds > 0
+ ? secondsToMilliseconds(previousIntervalSeconds)
+ : undefined
+ if (variableName === OCPP20RequiredVariableName.TxUpdatedInterval.toLowerCase()) {
+ chargingStation.restartTransactionMeterValues(
+ 'updated',
+ secondsToMilliseconds(intervalSeconds),
+ previousInterval
+ )
+ } else if (variableName === OCPP20RequiredVariableName.TxEndedInterval.toLowerCase()) {
+ chargingStation.restartTransactionMeterValues(
+ 'ended',
+ secondsToMilliseconds(intervalSeconds),
+ previousInterval
+ )
+ }
+ }
+ }
if (rebootRequired) {
return {
supportedAttributes: [AttributeEnumType.Actual],
variable: 'Available',
},
- [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, 'SendDuringIdle')]: {
+ [buildRegistryKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SendDuringIdle
+ )]: {
component: OCPP20ComponentName.AlignedDataCtrlr,
dataType: DataEnumType.boolean,
defaultValue: 'false',
mutability: MutabilityEnumType.ReadWrite,
persistence: PersistenceEnumType.Persistent,
supportedAttributes: [AttributeEnumType.Actual],
- variable: 'SendDuringIdle',
+ variable: OCPP20OptionalVariableName.SendDuringIdle,
},
[buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20OptionalVariableName.SignReadings)]:
{
OCPP20ComponentName.AlignedDataCtrlr,
OCPP20RequiredVariableName.AlignedDataInterval
)]: {
+ allowZero: true,
component: OCPP20ComponentName.AlignedDataCtrlr,
dataType: DataEnumType.integer,
- defaultValue: '900',
+ defaultValue: Constants.DEFAULT_ALIGNED_DATA_INTERVAL_SECONDS.toString(),
description:
- 'Size (in seconds) of the clock-aligned data interval, intended to be transmitted in the MeterValuesRequest message.',
- min: 1,
+ 'Size (in seconds) of the clock-aligned data interval within one UTC day, intended for MeterValuesRequest or transaction-scoped TransactionEventRequest emission.',
+ max: Constants.SECONDS_PER_DAY,
+ maxLength: 5,
+ min: 0,
mutability: MutabilityEnumType.ReadWrite,
persistence: PersistenceEnumType.Persistent,
required: true,
component: OCPP20ComponentName.OCPPCommCtrlr,
dataType: DataEnumType.integer,
defaultValue: '3',
- description: 'Maximum number of TransactionEvent message attempts after initial send.',
+ description: 'Maximum number of TransactionEvent message attempts, including the initial send.',
instance: OCPP20RequestCommand.TRANSACTION_EVENT,
max: 10,
min: 1,
messageId: string,
messagePayload: JsonType,
commandName: string,
- params?: RequestParams
+ params?: RequestParams,
+ cancellationGenerationAtRequestStart?: number
) => Promise<JsonType>
/**
fn: SendMessageFn
mock: {
calls: {
- arguments: [ChargingStation, string, JsonType, string, RequestParams?]
+ arguments: [ChargingStation, string, JsonType, string, RequestParams?, number?]
}[]
}
}
*/
import type {
+ IncomingRequestCommand,
+ JsonType,
OCPP20CertificateSignedRequest,
OCPP20CertificateSignedResponse,
OCPP20ChangeAvailabilityRequest,
* Interface exposing private handler methods of OCPP20IncomingRequestService for testing.
* Each method signature matches the corresponding private method in the service class.
*/
-interface TestableOCPP20IncomingRequestService {
+export interface TestableOCPP20IncomingRequestService {
/**
* Builds report data for the device model report.
* Used internally by handleRequestGetBaseReport.
handleRequestReset: (
chargingStation: ChargingStation,
commandPayload: OCPP20ResetRequest
- ) => Promise<OCPP20ResetResponse>
+ ) => OCPP20ResetResponse
handleRequestSendLocalList: (
chargingStation: ChargingStation,
presentedGroupIdToken?: OCPP20IdTokenType
) => boolean
+ onResponseSendError: (
+ chargingStation: ChargingStation,
+ commandName: IncomingRequestCommand,
+ commandPayload: JsonType
+ ) => Promise<void>
+
sendFirmwareStatusNotification: (
chargingStation: ChargingStation,
status: OCPP20FirmwareStatusEnumType,
handleRequestUnlockConnector: serviceImpl.handleRequestUnlockConnector.bind(service),
handleRequestUpdateFirmware: serviceImpl.handleRequestUpdateFirmware.bind(service),
isAuthorizedToStopTransaction: serviceImpl.isAuthorizedToStopTransaction.bind(service),
+ onResponseSendError: serviceImpl.onResponseSendError.bind(service),
sendFirmwareStatusNotification: serviceImpl.sendFirmwareStatusNotification.bind(service),
}
}
import { type ChargingStation } from '../../charging-station/index.js'
import { OCPPError } from '../../exception/index.js'
import {
+ AvailabilityType,
ChargingStationEvents,
type ConnectorStatus,
ConnectorStatusEnum,
* Sends a StatusNotification request and updates the connector status locally.
* @param chargingStation - Target charging station
* @param commandParams - Cross-version StatusNotification input; `connectorStatus` (OCPP 2.0.1) takes precedence over `status` (OCPP 1.6)
- * @param options - Optional settings to control whether the request is actually sent
- * @param options.send - Whether to actually send the status notification
+ * @param options - Optional send behavior.
+ * @param options.send - Whether to send the status notification.
+ * @param options.waitForResponse - Whether local completion waits for the CSMS response.
+ * @param options.responseTimeoutMs - Optional CSMS response timeout in milliseconds.
+ * @param options.expectedTransactionId - When set, apply the response locally only while the connector still owns this transaction.
+ * @param options.lifecycleAbortSignal - When set, apply the response only within the captured lifecycle generation.
+ * @param options.isOperationCurrent - When set, apply the response locally only while the owning operation remains current.
*/
export const sendAndSetConnectorStatus = async (
chargingStation: ChargingStation,
commandParams: StatusNotificationOptions,
- options?: { send: boolean }
+ options?: {
+ expectedTransactionId?: number | string
+ isOperationCurrent?: () => boolean
+ lifecycleAbortSignal?: AbortSignal
+ responseTimeoutMs?: number
+ send: boolean
+ waitForResponse?: boolean
+ }
): Promise<void> => {
options = { send: true, ...options }
- const { connectorId, errorCode } = commandParams
+ const { connectorId, errorCode, evseId } = commandParams
const status = commandParams.connectorStatus ?? commandParams.status
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
if (connectorStatus == null) {
return
}
if (options.send) {
- checkConnectorStatusTransition(chargingStation, connectorId, status)
- await chargingStation.ocppRequestService.requestHandler<
+ checkConnectorStatusTransition(chargingStation, connectorId, status, evseId)
+ const response = chargingStation.ocppRequestService.requestHandler<
StatusNotificationOptions,
StatusNotificationResponse
- >(chargingStation, RequestCommand.STATUS_NOTIFICATION, commandParams)
+ >(chargingStation, RequestCommand.STATUS_NOTIFICATION, commandParams, {
+ responseTimeoutMs: options.responseTimeoutMs,
+ })
+ if (options.waitForResponse === false) {
+ response.catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} OCPPConnectorStatusOperations.sendAndSetConnectorStatus: Failed to send connector status:`,
+ error
+ )
+ })
+ } else {
+ await response
+ }
+ }
+ const currentConnectorStatus =
+ options.expectedTransactionId != null
+ ? chargingStation.getConnectorStatus(connectorId, evseId)
+ : connectorStatus
+ if (
+ currentConnectorStatus == null ||
+ (options.lifecycleAbortSignal != null &&
+ (options.lifecycleAbortSignal.aborted ||
+ chargingStation.lifecycleAbortSignal !== options.lifecycleAbortSignal)) ||
+ options.isOperationCurrent?.() === false ||
+ (options.expectedTransactionId != null &&
+ currentConnectorStatus.transactionId !== options.expectedTransactionId)
+ ) {
+ return
}
- connectorStatus.status = status
- connectorStatus.errorCode = errorCode
+ currentConnectorStatus.status = status
+ currentConnectorStatus.errorCode = errorCode
chargingStation.emitChargingStationEvent(ChargingStationEvents.connectorStatusChanged, {
connectorId,
- ...connectorStatus,
+ ...(evseId != null && { evseId }),
+ ...currentConnectorStatus,
})
}
* Re-evaluates station and connector availability to determine the target status.
* @param chargingStation - Target charging station
* @param connectorId - Connector ID to transition
+ * @param evseId - Optional EVSE identifier for EVSE-local connector ids
+ * @param options - Optional send behavior.
+ * @param options.waitForResponse - Whether local completion waits for the CSMS response.
+ * @param options.responseTimeoutMs - Optional CSMS response timeout in milliseconds.
*/
export const sendPostTransactionStatus = async (
chargingStation: ChargingStation,
- connectorId: number
+ connectorId: number,
+ evseId?: number,
+ options?: { responseTimeoutMs?: number; waitForResponse?: boolean }
): Promise<void> => {
const status =
chargingStation.isChargingStationAvailable() &&
- chargingStation.isConnectorAvailable(connectorId)
+ chargingStation.getConnectorStatus(connectorId, evseId)?.availability ===
+ AvailabilityType.Operative
? ConnectorStatusEnum.Available
: ConnectorStatusEnum.Unavailable
- await sendAndSetConnectorStatus(chargingStation, {
- connectorId,
- connectorStatus: status,
- status,
- })
+ await sendAndSetConnectorStatus(
+ chargingStation,
+ {
+ connectorId,
+ connectorStatus: status,
+ evseId,
+ status,
+ },
+ { responseTimeoutMs: options?.responseTimeoutMs, send: true, ...options }
+ )
}
/**
* @param chargingStation - Target charging station
* @param connectorId - Connector ID to restore
* @param connectorStatus - Current connector status to evaluate
+ * @param evseId - Optional EVSE identifier for EVSE-local connector IDs
*/
export const restoreConnectorStatus = async (
chargingStation: ChargingStation,
connectorId: number,
- connectorStatus: ConnectorStatus | undefined
+ connectorStatus: ConnectorStatus | undefined,
+ evseId?: number
): Promise<void> => {
if (
connectorStatus?.reservation != null &&
) {
await sendAndSetConnectorStatus(chargingStation, {
connectorId,
+ ...(evseId != null && { evseId }),
status: ConnectorStatusEnum.Reserved,
})
} else if (connectorStatus?.status !== ConnectorStatusEnum.Available) {
await sendAndSetConnectorStatus(chargingStation, {
connectorId,
+ ...(evseId != null && { evseId }),
status: ConnectorStatusEnum.Available,
})
}
const checkConnectorStatusTransition = (
chargingStation: ChargingStation,
connectorId: number,
- status: ConnectorStatusEnum | undefined
+ status: ConnectorStatusEnum | undefined,
+ evseId?: number
): boolean => {
- const fromStatus = chargingStation.getConnectorStatus(connectorId)?.status
- let chargingStationTransitions: readonly { from?: ConnectorStatusEnum; to: ConnectorStatusEnum }[]
+ const fromStatus = chargingStation.getConnectorStatus(connectorId, evseId)?.status
+ let chargingStationTransitions: readonly {
+ from?: ConnectorStatusEnum
+ to: ConnectorStatusEnum
+ }[]
let connectorTransitions: readonly { from?: ConnectorStatusEnum; to: ConnectorStatusEnum }[]
switch (chargingStation.stationInfo?.ocppVersion) {
case OCPPVersion.VERSION_16:
chargingStation.stationInfo.ocppVersion
} connector id ${connectorId.toString()} status transition from '${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- chargingStation.getConnectorStatus(connectorId)?.status
+ chargingStation.getConnectorStatus(connectorId, evseId)?.status
}' to '${
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
status
})
static readonly OCPP_MEASURANDS_SUPPORTED = Object.freeze([
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
MeterValueMeasurand.CURRENT_IMPORT,
import type { ValidateFunction } from 'ajv'
+import { AsyncLocalStorage } from 'node:async_hooks'
import { EventEmitter } from 'node:events'
import { type ChargingStation } from '../../charging-station/index.js'
* - a lazy-init getter (`getOrCreateStationState`) that returns the
* sealed stopped state instead of resurrecting a fresh entry after
* {@link stop};
+ * - explicit {@link activate} replacement before a new socket lifecycle;
+ * - admission-scoped state that keeps late callbacks on their original entry;
* - a concrete `stop()` template that resets and marks the entry
- * `stopped: true`, preserving the WeakMap entry so late dispatch
- * observes the marker and drops.
+ * `stopped: true`, preserving the WeakMap entry until activation.
*
* Subclass contract:
* - `createStationState` — factory returning the initial state object.
* `no-restricted-syntax` ESLint rule).
*/
protected readonly stationsState = new WeakMap<ChargingStation, TStationState>()
+ private readonly stationStateContext = new AsyncLocalStorage<{
+ chargingStation: ChargingStation
+ messageLifecycleIsCurrent: () => boolean
+ messageLifecycleSignal: AbortSignal
+ messageSourceIsCurrent: () => boolean
+ stationState: TStationState
+ }>()
+
private readonly version: OCPPVersion
protected constructor (version: OCPPVersion) {
value: string
): ConfigurationStatus
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
- public async incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters, @typescript-eslint/no-unused-vars
+ public incomingRequestHandler<ReqType extends JsonType, ResType extends JsonType>(
chargingStation: ChargingStation,
messageId: string,
commandName: IncomingRequestCommand,
- commandPayload: ReqType
+ commandPayload: ReqType,
+ messageLifecycleSignal: AbortSignal = chargingStation.lifecycleAbortSignal,
+ messageLifecycleIsCurrent: () => boolean = () =>
+ chargingStation.lifecycleAbortSignal === messageLifecycleSignal &&
+ !messageLifecycleSignal.aborted,
+ messageSourceIsCurrent: () => boolean = () => true
): Promise<void> {
- let response: ResType
- if (
- chargingStation.stationInfo?.ocppStrictCompliance === true &&
- chargingStation.inPendingState() &&
- this.pendingStateBlockedCommands.includes(commandName)
- ) {
- throw new OCPPError(
- ErrorType.SECURITY_ERROR,
- `${commandName} cannot be issued to handle request PDU ${JSONStringify(commandPayload, 2)} while the charging station is in pending state on the ${this.csmsName}`,
- commandName,
- commandPayload
- )
- }
- if (
- chargingStation.inAcceptedState() ||
- chargingStation.inPendingState() ||
- (chargingStation.stationInfo?.ocppStrictCompliance === false &&
- chargingStation.inUnknownState())
- ) {
- if (
- this.incomingRequestHandlers.has(commandName) &&
- this.isIncomingRequestCommandSupported(chargingStation, commandName)
- ) {
- try {
- this.validateIncomingRequestPayload(chargingStation, commandName, commandPayload)
- const incomingRequestHandler = this.incomingRequestHandlers.get(commandName)
- if (incomingRequestHandler == null) {
- throw new OCPPError(
- ErrorType.NOT_IMPLEMENTED,
- `${commandName} incoming request handler not found`,
- commandName,
- commandPayload
- )
- }
- if (isAsyncFunction(incomingRequestHandler)) {
- response = (await incomingRequestHandler(chargingStation, commandPayload)) as ResType
- } else {
- response = incomingRequestHandler(chargingStation, commandPayload) as ResType
- }
- } catch (error) {
- logger.error(
- `${chargingStation.logPrefix()} ${this.moduleName}.incomingRequestHandler: Handle incoming request error:`,
- error
- )
- throw error
- }
- } else {
- // Throw exception
- throw new OCPPError(
- ErrorType.NOT_IMPLEMENTED,
- `${commandName} is not implemented to handle request PDU ${JSONStringify(commandPayload, 2)}`,
+ const stationState = this.getOrCreateCurrentStationState(chargingStation)
+ return this.stationStateContext.run(
+ {
+ chargingStation,
+ messageLifecycleIsCurrent,
+ messageLifecycleSignal,
+ messageSourceIsCurrent,
+ stationState,
+ },
+ async () => {
+ await this.handleIncomingRequest(
+ chargingStation,
+ messageId,
commandName,
- commandPayload
+ commandPayload,
+ messageLifecycleSignal,
+ messageLifecycleIsCurrent,
+ messageSourceIsCurrent,
+ stationState
)
}
- } else {
- throw new OCPPError(
- ErrorType.SECURITY_ERROR,
- `${commandName} cannot be issued to handle request PDU ${JSONStringify(commandPayload, 2)} while the charging station is not registered on the ${this.csmsName}`,
- commandName,
- commandPayload
- )
- }
- await chargingStation.ocppRequestService.sendResponse(
- chargingStation,
- messageId,
- response,
- commandName
)
- // Emit command name event to allow delayed handling only if there are listeners
- if (this.listenerCount(commandName) > 0) {
- this.emit(commandName, chargingStation, commandPayload, response)
- }
}
/**
* `super.stop()` first so the reset-then-mark ordering is preserved.
*
* The WeakMap entry is NOT deleted — it is marked `stopped: true`
- * after {@link resetStationState} releases its resources.
+ * after {@link resetStationState} releases its resources and remains
+ * sealed until {@link activate} installs the next lifecycle entry.
* Deletion would re-enable resurrection via
* {@link getOrCreateStationState} lazy-init on any late handler
* dispatch. Keeping the sealed entry lets
}
}
+ /**
+ * Installs fresh request-service state for a new station lifecycle.
+ * @param chargingStation - Target charging station
+ * @param lifecycleSignal - Lifecycle being activated
+ */
+ // eslint-disable-next-line perfectionist/sort-classes
+ public activate (chargingStation: ChargingStation, lifecycleSignal: AbortSignal): void {
+ if (
+ chargingStation.lifecycleAbortSignal !== lifecycleSignal ||
+ lifecycleSignal.aborted ||
+ chargingStation.isStopping()
+ ) {
+ return
+ }
+ const previousState = this.stationsState.get(chargingStation)
+ if (previousState != null && previousState.stopped !== true) {
+ this.resetStationState(previousState)
+ previousState.stopped = true
+ }
+ this.stationsState.set(chargingStation, this.createStationState())
+ }
+
/**
* Hook method: creates the initial per-station state, called on first
* access via {@link getOrCreateStationState}.
*/
protected abstract createStationState (): TStationState
- /**
- * Returns the state entry for `chargingStation`, creating one on first
- * access via {@link createStationState}. Subsequent calls return the
- * same reference for the station's active lifecycle.
- *
- * Once {@link stop} has marked the entry `stopped: true`,
- * this method returns the sealed stopped state instead of
- * lazy-init'ing a fresh entry. `.stopped` is written only from
- * {@link stop}, so the guard is unreachable on the pre-stop happy
- * path. Callers that need "silent-drop on stop" semantics MUST NOT
- * rely on this guard alone — they use `stationsState.get(cs)` with
- * an inline `stopped === true` null-guard and return early, because
- * writes performed through this getter still land on the sealed
- * object (harmless but wasteful).
- * @param chargingStation - Target charging station.
- * @returns The lazily-initialized per-station state, or the sealed
- * stopped state after {@link stop}.
- * @see OCPP20IncomingRequestService.sendNotifyReportRequest for the
- * consume-only null-guard pattern.
- * @see OCPP20IncomingRequestService.sendSecurityEventNotification for
- * the lifecycle-entry pattern where creation is required.
- */
protected getOrCreateStationState (chargingStation: ChargingStation): TStationState {
+ const contextualState = this.getContextualStationState(chargingStation)
+ if (contextualState != null) return contextualState
let state = this.stationsState.get(chargingStation)
- if (state?.stopped === true) {
- return state
- }
+ if (state?.stopped === true) return state
if (state == null) {
state = this.createStationState()
this.stationsState.set(chargingStation, state)
return state
}
+ /**
+ * Returns serialized bytes retained by subclass-owned delivery reservations
+ * that remain reachable through a buffered response callback.
+ * @param _commandName - Incoming request command owning the reservation.
+ * @param _commandPayload - Incoming request payload used as the reservation key.
+ * @returns Additional per-response retained bytes.
+ */
+ protected getResponseDeliveryRetainedBytes (
+ _commandName: IncomingRequestCommand,
+ _commandPayload: JsonType
+ ): number {
+ return 0
+ }
+
+ protected getStationState (chargingStation: ChargingStation): TStationState | undefined {
+ return (
+ this.getContextualStationState(chargingStation) ?? this.stationsState.get(chargingStation)
+ )
+ }
+
+ /**
+ * Checks whether the currently executing incoming handler still owns its admission lifecycle.
+ * Direct trusted calls outside incoming dispatch have no admission context and remain valid.
+ * @param chargingStation - Station whose request admission is being checked
+ * @returns Whether the current handler may still mutate station transaction state
+ */
+ protected isIncomingRequestAdmissionCurrent (chargingStation: ChargingStation): boolean {
+ const context = this.stationStateContext.getStore()
+ if (context == null) return true
+ return (
+ context.messageSourceIsCurrent() && this.isIncomingRequestDeliveryCurrent(chargingStation)
+ )
+ }
+
/**
* Whether the given incoming-request command is supported for this station.
* @param chargingStation - Target charging station.
commandName: IncomingRequestCommand
): boolean
+ /**
+ * Checks whether a deferred response action still belongs to its station lifecycle.
+ * Unlike admission ownership, this deliberately permits replay on a replacement socket.
+ * @param chargingStation - Station whose response action is being checked
+ * @returns Whether the response action may still mutate its captured station state
+ */
+ protected isIncomingRequestDeliveryCurrent (chargingStation: ChargingStation): boolean {
+ const context = this.stationStateContext.getStore()
+ if (context == null) return true
+ return (
+ context.chargingStation === chargingStation &&
+ context.messageLifecycleSignal === chargingStation.lifecycleAbortSignal &&
+ !context.messageLifecycleSignal.aborted &&
+ context.messageLifecycleIsCurrent() &&
+ this.stationsState.get(chargingStation) === context.stationState &&
+ context.stationState.stopped !== true &&
+ !chargingStation.isStopping()
+ )
+ }
+
+ /**
+ * Called when an incoming request response could not be sent.
+ * @param _chargingStation - Target charging station.
+ * @param _commandName - Incoming request command whose response failed.
+ * @param _commandPayload - Incoming request payload associated with the failed response.
+ */
+ protected onResponseSendError (
+ _chargingStation: ChargingStation,
+ _commandName: IncomingRequestCommand,
+ _commandPayload: JsonType
+ ): Promise<void> | void {
+ // Optional lifecycle cleanup hook for subclass-owned response reservations.
+ }
+
/**
* Hook method paired with the {@link stop} template: releases
* resources held by the per-station state (abort controllers, timer
*/
protected abstract resetStationState (stationState: TStationState): void
+ /**
+ * Whether a delivered response may run its command callback during shutdown.
+ * @param _commandName - Delivered incoming command
+ * @returns Whether shutdown permits its callback
+ */
+ // eslint-disable-next-line perfectionist/sort-classes
+ protected canDispatchResponseWhileStopping (_commandName: IncomingRequestCommand): boolean {
+ return false
+ }
+
+ /**
+ * Runs lifecycle-changing work outside the incoming-request admission context.
+ * Async resources created by the action therefore resolve station state from
+ * the current lifecycle instead of inheriting the sealed request lifecycle.
+ * @param action - Lifecycle action to invoke without an admission context
+ * @returns The action result
+ */
+ protected runOutsideIncomingRequestContext<T>(action: () => T): T {
+ return this.stationStateContext.exit(action)
+ }
+
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters -- P bridges contravariant handler payload signatures into IncomingRequestHandler
protected toRequestHandler<P extends JsonType>(
handler: (chargingStation: ChargingStation, commandPayload: P) => JsonType | Promise<JsonType>
'incoming request'
)
}
+
+ private getContextualStationState (chargingStation: ChargingStation): TStationState | undefined {
+ const context = this.stationStateContext.getStore()
+ return context?.chargingStation === chargingStation ? context.stationState : undefined
+ }
+
+ /**
+ * Returns the state entry for `chargingStation`, creating one on first
+ * access via {@link createStationState}. Subsequent calls return the
+ * same reference for the station's active lifecycle.
+ *
+ * Once {@link stop} has marked the entry `stopped: true`,
+ * this method returns the sealed stopped state instead of
+ * lazy-init'ing a fresh entry. `.stopped` is written only from
+ * {@link stop}, so the guard is unreachable on the pre-stop happy
+ * path. Callers that need "silent-drop on stop" semantics MUST NOT
+ * rely on this guard alone — they use `stationsState.get(cs)` with
+ * an inline `stopped === true` null-guard and return early, because
+ * writes performed through this getter still land on the sealed
+ * object (harmless but wasteful).
+ * @param chargingStation - Target charging station.
+ * @returns The lazily-initialized per-station state, or the sealed
+ * stopped state after {@link stop}.
+ * @see OCPP20IncomingRequestService.sendNotifyReportRequest for the
+ * consume-only null-guard pattern.
+ * @see OCPP20IncomingRequestService.sendSecurityEventNotification for
+ * the lifecycle-entry pattern where creation is required.
+ */
+ private getOrCreateCurrentStationState (chargingStation: ChargingStation): TStationState {
+ let state = this.stationsState.get(chargingStation)
+ if (state?.stopped === true) return state
+ if (state == null) {
+ state = this.createStationState()
+ this.stationsState.set(chargingStation, state)
+ }
+ return state
+ }
+
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
+ private async handleIncomingRequest<ReqType extends JsonType, ResType extends JsonType>(
+ chargingStation: ChargingStation,
+ messageId: string,
+ commandName: IncomingRequestCommand,
+ commandPayload: ReqType,
+ messageLifecycleSignal: AbortSignal,
+ messageLifecycleIsCurrent: () => boolean,
+ messageSourceIsCurrent: () => boolean,
+ stationState: TStationState
+ ): Promise<void> {
+ if (chargingStation.isStopping()) {
+ throw new OCPPError(
+ ErrorType.SECURITY_ERROR,
+ `${commandName} cannot be issued while the charging station is stopping`,
+ commandName,
+ commandPayload
+ )
+ }
+ let response: ResType
+ if (
+ chargingStation.stationInfo?.ocppStrictCompliance === true &&
+ chargingStation.inPendingState() &&
+ this.pendingStateBlockedCommands.includes(commandName)
+ ) {
+ throw new OCPPError(
+ ErrorType.SECURITY_ERROR,
+ `${commandName} cannot be issued to handle request PDU ${JSONStringify(commandPayload, 2)} while the charging station is in pending state on the ${this.csmsName}`,
+ commandName,
+ commandPayload
+ )
+ }
+ if (
+ chargingStation.inAcceptedState() ||
+ chargingStation.inPendingState() ||
+ (chargingStation.stationInfo?.ocppStrictCompliance === false &&
+ chargingStation.inUnknownState())
+ ) {
+ if (
+ this.incomingRequestHandlers.has(commandName) &&
+ this.isIncomingRequestCommandSupported(chargingStation, commandName)
+ ) {
+ try {
+ this.validateIncomingRequestPayload(chargingStation, commandName, commandPayload)
+ const incomingRequestHandler = this.incomingRequestHandlers.get(commandName)
+ if (incomingRequestHandler == null) {
+ throw new OCPPError(
+ ErrorType.NOT_IMPLEMENTED,
+ `${commandName} incoming request handler not found`,
+ commandName,
+ commandPayload
+ )
+ }
+ if (isAsyncFunction(incomingRequestHandler)) {
+ response = (await incomingRequestHandler(chargingStation, commandPayload)) as ResType
+ } else {
+ response = incomingRequestHandler(chargingStation, commandPayload) as ResType
+ }
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${this.moduleName}.incomingRequestHandler: Handle incoming request error:`,
+ error
+ )
+ throw error
+ }
+ } else {
+ // Throw exception
+ throw new OCPPError(
+ ErrorType.NOT_IMPLEMENTED,
+ `${commandName} is not implemented to handle request PDU ${JSONStringify(commandPayload, 2)}`,
+ commandName,
+ commandPayload
+ )
+ }
+ } else {
+ throw new OCPPError(
+ ErrorType.SECURITY_ERROR,
+ `${commandName} cannot be issued to handle request PDU ${JSONStringify(commandPayload, 2)} while the charging station is not registered on the ${this.csmsName}`,
+ commandName,
+ commandPayload
+ )
+ }
+ const admissionStateIsCurrent = (): boolean =>
+ this.stationsState.get(chargingStation) === stationState
+ const responseCanStartSending = (): boolean =>
+ messageSourceIsCurrent() &&
+ messageLifecycleIsCurrent() &&
+ admissionStateIsCurrent() &&
+ !chargingStation.isStopping()
+ const responseCanBeDispatched = (): boolean =>
+ admissionStateIsCurrent() &&
+ ((!chargingStation.isStopping() && messageLifecycleIsCurrent()) ||
+ (chargingStation.isStopping() && this.canDispatchResponseWhileStopping(commandName)))
+ if (!responseCanStartSending()) {
+ await this.onResponseSendError(chargingStation, commandName, commandPayload)
+ return
+ }
+ let bufferedResponseRetainedBytes: number
+ try {
+ bufferedResponseRetainedBytes =
+ Buffer.byteLength(JSON.stringify([commandPayload, response]), 'utf8') +
+ this.getResponseDeliveryRetainedBytes(commandName, commandPayload)
+ } catch (error) {
+ await this.onResponseSendError(chargingStation, commandName, commandPayload)
+ throw error
+ }
+ const deliveryState = { buffered: false, dispatched: false }
+ const admissionContext = {
+ chargingStation,
+ messageLifecycleIsCurrent,
+ messageLifecycleSignal,
+ messageSourceIsCurrent,
+ stationState,
+ }
+ let responseSendError: Promise<void> | undefined
+ const finalizeResponseSendError = (): Promise<void> => {
+ if (deliveryState.dispatched) return Promise.resolve()
+ responseSendError ??= this.stationStateContext.run(admissionContext, async () => {
+ await this.onResponseSendError(chargingStation, commandName, commandPayload)
+ })
+ return responseSendError
+ }
+ const dispatchResponse = (): void => {
+ this.stationStateContext.run(admissionContext, () => {
+ if (deliveryState.dispatched) return
+ if (!responseCanBeDispatched()) {
+ finalizeResponseSendError().catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${this.moduleName}.incomingRequestHandler: Response failure cleanup error:`,
+ error
+ )
+ })
+ return
+ }
+ deliveryState.dispatched = true
+ if (this.listenerCount(commandName) > 0) {
+ this.emit(commandName, chargingStation, commandPayload, response)
+ }
+ })
+ }
+ try {
+ await chargingStation.ocppRequestService.sendResponse(
+ chargingStation,
+ messageId,
+ response,
+ commandName,
+ {
+ bufferedResponseRetainedBytes,
+ onError: () => {
+ finalizeResponseSendError().catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${this.moduleName}.incomingRequestHandler: Response failure cleanup error:`,
+ error
+ )
+ })
+ },
+ onMessageSent: dispatchResponse,
+ onRequestBuffered: () => {
+ deliveryState.buffered = true
+ },
+ }
+ )
+ } catch (error) {
+ if (deliveryState.dispatched) return
+ if (!deliveryState.buffered) {
+ await finalizeResponseSendError()
+ throw error
+ }
+ return
+ }
+ if (!deliveryState.dispatched && !responseCanBeDispatched()) {
+ await finalizeResponseSendError()
+ return
+ }
+ dispatchResponse()
+ }
}
MessageType,
type OCPPVersion,
type OutgoingRequest,
+ type PendingRequestCancellationCallback,
RequestCommand,
type RequestParams,
type Response,
type ResponseType,
} from '../../types/index.js'
import {
+ clampToSafeTimerValue,
+ Constants,
ensureError,
formatDurationMilliSeconds,
generateUUID,
getErrorMessage,
getMessageTypeString,
handleSendMessageError,
+ isEmpty,
logger,
} from '../../utils/index.js'
import { OCPPConstants } from './OCPPConstants.js'
validatePayload,
} from './OCPPServiceUtils.js'
+interface OutgoingCallCancellationState {
+ readonly destructiveError: OCPPError | undefined
+ readonly destructiveGeneration: number
+ readonly generation: number
+ readonly latestError: OCPPError
+}
+
+interface OutgoingCallGate {
+ activeDeadline?: number
+ activeMessageId?: string
+ readonly waiters: OutgoingCallWaiter[]
+}
+
+interface OutgoingCallWaiter {
+ readonly messageId: string
+ readonly reject: (reason: OCPPError) => void
+ readonly resolve: () => void
+ readonly retainOnCancellation?: () => boolean
+ readonly slotTimeoutMs: number
+}
+
+interface PendingSendOperation {
+ materialize: (error: OCPPError) => void
+ readonly materialized: boolean
+ readonly promise: Promise<ResponseType>
+ rejectBeforeSend: (error: OCPPError, notifyTransportError: boolean) => void
+ start: () => void
+}
+
+interface PreSendBufferedCall {
+ materialize: (error: OCPPError) => void
+ readonly materialized: boolean
+ readonly messageId: string
+}
+
const defaultRequestParams: RequestParams = {
skipBufferingOnError: false,
throwError: false,
protected readonly moduleName: string
protected abstract payloadValidatorFunctions: Map<RequestCommand, ValidateFunction<JsonType>>
private readonly ocppResponseService: OCPPResponseService
+ private readonly outgoingCallCancellationStates = new WeakMap<
+ ChargingStation,
+ OutgoingCallCancellationState
+ >()
+
+ private readonly outgoingCallGates = new WeakMap<ChargingStation, OutgoingCallGate>()
+ private readonly preSendBufferedCalls = new WeakMap<
+ ChargingStation,
+ Map<string, PreSendBufferedCall>
+ >()
+
private readonly version: OCPPVersion
protected constructor (
return OCPPRequestService.instances.get(this) as T
}
+ /**
+ * Acquires the station-wide FIFO slot for one outgoing CALL. CALLRESULT and
+ * CALLERROR frames never use this gate.
+ * @param chargingStation - Station that owns the OCPP-J connection
+ * @param messageId - Correlation id of the outgoing CALL
+ * @param timeoutMs - Maximum duration of this CALL slot
+ * @param retainOnCancellation - Optional predicate deciding whether lifecycle cancellation
+ * preserves this CALL
+ */
+ public async acquireOutgoingCall (
+ chargingStation: ChargingStation,
+ messageId: string,
+ timeoutMs: number,
+ retainOnCancellation?: () => boolean
+ ): Promise<void> {
+ let gate = this.outgoingCallGates.get(chargingStation)
+ if (gate == null) {
+ gate = { waiters: [] }
+ this.outgoingCallGates.set(chargingStation, gate)
+ }
+ const boundedSlotTimeoutMs = clampToSafeTimerValue(timeoutMs)
+ if (gate.activeMessageId == null) {
+ gate.activeMessageId = messageId
+ gate.activeDeadline = Date.now() + boundedSlotTimeoutMs
+ return
+ }
+ if (gate.waiters.length >= Constants.MAX_OUTGOING_CALL_WAITERS) {
+ throw new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Outgoing CALL gate waiter limit of ${Constants.MAX_OUTGOING_CALL_WAITERS.toString()} reached for message id '${messageId}'`
+ )
+ }
+ await new Promise<void>((resolve, reject: (reason: OCPPError) => void) => {
+ let waiterTimeout: NodeJS.Timeout | undefined
+ const clearWaiterTimeout = (): void => {
+ if (waiterTimeout != null) {
+ clearTimeout(waiterTimeout)
+ waiterTimeout = undefined
+ }
+ }
+ const maximumTimerValue = clampToSafeTimerValue(Number.MAX_SAFE_INTEGER)
+ const addBudget = (totalMs: number, slotTimeoutMs: number): number =>
+ totalMs > maximumTimerValue - slotTimeoutMs ? maximumTimerValue : totalMs + slotTimeoutMs
+ const now = Date.now()
+ let waiterTimeoutMs = Math.max(0, (gate.activeDeadline ?? now) - now)
+ for (const waiter of gate.waiters) {
+ waiterTimeoutMs = addBudget(waiterTimeoutMs, waiter.slotTimeoutMs)
+ }
+ waiterTimeoutMs = addBudget(waiterTimeoutMs, boundedSlotTimeoutMs)
+ const waiter: OutgoingCallWaiter = {
+ messageId,
+ reject: reason => {
+ clearWaiterTimeout()
+ reject(reason)
+ },
+ resolve: () => {
+ clearWaiterTimeout()
+ resolve()
+ },
+ retainOnCancellation,
+ slotTimeoutMs: boundedSlotTimeoutMs,
+ }
+ gate.waiters.push(waiter)
+ waiterTimeout = setTimeout(() => {
+ const waiterIndex = gate.waiters.indexOf(waiter)
+ if (waiterIndex < 0) return
+ gate.waiters.splice(waiterIndex, 1)
+ waiter.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Timeout ${formatDurationMilliSeconds(waiterTimeoutMs)} waiting to acquire the outgoing CALL gate for message id '${messageId}'`
+ )
+ )
+ }, waiterTimeoutMs)
+ })
+ }
+
+ /**
+ * Cancels one queued outgoing CALL without disturbing the active CALL.
+ * @param chargingStation - Station that owns the OCPP-J connection
+ * @param messageId - Correlation id of the queued CALL
+ * @param error - Typed cancellation delivered to the waiter
+ * @returns Whether a matching queued waiter was cancelled
+ */
+ public cancelOutgoingCallWaiter (
+ chargingStation: ChargingStation,
+ messageId: string,
+ error: OCPPError
+ ): boolean {
+ const gate = this.outgoingCallGates.get(chargingStation)
+ if (gate == null || gate.activeMessageId === messageId) return false
+ const waiterIndex = gate.waiters.findIndex(waiter => waiter.messageId === messageId)
+ if (waiterIndex < 0) return false
+ const [waiter] = gate.waiters.splice(waiterIndex, 1)
+ waiter.reject(error)
+ return true
+ }
+
+ /**
+ * Rejects pending requests so their response timers and captured station
+ * state are released when the station stops. Buffered CALLs remain registered
+ * for replay, and explicitly retained graceful-stop CALLs keep awaiting their
+ * response, unless final shutdown or permanent deletion cancels them.
+ * @param chargingStation - Station whose pending requests are cancelled
+ * @param message - Error message delivered to pending callers
+ * @param discardBufferedRequests - Whether deferred CALL frames and callbacks are discarded
+ * @param options - Cancellation behavior for queued and in-flight transport sends
+ * @param options.bufferInFlightSends - Settle in-flight sends through their normal buffer policy
+ * @param options.handleTransportStartedSends - Settle only sends already handed to the transport
+ * @param options.preserveRetainableWaiters - Keep queued CALLs whose request policy permits replay
+ */
+ public cancelPendingRequests (
+ chargingStation: ChargingStation,
+ message = 'Charging station stopped while awaiting an OCPP response',
+ discardBufferedRequests = false,
+ {
+ bufferInFlightSends = false,
+ handleTransportStartedSends = false,
+ preserveRetainableWaiters = !discardBufferedRequests && !bufferInFlightSends,
+ }: {
+ bufferInFlightSends?: boolean
+ handleTransportStartedSends?: boolean
+ preserveRetainableWaiters?: boolean
+ } = {}
+ ): void {
+ const cancellationError = new OCPPError(ErrorType.GENERIC_ERROR, message)
+ const previousCancellationState = this.outgoingCallCancellationStates.get(chargingStation)
+ const cancellationGeneration = (previousCancellationState?.generation ?? 0) + 1
+ this.outgoingCallCancellationStates.set(chargingStation, {
+ destructiveError: discardBufferedRequests
+ ? cancellationError
+ : previousCancellationState?.destructiveError,
+ destructiveGeneration: discardBufferedRequests
+ ? cancellationGeneration
+ : (previousCancellationState?.destructiveGeneration ?? 0),
+ generation: cancellationGeneration,
+ latestError: cancellationError,
+ })
+ const detachedWaiters = this.cancelOutgoingCallWaiters(
+ chargingStation,
+ preserveRetainableWaiters
+ )
+ const bufferedRequestIds = discardBufferedRequests
+ ? undefined
+ : chargingStation.getBufferedRequestIds()
+ // Clear buffered frames before invoking callbacks. Their terminal cleanup
+ // can then remain idempotent without repeatedly scanning a shrinking queue.
+ if (discardBufferedRequests) chargingStation.clearMessageBuffer()
+ for (const [messageId, [, errorCallback, , , cancelPendingSend]] of [
+ ...chargingStation.requests.entries(),
+ ]) {
+ if (bufferedRequestIds?.has(messageId) === true) {
+ // A replay already in flight must stay queued and stop the active drain;
+ // a merely queued CALL is retained without changing its position.
+ chargingStation.retainBufferedRequest(messageId)
+ cancelPendingSend?.(cancellationError, {
+ handleTransportStartedSend: true,
+ preserveRetainableWaiter: preserveRetainableWaiters,
+ })
+ this.releaseOutgoingCall(chargingStation, messageId)
+ continue
+ }
+ // Preserve shutdown-critical CALLs before their send timers and initiating promises are settled.
+ if (
+ !discardBufferedRequests &&
+ cancelPendingSend?.(cancellationError, {
+ bufferInFlightSend: bufferInFlightSends,
+ handleTransportStartedSend: handleTransportStartedSends,
+ preserveRetainableWaiter: preserveRetainableWaiters,
+ }) === true
+ ) {
+ continue
+ }
+ chargingStation.requests.delete(messageId)
+ errorCallback(cancellationError, false)
+ }
+ const preSendBufferedCalls = this.preSendBufferedCalls.get(chargingStation)
+ if (discardBufferedRequests) {
+ preSendBufferedCalls?.clear()
+ this.preSendBufferedCalls.delete(chargingStation)
+ } else {
+ const activeMessageId = this.outgoingCallGates.get(chargingStation)?.activeMessageId
+ if (!preserveRetainableWaiters && activeMessageId != null) {
+ preSendBufferedCalls?.get(activeMessageId)?.materialize(cancellationError)
+ }
+ for (const waiter of detachedWaiters) {
+ preSendBufferedCalls?.get(waiter.messageId)?.materialize(cancellationError)
+ }
+ }
+ for (const waiter of detachedWaiters) waiter.reject(cancellationError)
+ }
+
+ /**
+ * Releases an outgoing CALL slot after its terminal transport outcome.
+ * @param chargingStation - Station that owns the OCPP-J connection
+ * @param messageId - Correlation id of the completed CALL
+ */
+ public releaseOutgoingCall (chargingStation: ChargingStation, messageId: string): void {
+ const gate = this.outgoingCallGates.get(chargingStation)
+ if (gate?.activeMessageId !== messageId) return
+ const next = gate.waiters.shift()
+ if (next == null) {
+ this.outgoingCallGates.delete(chargingStation)
+ return
+ }
+ gate.activeMessageId = next.messageId
+ gate.activeDeadline = Date.now() + next.slotTimeoutMs
+ next.resolve()
+ }
+
/**
* Sends an OCPP request and awaits its response.
* @param chargingStation - Target charging station.
commandParams?: ReqType,
params?: RequestParams
): Promise<ResType> {
+ const cancellationGenerationAtRequestStart =
+ this.outgoingCallCancellationStates.get(chargingStation)?.generation ?? 0
logger.debug(
`${chargingStation.logPrefix()} ${this.moduleName}.requestHandler: Processing '${commandName}' request`
)
messageId,
requestPayload,
commandName,
- params
+ params,
+ cancellationGenerationAtRequestStart
)) as ResType
logger.debug(
`${chargingStation.logPrefix()} ${this.moduleName}.requestHandler: '${commandName}' request completed successfully`
chargingStation: ChargingStation,
messageId: string,
messagePayload: JsonType,
- commandName: IncomingRequestCommand
+ commandName: IncomingRequestCommand,
+ params?: RequestParams
): Promise<ResponseType> {
try {
return await this.internalSendMessage(
messageId,
messagePayload,
MessageType.CALL_RESULT_MESSAGE,
- commandName
+ commandName,
+ params
)
} catch (error) {
handleSendMessageError(
}
}
+ /**
+ * Validates outgoing request payload against JSON schema
+ * @param chargingStation - The charging station instance sending the request
+ * @param commandName - OCPP command name to validate against
+ * @param payload - JSON payload to validate
+ * @param options - Validation policy overrides
+ * @param options.forceValidation - Validate even when strict compliance is disabled
+ * @returns `true` when payload validation succeeds; `false` otherwise.
+ */
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
+ public validateRequestPayload<T extends JsonType>(
+ chargingStation: ChargingStation,
+ commandName: IncomingRequestCommand | RequestCommand,
+ payload: T,
+ { forceValidation = false }: { forceValidation?: boolean } = {}
+ ): boolean {
+ return validatePayload(
+ chargingStation,
+ commandName,
+ payload,
+ this.payloadValidatorFunctions.get(commandName as RequestCommand),
+ 'request',
+ true,
+ forceValidation
+ )
+ }
+
protected abstract buildRequestPayload (
chargingStation: ChargingStation,
commandName: RequestCommand,
commandParams?: JsonType
): JsonType
+ protected abstract getDefaultResponseTimeoutMs (chargingStation: ChargingStation): number
+
protected logRequestHandlerError (
chargingStation: ChargingStation,
commandName: RequestCommand,
messageId: string,
messagePayload: JsonType,
commandName: RequestCommand,
- params?: RequestParams
+ params?: RequestParams,
+ cancellationGenerationAtRequestStart?: number
): Promise<ResponseType> {
params = {
...defaultRequestParams,
messagePayload,
MessageType.CALL_MESSAGE,
commandName,
- params
+ params,
+ cancellationGenerationAtRequestStart
)
} catch (error) {
handleSendMessageError(
)
}
- /**
- * Validates outgoing request payload against JSON schema
- * @param chargingStation - The charging station instance sending the request
- * @param commandName - OCPP command name to validate against
- * @param payload - JSON payload to validate
- * @returns `true` when payload validation succeeds; `false` otherwise.
- */
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
- protected validateRequestPayload<T extends JsonType>(
- chargingStation: ChargingStation,
- commandName: IncomingRequestCommand | RequestCommand,
- payload: T
- ): boolean {
- return validatePayload(
- chargingStation,
- commandName,
- payload,
- this.payloadValidatorFunctions.get(commandName as RequestCommand),
- 'request',
- true
- )
- }
-
private buildMessageToSend (
chargingStation: ChargingStation,
messageId: string,
return messageToSend
}
- private async internalSendMessage (
+ /**
+ * Cancels non-retained CALLs waiting behind the active station request during lifecycle teardown.
+ * @param chargingStation - Station whose queued CALLs are cancelled
+ * @param preserveRetainableWaiters - Whether graceful-stop CALLs remain queued
+ * @returns Detached waiters that the caller must reject
+ */
+ private cancelOutgoingCallWaiters (
+ chargingStation: ChargingStation,
+ preserveRetainableWaiters: boolean
+ ): OutgoingCallWaiter[] {
+ const gate = this.outgoingCallGates.get(chargingStation)
+ if (gate == null) return []
+ const detachedWaiters: OutgoingCallWaiter[] = []
+ const waiters = gate.waiters.splice(0)
+ for (const waiter of waiters) {
+ if (!preserveRetainableWaiters || waiter.retainOnCancellation?.() !== true) {
+ detachedWaiters.push(waiter)
+ } else {
+ gate.waiters.push(waiter)
+ }
+ }
+ if (gate.activeMessageId == null && isEmpty(gate.waiters)) {
+ this.outgoingCallGates.delete(chargingStation)
+ }
+ return detachedWaiters
+ }
+
+ private createPendingSendOperation (
chargingStation: ChargingStation,
messageId: string,
messagePayload: JsonType | OCPPError,
messageType: MessageType,
commandName: IncomingRequestCommand | RequestCommand,
- params?: RequestParams
- ): Promise<ResponseType> {
- params = {
- ...defaultRequestParams,
- ...params,
- }
- if (
- ((chargingStation.inUnknownState() ||
- chargingStation.inPendingState() ||
- chargingStation.inRejectedState()) &&
- commandName === RequestCommand.BOOT_NOTIFICATION) ||
- (chargingStation.stationInfo?.ocppStrictCompliance === false &&
- chargingStation.inUnknownState()) ||
- chargingStation.inAcceptedState() ||
- (chargingStation.inPendingState() &&
- (params.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
- ) {
- // eslint-disable-next-line @typescript-eslint/no-this-alias -- stable outer-this reference captured for nested Promise executor and its response-handler closures
- const self = this
- return await new Promise<ResponseType>((resolve, reject: (reason?: unknown) => void) => {
- /**
- * Function that will receive the request's response
- * @param payload - The response payload
- * @param requestPayload - The original request payload
- */
- const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
- chargingStation.recordRequestStatistic(commandName, MessageType.CALL_RESULT_MESSAGE)
- self.ocppResponseService
- .responseHandler(
- chargingStation,
- commandName as RequestCommand,
- payload,
- requestPayload
- )
- .then(() => {
- resolve(payload)
- return undefined
- })
- .finally(() => {
- chargingStation.requests.delete(messageId)
- chargingStation.emitChargingStationEvent(ChargingStationEvents.updated)
- })
- .catch(reject)
+ params: RequestParams,
+ responseTimeoutMs: number
+ ): PendingSendOperation {
+ const messageToSend = this.buildMessageToSend(
+ chargingStation,
+ messageId,
+ messagePayload,
+ messageType,
+ commandName
+ )
+ const bufferedResponseBytes =
+ messageType === MessageType.CALL_MESSAGE
+ ? 0
+ : Buffer.byteLength(messageToSend, 'utf8') +
+ Math.max(0, params.bufferedResponseRetainedBytes ?? 0)
+ let responseCallbackReservation: ReturnType<ChargingStation['reserveBufferedMessageCallbacks']>
+ let materialized = false
+ let started = false
+ let materialize = (_error: OCPPError): void => undefined
+ let rejectBeforeSend = (_error: OCPPError, _notifyTransport = true): void => undefined
+ let start = (): void => undefined
+ const promise = new Promise<ResponseType>((resolve, reject: (reason?: unknown) => void) => {
+ let responseTimeout: NodeJS.Timeout | undefined
+ let sendTimeout: NodeJS.Timeout | undefined
+ let bufferedMessage: string | undefined
+ let sendErrorHandled = false
+ let sendErrorBuffered = false
+ let transportStarted = false
+ let terminalResponseHandled = false
+ let everTransportAmbiguous = false
+ let responseDeliveryNotified = false
+ const clearResponseTimeout = (): void => {
+ if (responseTimeout != null) {
+ clearTimeout(responseTimeout)
+ responseTimeout = undefined
}
-
- /**
- * Function that will receive the request's error response
- * @param ocppError - The OCPP error response
- * @param requestStatistic - Whether to record request statistics
- */
- const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
- if (requestStatistic) {
- chargingStation.recordRequestStatistic(commandName, MessageType.CALL_ERROR_MESSAGE)
+ }
+ const clearSendTimeout = (): void => {
+ if (sendTimeout != null) {
+ clearTimeout(sendTimeout)
+ sendTimeout = undefined
+ }
+ }
+ const releaseResponseCallbackReservation = (): void => {
+ chargingStation.releaseBufferedMessageCallbacks(responseCallbackReservation)
+ responseCallbackReservation = undefined
+ }
+ const prepareTerminalResponse = (): boolean => {
+ if (terminalResponseHandled) return false
+ terminalResponseHandled = true
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.releaseOutgoingCall(chargingStation, messageId)
+ }
+ clearResponseTimeout()
+ clearSendTimeout()
+ if (bufferedMessage != null) {
+ if (messageType === MessageType.CALL_MESSAGE) {
+ chargingStation.removeBufferedRequest(messageId)
+ } else {
+ chargingStation.removeBufferedMessage(bufferedMessage)
}
+ bufferedMessage = undefined
+ }
+ return true
+ }
+
+ /**
+ * Function that will receive the request's response
+ * @param payload - The response payload
+ * @param requestPayload - The original request payload
+ */
+ const responseCallback = (payload: JsonType, requestPayload: JsonType): void => {
+ if (!prepareTerminalResponse()) return
+ chargingStation.recordRequestStatistic(commandName, MessageType.CALL_RESULT_MESSAGE)
+ try {
+ params.onResponseReceived?.()
+ } catch (error: unknown) {
logger.error(
- `${chargingStation.logPrefix()} Error occurred at ${getMessageTypeString(
- messageType
- )} command ${commandName} with PDU %j:`,
- messagePayload,
- ocppError
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onResponseReceived callback failed for message id '${messageId}':`,
+ error
)
- chargingStation.requests.delete(messageId)
- chargingStation.emitChargingStationEvent(ChargingStationEvents.updated)
- reject(ocppError)
}
+ // The wire response has arrived: remove the correlation entry before
+ // asynchronous response-side effects so lifecycle cancellation cannot
+ // reject an already-answered request while its handler is still running.
+ chargingStation.requests.delete(messageId)
+ this.ocppResponseService
+ .responseHandler(chargingStation, commandName as RequestCommand, payload, requestPayload)
+ .then(() => {
+ resolve(payload)
+ return undefined
+ })
+ .finally(() => {
+ chargingStation.emitChargingStationEvent(ChargingStationEvents.updated)
+ })
+ .catch(reject)
+ }
+
+ /**
+ * Function that will receive the request's error response
+ * @param ocppError - The OCPP error response
+ * @param requestStatistic - Whether to record request statistics
+ */
+ const errorCallback = (ocppError: OCPPError, requestStatistic = true): void => {
+ if (!prepareTerminalResponse()) return
+ if (requestStatistic) {
+ chargingStation.recordRequestStatistic(commandName, MessageType.CALL_ERROR_MESSAGE)
+ }
+ logger.error(
+ `${chargingStation.logPrefix()} Error occurred at ${getMessageTypeString(
+ messageType
+ )} command ${commandName} with PDU %j:`,
+ messagePayload,
+ ocppError
+ )
+ chargingStation.requests.delete(messageId)
+ try {
+ params.onError?.(ocppError, requestStatistic)
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onError callback failed for message id '${messageId}':`,
+ error
+ )
+ }
+ chargingStation.emitChargingStationEvent(ChargingStationEvents.updated)
+ reject(ocppError)
+ }
- const handleSendError = (ocppError: OCPPError): void => {
- if (params.skipBufferingOnError === false) {
- // Buffer
- chargingStation.bufferMessage(messageToSend)
- if (messageType === MessageType.CALL_MESSAGE) {
- this.setCachedRequest(
- chargingStation,
- messageId,
- messagePayload as JsonType,
+ const shouldBufferOnError = (): boolean =>
+ params.skipBufferingOnError === false ||
+ (params.bufferOnErrorDuringStationStop === true && chargingStation.isStopping())
+ const notifyMessageSent = (): void => {
+ if (messageType !== MessageType.CALL_MESSAGE) {
+ if (responseDeliveryNotified) return
+ responseDeliveryNotified = true
+ }
+ clearResponseTimeout()
+ if (messageType === MessageType.CALL_MESSAGE) {
+ responseTimeout = setTimeout(() => {
+ errorCallback(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Timeout ${formatDurationMilliSeconds(responseTimeoutMs)} waiting for response to message id '${messageId}'`,
commandName,
- responseCallback,
- errorCallback
+ messagePayload instanceof OCPPError ? messagePayload.details : undefined
+ ),
+ false
+ )
+ }, responseTimeoutMs)
+ }
+ try {
+ params.onMessageSent?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onMessageSent callback failed for message id '${messageId}':`,
+ error
+ )
+ }
+ }
+ const notifyRequestBuffered = (): void => {
+ try {
+ params.onRequestBuffered?.()
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onRequestBuffered callback failed for message id '${messageId}':`,
+ error
+ )
+ }
+ }
+ const notifyTransportError = (ocppError: OCPPError, deliveryAmbiguous: boolean): void => {
+ try {
+ params.onTransportError?.(ocppError, deliveryAmbiguous)
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onTransportError callback failed for message id '${messageId}':`,
+ error
+ )
+ }
+ }
+ const bufferDeferredMessage = (ocppError: OCPPError, prepend = false): boolean =>
+ chargingStation.bufferMessage(
+ messageToSend,
+ prepend,
+ messageType === MessageType.CALL_MESSAGE
+ ? undefined
+ : {
+ onDiscarded: () => {
+ if (everTransportAmbiguous) {
+ notifyMessageSent()
+ resolve(messagePayload)
+ return
+ }
+ try {
+ params.onError?.(ocppError, false)
+ } catch (error: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onError callback failed for discarded buffered response id '${messageId}':`,
+ error
+ )
+ }
+ },
+ onSent: notifyMessageSent,
+ retainedBytes: bufferedResponseBytes,
+ },
+ responseCallbackReservation
+ )
+ const handleSendError = (
+ ocppError: OCPPError,
+ deliveryAmbiguous: boolean,
+ forceBuffer = false
+ ): boolean => {
+ if (sendErrorHandled) return sendErrorBuffered
+ everTransportAmbiguous ||= deliveryAmbiguous
+ sendErrorHandled = true
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.releaseOutgoingCall(chargingStation, messageId)
+ notifyTransportError(ocppError, deliveryAmbiguous)
+ if (terminalResponseHandled) {
+ clearResponseTimeout()
+ clearSendTimeout()
+ return false
+ }
+ if (sendErrorBuffered) {
+ clearResponseTimeout()
+ clearSendTimeout()
+ return true
+ }
+ }
+ clearResponseTimeout()
+ clearSendTimeout()
+ if (forceBuffer || shouldBufferOnError()) {
+ if (!bufferDeferredMessage(ocppError)) {
+ bufferedMessage = undefined
+ releaseResponseCallbackReservation()
+ reject(ocppError)
+ return false
+ }
+ bufferedMessage = messageToSend
+ responseCallbackReservation = undefined
+ sendErrorBuffered = true
+ notifyRequestBuffered()
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.setCachedRequest(
+ chargingStation,
+ messageId,
+ messagePayload as JsonType,
+ commandName,
+ responseCallback,
+ errorCallback,
+ cancelPendingSend,
+ notifyMessageSent,
+ clearResponseTimeout,
+ responseTimeoutMs
+ )
+ }
+ } else if (messageType === MessageType.CALL_MESSAGE) {
+ chargingStation.requests.delete(messageId)
+ } else {
+ releaseResponseCallbackReservation()
+ }
+ reject(ocppError)
+ return sendErrorBuffered
+ }
+ const forceBufferPendingRequest = (ocppError: OCPPError): boolean => {
+ if (!sendErrorHandled) return handleSendError(ocppError, false, true)
+ if (terminalResponseHandled) return false
+ this.releaseOutgoingCall(chargingStation, messageId)
+ clearResponseTimeout()
+ clearSendTimeout()
+ const bufferedMessageRetained =
+ bufferedMessage != null &&
+ (messageType === MessageType.CALL_MESSAGE
+ ? chargingStation.retainBufferedRequest(messageId)
+ : chargingStation.retainBufferedMessage(bufferedMessage))
+ if (!bufferedMessageRetained) {
+ const wasBuffered = bufferedMessage != null
+ if (!bufferDeferredMessage(ocppError, wasBuffered)) return false
+ bufferedMessage = messageToSend
+ }
+ if (!sendErrorBuffered) notifyRequestBuffered()
+ sendErrorBuffered = true
+ reject(ocppError)
+ return true
+ }
+ materialize = (ocppError: OCPPError): void => {
+ if (materialized) return
+ materialized = true
+ this.unregisterPreSendBufferedCall(chargingStation, messageId)
+ forceBufferPendingRequest(ocppError)
+ }
+ rejectBeforeSend = (ocppError: OCPPError, notifyTransport): void => {
+ if (terminalResponseHandled) return
+ sendErrorHandled = true
+ terminalResponseHandled = true
+ this.unregisterPreSendBufferedCall(chargingStation, messageId)
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.releaseOutgoingCall(chargingStation, messageId)
+ if (notifyTransport) notifyTransportError(ocppError, false)
+ }
+ clearResponseTimeout()
+ clearSendTimeout()
+ releaseResponseCallbackReservation()
+ reject(ocppError)
+ }
+
+ const cancelPendingSend: PendingRequestCancellationCallback | undefined =
+ messageType === MessageType.CALL_MESSAGE
+ ? (
+ ocppError,
+ {
+ bufferInFlightSend = false,
+ handleTransportStartedSend = false,
+ preserveRetainableWaiter = true,
+ } = {}
+ ) => {
+ if (params.waitForResponseOnStationStop === true) {
+ if (preserveRetainableWaiter && !bufferInFlightSend) return true
+ errorCallback(ocppError, false)
+ return true
+ }
+ if (
+ !sendErrorHandled &&
+ (bufferInFlightSend || (handleTransportStartedSend && transportStarted))
+ ) {
+ handleSendError(ocppError, transportStarted, false)
+ return true
+ }
+ return params.bufferOnErrorDuringStationStop === true
+ ? forceBufferPendingRequest(ocppError)
+ : false
+ }
+ : undefined
+
+ start = (): void => {
+ if (started || materialized) return
+ started = true
+ if (messageType !== MessageType.CALL_MESSAGE) {
+ responseCallbackReservation = chargingStation.reserveBufferedMessageCallbacks(
+ bufferedResponseBytes,
+ () => {
+ const cancellationError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Response delivery cancelled for message id '${messageId}'`,
+ commandName
)
+ if (!transportStarted) {
+ rejectBeforeSend(cancellationError, false)
+ return
+ }
+ if (terminalResponseHandled) return
+ // Once the response has been handed to the transport, delivery is
+ // ambiguous. Preserve its accepted side effects instead of routing
+ // it through the definite-unsent rollback path.
+ everTransportAmbiguous = true
+ sendErrorHandled = true
+ terminalResponseHandled = true
+ clearResponseTimeout()
+ clearSendTimeout()
+ releaseResponseCallbackReservation()
+ notifyMessageSent()
+ resolve(messagePayload)
}
- } else if (
- params.skipBufferingOnError === true &&
- messageType === MessageType.CALL_MESSAGE
- ) {
- chargingStation.requests.delete(messageId)
+ )
+ if (responseCallbackReservation == null) {
+ rejectBeforeSend(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Response callback capacity exceeded for message id '${messageId}'`,
+ commandName
+ ),
+ false
+ )
+ return
}
- reject(ocppError)
}
-
chargingStation.recordRequestStatistic(commandName, messageType)
- const messageToSend = this.buildMessageToSend(
- chargingStation,
- messageId,
- messagePayload,
- messageType,
- commandName
- )
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.setCachedRequest(
+ chargingStation,
+ messageId,
+ messagePayload as JsonType,
+ commandName,
+ responseCallback,
+ errorCallback,
+ cancelPendingSend,
+ notifyMessageSent,
+ clearResponseTimeout,
+ responseTimeoutMs
+ )
+ this.unregisterPreSendBufferedCall(chargingStation, messageId)
+ if (params.bufferWithoutSending === true) {
+ handleSendError(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `Buffered message id '${messageId}' without sending`,
+ commandName,
+ messagePayload instanceof OCPPError ? messagePayload.details : undefined
+ ),
+ false,
+ true
+ )
+ return
+ }
+ }
if (chargingStation.isWebSocketConnectionOpened()) {
const beginId = PerformanceStatistics.beginMeasure(commandName)
- const sendTimeout = setTimeout(() => {
+ sendTimeout = setTimeout(() => {
handleSendError(
new OCPPError(
ErrorType.GENERIC_ERROR,
`Timeout ${formatDurationMilliSeconds(
OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS
)} reached for ${
- params.skipBufferingOnError === false ? '' : 'non '
+ shouldBufferOnError() ? '' : 'non '
}buffered message id '${messageId}' with content '${messageToSend}'`,
commandName,
messagePayload instanceof OCPPError ? messagePayload.details : undefined
- )
+ ),
+ true
)
}, OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)
- chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
- PerformanceStatistics.endMeasure(commandName, beginId)
- clearTimeout(sendTimeout)
- if (error == null) {
- logger.debug(
- `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: >> Command '${commandName}' sent ${getMessageTypeString(
- messageType
- )} payload: ${messageToSend}`
- )
- if (messageType === MessageType.CALL_MESSAGE) {
- this.setCachedRequest(
- chargingStation,
- messageId,
- messagePayload as JsonType,
- commandName,
- responseCallback,
- errorCallback
+ try {
+ transportStarted = true
+ chargingStation.wsConnection?.send(messageToSend, (error?: Error) => {
+ PerformanceStatistics.endMeasure(commandName, beginId)
+ clearSendTimeout()
+ if (sendErrorHandled) return
+ if (
+ messageType === MessageType.CALL_MESSAGE &&
+ !chargingStation.requests.has(messageId)
+ ) {
+ return
+ }
+ if (error == null) {
+ sendErrorHandled = true
+ if (messageType === MessageType.CALL_MESSAGE) {
+ this.setCachedRequest(
+ chargingStation,
+ messageId,
+ messagePayload as JsonType,
+ commandName,
+ responseCallback,
+ errorCallback,
+ cancelPendingSend,
+ notifyMessageSent,
+ clearResponseTimeout,
+ responseTimeoutMs
+ )
+ }
+ logger.debug(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: >> Command '${commandName}' sent ${getMessageTypeString(
+ messageType
+ )} payload: ${messageToSend}`
)
+ if (messageType === MessageType.CALL_MESSAGE) {
+ notifyMessageSent()
+ } else {
+ // Resolve response
+ releaseResponseCallbackReservation()
+ resolve(messagePayload)
+ notifyMessageSent()
+ }
} else {
- // Resolve response
- resolve(messagePayload)
- }
- } else {
- handleSendError(
- new OCPPError(
- ErrorType.GENERIC_ERROR,
- `WebSocket errored for ${
- params.skipBufferingOnError === false ? '' : 'non '
- }buffered message id '${messageId}' with content '${messageToSend}'`,
- commandName,
- {
- message: error.message,
- name: error.name,
- stack: error.stack,
- }
+ handleSendError(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `WebSocket errored for ${
+ shouldBufferOnError() ? '' : 'non '
+ }buffered message id '${messageId}' with content '${messageToSend}'`,
+ commandName,
+ {
+ message: error.message,
+ name: error.name,
+ stack: error.stack,
+ }
+ ),
+ true
)
- )
- }
- })
+ }
+ })
+ } catch (error: unknown) {
+ PerformanceStatistics.endMeasure(commandName, beginId)
+ clearSendTimeout()
+ const sendError = ensureError(error)
+ handleSendError(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `WebSocket errored for ${
+ shouldBufferOnError() ? '' : 'non '
+ }buffered message id '${messageId}' with content '${messageToSend}'`,
+ commandName,
+ {
+ message: sendError.message,
+ name: sendError.name,
+ stack: sendError.stack,
+ }
+ ),
+ false
+ )
+ }
} else {
handleSendError(
new OCPPError(
ErrorType.GENERIC_ERROR,
`WebSocket closed for ${
- params.skipBufferingOnError === false ? '' : 'non '
+ shouldBufferOnError() ? '' : 'non '
}buffered message id '${messageId}' with content '${messageToSend}'`,
commandName,
messagePayload instanceof OCPPError ? messagePayload.details : undefined
- )
+ ),
+ false
)
}
- })
+ }
+ })
+ promise.catch(() => undefined)
+ return {
+ materialize: error => {
+ materialize(error)
+ },
+ get materialized () {
+ return materialized
+ },
+ promise,
+ rejectBeforeSend: (error, notifyTransportError) => {
+ rejectBeforeSend(error, notifyTransportError)
+ },
+ start: () => {
+ start()
+ },
}
- throw new OCPPError(
- ErrorType.SECURITY_ERROR,
- // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
- `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.bootNotificationResponse?.status} state on the central server`,
- commandName
+ }
+
+ private async internalSendMessage (
+ chargingStation: ChargingStation,
+ messageId: string,
+ messagePayload: JsonType | OCPPError,
+ messageType: MessageType,
+ commandName: IncomingRequestCommand | RequestCommand,
+ params?: RequestParams,
+ cancellationGenerationAtRequestStart?: number
+ ): Promise<ResponseType> {
+ const requestParams: RequestParams = {
+ ...defaultRequestParams,
+ ...params,
+ }
+ const responseTimeoutMs =
+ requestParams.responseTimeoutMs != null && requestParams.responseTimeoutMs > 0
+ ? requestParams.responseTimeoutMs
+ : this.getDefaultResponseTimeoutMs(chargingStation)
+ const canSendMessage = (): boolean =>
+ ((chargingStation.inUnknownState() ||
+ chargingStation.inPendingState() ||
+ chargingStation.inRejectedState()) &&
+ commandName === RequestCommand.BOOT_NOTIFICATION) ||
+ (chargingStation.stationInfo?.ocppStrictCompliance === false &&
+ chargingStation.inUnknownState()) ||
+ chargingStation.inAcceptedState() ||
+ (chargingStation.inPendingState() &&
+ (requestParams.triggerMessage === true || messageType === MessageType.CALL_RESULT_MESSAGE))
+ const buildInvalidStateError = (): OCPPError =>
+ new OCPPError(
+ ErrorType.SECURITY_ERROR,
+ // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+ `Cannot send command ${commandName} PDU when the charging station is in ${chargingStation.bootNotificationResponse?.status} state on the central server`,
+ commandName
+ )
+ if (!canSendMessage()) throw buildInvalidStateError()
+
+ if (messageType !== MessageType.CALL_MESSAGE) {
+ const operation = this.createPendingSendOperation(
+ chargingStation,
+ messageId,
+ messagePayload,
+ messageType,
+ commandName,
+ requestParams,
+ responseTimeoutMs
+ )
+ operation.start()
+ return await operation.promise
+ }
+
+ const cancellationStateAtStart = this.outgoingCallCancellationStates.get(chargingStation)
+ if (cancellationStateAtStart?.destructiveError != null) {
+ try {
+ requestParams.onTransportError?.(cancellationStateAtStart.destructiveError, false)
+ } catch (callbackError: unknown) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.internalSendMessage: onTransportError callback failed after permanent cancellation for message id '${messageId}':`,
+ callbackError
+ )
+ }
+ throw cancellationStateAtStart.destructiveError
+ }
+ const cancellationGeneration =
+ cancellationGenerationAtRequestStart ?? cancellationStateAtStart?.generation ?? 0
+ const retainOnCancellation = (): boolean =>
+ chargingStation.isStopping()
+ ? requestParams.bufferOnErrorDuringStationStop === true ||
+ requestParams.waitForResponseOnStationStop === true
+ : requestParams.skipBufferingOnError === false
+ const operation = this.createPendingSendOperation(
+ chargingStation,
+ messageId,
+ messagePayload,
+ messageType,
+ commandName,
+ requestParams,
+ responseTimeoutMs
)
+ const materializeOnCancellation =
+ requestParams.materializeOnCancellationBeforeSend === true &&
+ requestParams.bufferOnErrorDuringStationStop === true
+ if (materializeOnCancellation) {
+ this.registerPreSendBufferedCall(chargingStation, messageId, operation)
+ }
+ try {
+ await this.acquireOutgoingCall(
+ chargingStation,
+ messageId,
+ clampToSafeTimerValue(
+ clampToSafeTimerValue(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS) +
+ clampToSafeTimerValue(responseTimeoutMs)
+ ),
+ retainOnCancellation
+ )
+ } catch (error: unknown) {
+ const transportError =
+ error instanceof OCPPError
+ ? error
+ : new OCPPError(ErrorType.GENERIC_ERROR, getErrorMessage(error), commandName)
+ if (operation.materialized) return await operation.promise
+ this.unregisterPreSendBufferedCall(chargingStation, messageId)
+ operation.rejectBeforeSend(transportError, true)
+ return await operation.promise
+ }
+ if (operation.materialized) return await operation.promise
+ const cancellationState = this.outgoingCallCancellationStates.get(chargingStation)
+ const destructiveCancellation =
+ cancellationState != null && cancellationState.destructiveGeneration > cancellationGeneration
+ if (
+ cancellationState != null &&
+ (destructiveCancellation ||
+ (cancellationState.generation > cancellationGeneration && !retainOnCancellation()))
+ ) {
+ const cancellationError = destructiveCancellation
+ ? (cancellationState.destructiveError ?? cancellationState.latestError)
+ : cancellationState.latestError
+ operation.rejectBeforeSend(cancellationError, true)
+ return await operation.promise
+ }
+ if (!canSendMessage()) {
+ operation.rejectBeforeSend(buildInvalidStateError(), false)
+ return await operation.promise
+ }
+ operation.start()
+ return await operation.promise
+ }
+
+ private registerPreSendBufferedCall (
+ chargingStation: ChargingStation,
+ messageId: string,
+ operation: PendingSendOperation
+ ): void {
+ let calls = this.preSendBufferedCalls.get(chargingStation)
+ if (calls == null) {
+ calls = new Map<string, PreSendBufferedCall>()
+ this.preSendBufferedCalls.set(chargingStation, calls)
+ }
+ calls.set(messageId, {
+ materialize: error => {
+ operation.materialize(error)
+ },
+ get materialized () {
+ return operation.materialized
+ },
+ messageId,
+ })
}
private setCachedRequest (
messagePayload: JsonType,
commandName: IncomingRequestCommand | RequestCommand,
responseCallback: ResponseCallback,
- errorCallback: ErrorCallback
+ errorCallback: ErrorCallback,
+ cancelPendingSend?: PendingRequestCancellationCallback,
+ onMessageSent?: () => void,
+ onTransportLost?: () => void,
+ responseTimeoutMs?: number
): void {
chargingStation.requests.set(messageId, [
responseCallback,
errorCallback,
commandName,
messagePayload,
+ cancelPendingSend,
+ onMessageSent,
+ onTransportLost,
+ responseTimeoutMs,
])
}
+
+ private unregisterPreSendBufferedCall (chargingStation: ChargingStation, messageId: string): void {
+ const calls = this.preSendBufferedCalls.get(chargingStation)
+ if (calls == null) return
+ calls.delete(messageId)
+ if (isEmpty(calls)) this.preSendBufferedCalls.delete(chargingStation)
+ }
}
import { OCPPError } from '../../exception/index.js'
import {
AuthorizationStatus,
- ConnectorStatusEnum,
ErrorType,
OCPPVersion,
type StartTransactionResult,
type StopTransactionResult,
} from '../../types/index.js'
-import { logger, truncateId } from '../../utils/index.js'
+import { ensureError, logger, truncateId } from '../../utils/index.js'
import { OCPP16ServiceUtils } from './1.6/OCPP16ServiceUtils.js'
import { mapStopReasonToOCPP20 } from './2.0/OCPP20RequestBuilders.js'
import { OCPP20ServiceUtils } from './2.0/OCPP20ServiceUtils.js'
/**
* Stops all running transactions on all connectors of a charging station.
+ * OCPP 1.6 starts already in flight settle before the active-transaction check.
* @param chargingStation - Target charging station
* @param reason - Optional reason for stopping the transactions
*/
): Promise<void> => {
switch (chargingStation.stationInfo?.ocppVersion) {
case OCPPVersion.VERSION_16: {
+ const stopTransactionPromises: Promise<void>[] = []
for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors(true)) {
if (
- connectorStatus.transactionStarted === true &&
- connectorStatus.status !== ConnectorStatusEnum.Finishing
+ connectorStatus.transactionStarted === true ||
+ connectorStatus.transactionStarting === true
) {
- await OCPP16ServiceUtils.stopTransactionOnConnector(chargingStation, connectorId, reason)
+ stopTransactionPromises.push(
+ (async (): Promise<void> => {
+ const pendingStart = OCPP16ServiceUtils.getPendingStartTransaction(connectorStatus)
+ if (pendingStart != null) await pendingStart.catch(() => undefined)
+ if (connectorStatus.transactionStarted !== true) return
+ const transactionId = connectorStatus.transactionId
+ try {
+ await OCPP16ServiceUtils.stopTransactionOnConnector(
+ chargingStation,
+ connectorId,
+ reason
+ )
+ } catch (error) {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.stopRunningTransactions: Failed to stop transaction ${transactionId?.toString() ?? 'unknown'} on connector ${connectorId.toString()}:`,
+ ensureError(error)
+ )
+ }
+ })()
+ )
}
}
+ await Promise.all(stopTransactionPromises)
break
}
case OCPPVersion.VERSION_20:
break
case OCPPVersion.VERSION_20:
case OCPPVersion.VERSION_201:
- for (const { connectorId, connectorStatus } of chargingStation.iterateConnectors()) {
+ for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors()) {
if ((connectorStatus.transactionEventQueue?.length ?? 0) > 0) {
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(chargingStation, connectorId).catch(
- (error: unknown) => {
- logger.error(
- `${chargingStation.logPrefix()} ${moduleName}.flushQueuedTransactionMessages: Error flushing queued TransactionEvents:`,
- error
- )
- }
- )
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(
+ chargingStation,
+ connectorId,
+ evseId
+ ).catch((error: unknown) => {
+ logger.error(
+ `${chargingStation.logPrefix()} ${moduleName}.flushQueuedTransactionMessages: Error flushing queued TransactionEvents:`,
+ error
+ )
+ })
}
}
+ if (chargingStation.started && !chargingStation.isStopping()) {
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(chargingStation)
+ }
break
default:
break
type ChargingStation,
getConfigurationKey,
} from '../../charging-station/index.js'
+import {
+ advanceStationEnergyRegister,
+ areMeterValueUnitsCompatible,
+ buildCoherentMeterValue,
+ buildCoherentMeterValueSnapshot,
+ buildSampledValueFamilyKey,
+ type BuildVersionedSampledValue,
+ canonicalizeCustomData,
+ isCoherentModeActive,
+ recordTransactionIntervalEmission,
+ resolveInletToOutputEfficiency,
+ resolveLinePhaseIndex,
+ resolveMeterValueUnitDivider,
+ resolveRootSeed,
+ truncateTransactionIntervalValue,
+} from '../../charging-station/meter-values/index.js'
import { BaseError, OCPPError } from '../../exception/index.js'
import {
type BootNotificationRequest,
MeterValueMeasurand,
MeterValuePhase,
MeterValueUnit,
+ OCPP16MeterValueFormat,
OCPP20ComponentName,
+ type OCPP20MeterValue,
OCPP20OptionalVariableName,
+ OCPP20PhaseEnumType,
OCPP20ReadingContextEnumType,
+ type OCPP20SampledValue,
OCPPVersion,
RequestCommand,
type SampledValue,
getRandomFloatFluctuatedRounded,
getRandomFloatRounded,
handleFileException,
+ isEmpty,
isNotEmptyArray,
isNotEmptyString,
+ isOCPP20x,
isValidRandomIntBounds,
JSONStringify,
logger,
min,
roundTo,
} from '../../utils/index.js'
-import {
- buildCoherentMeterValue,
- type BuildVersionedSampledValue,
- isCoherentModeActive,
- resolveRootSeed,
-} from '../meter-values/index.js'
import {
buildOCPP16BootNotificationRequest,
buildOCPP16SampledValue,
}
interface SingleValueMeasurandData {
+ phaseValues?: { template: SampledValueTemplate; value: number }[]
template: SampledValueTemplate
value: number
}
* @param validate - Ajv validation function for the command
* @param context - Description of the validation context (e.g. 'request', 'response')
* @param clonePayload - Whether to clone payload and convert dates before validation
+ * @param forceValidation - Whether to validate even when strict compliance is disabled
* @returns `true` when payload validation succeeds; `false` otherwise.
*/
// eslint-disable-next-line @typescript-eslint/no-unnecessary-type-parameters
payload: T,
validate: undefined | ValidateFunction<JsonType>,
context: string,
- clonePayload = false
+ clonePayload = false,
+ forceValidation = false
): boolean => {
- if (chargingStation.stationInfo?.ocppStrictCompliance === false) {
+ if (!forceValidation && chargingStation.stationInfo?.ocppStrictCompliance === false) {
return true
}
if (validate == null) {
chargingStation: ChargingStation,
connectorId: number,
evseId?: number,
- measurandsKey?: ConfigurationKeyType
+ measurandsKey?: ConfigurationKeyType,
+ connectorLocalFallback = false
): null | SingleValueMeasurandData => {
const socSampledValueTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
MeterValueMeasurand.STATE_OF_CHARGE,
- evseId
+ evseId,
+ undefined,
+ connectorLocalFallback
)
if (socSampledValueTemplate == null) {
return null
chargingStation: ChargingStation,
connectorId: number,
evseId?: number,
- measurandsKey?: ConfigurationKeyType
+ measurandsKey?: ConfigurationKeyType,
+ connectorLocalFallback = false
): null | SingleValueMeasurandData => {
const voltageSampledValueTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
MeterValueMeasurand.VOLTAGE,
- evseId
+ evseId,
+ undefined,
+ connectorLocalFallback
)
if (voltageSampledValueTemplate == null) {
return null
) => TSampledValue,
measurandsKey?: ConfigurationKeyType,
context?: MeterValueContext,
- noTemplateFallback?: number
+ noTemplateFallback?: number,
+ evseId?: number,
+ connectorLocalFallback = false
): void => {
const phaseSampledValueTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
MeterValueMeasurand.VOLTAGE,
- undefined,
- phaseLabel
+ evseId,
+ phaseLabel,
+ connectorLocalFallback
)
let phaseMeasurandValue: number | undefined
if (phaseSampledValueTemplate != null) {
connectorId: number,
interval: number,
evseId?: number,
- measurandsKey?: ConfigurationKeyType
+ measurandsKey?: ConfigurationKeyType,
+ snapshotOnly = false,
+ connectorLocalFallback = false,
+ timeScale = 1,
+ allowPhaseFallback = true
): null | SingleValueMeasurandData => {
- const energyTemplate = getSampledValueTemplate(
+ const enabledMeasurands = resolveEnabledMeasurands(chargingStation, measurandsKey)
+ const energyMeasurand =
+ enabledMeasurands?.has(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) !== false
+ ? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ : enabledMeasurands.has(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ ? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ : undefined
+ if (snapshotOnly && energyMeasurand == null) return null
+ const configuredEnergyTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
+ energyMeasurand,
+ evseId,
undefined,
- evseId
+ connectorLocalFallback
)
- if (energyTemplate == null) {
- return null
- }
+ const intervalTemplates =
+ allowPhaseFallback && energyMeasurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ? resolveEnergyIntervalTemplates(chargingStation, connectorId, evseId, undefined)
+ : []
+ const energyTemplate = configuredEnergyTemplate ?? intervalTemplates.at(0)
+ if (energyTemplate == null) return null
+ if (snapshotOnly) return { template: energyTemplate, value: 0 }
checkMeasurandPowerDivider(chargingStation, energyTemplate.measurand)
const unitDivider =
energyTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? Constants.UNIT_DIVIDER_KILO : 1
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
+ )
const connectorMaximumEnergyRounded = roundTo(
(connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR,
2
)
- const connectorMinimumEnergyRounded = roundTo(energyTemplate.minimumValue ?? 0, 2)
+ const minimumEnergy = energyTemplate.minimumValue ?? 0
+ const connectorMinimumEnergyRounded = roundTo(minimumEnergy * unitDivider * timeScale, 2)
const energyValueRounded = isNotEmptyString(energyTemplate.value)
? getRandomFloatFluctuatedRounded(
connectorMaximumEnergyRounded,
connectorMinimumEnergyRounded,
{
- fallbackValue: connectorMinimumEnergyRounded,
+ fallbackValue: minimumEnergy,
limitationEnabled: chargingStation.stationInfo?.customValueLimitationMeterValues,
- unitMultiplier: unitDivider,
+ unitMultiplier: unitDivider * timeScale,
}
),
energyTemplate.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
}
const updateConnectorEnergyValues = (
+ chargingStation: ChargingStation,
connectorStatus: ConnectorStatus | undefined,
- energyValue: number
+ energyValue: number,
+ evseId?: number,
+ transactionEnergyValue = energyValue,
+ timestamp?: Date
): void => {
if (connectorStatus != null) {
if (
connectorStatus.transactionEnergyActiveImportRegisterValue >= 0
) {
connectorStatus.energyActiveImportRegisterValue += energyValue
- connectorStatus.transactionEnergyActiveImportRegisterValue += energyValue
+ connectorStatus.transactionEnergyActiveImportRegisterValue += transactionEnergyValue
} else {
connectorStatus.energyActiveImportRegisterValue = 0
connectorStatus.transactionEnergyActiveImportRegisterValue = 0
}
+ advanceStationEnergyRegister(
+ chargingStation,
+ evseId,
+ chargingStation.stationInfo?.currentOutType ?? CurrentType.AC,
+ MeterValueLocation.OUTLET,
+ energyValue
+ )
+ if (
+ evseId != null &&
+ timestamp != null &&
+ chargingStation.stationInfo?.meteringPerTransaction !== true
+ ) {
+ const evseStatus = chargingStation.getEvseStatus(evseId)
+ if (
+ evseStatus != null &&
+ (evseStatus.energyActiveImportRegisterLastUpdatedAt == null ||
+ timestamp > evseStatus.energyActiveImportRegisterLastUpdatedAt)
+ ) {
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = timestamp
+ }
+ }
}
}
chargingStation: ChargingStation,
connectorId: number,
evseId?: number,
- measurandsKey?: ConfigurationKeyType
+ measurandsKey?: ConfigurationKeyType,
+ idle = false,
+ connectorLocalFallback = false
): MultiPhaseMeasurandData | null => {
const powerTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
- evseId
+ evseId,
+ undefined,
+ connectorLocalFallback
)
if (powerTemplate == null) {
return null
measurandsKey,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
evseId,
- MeterValuePhase.L1_N
+ MeterValuePhase.L1_N,
+ connectorLocalFallback
),
L2: getSampledValueTemplate(
chargingStation,
measurandsKey,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
evseId,
- MeterValuePhase.L2_N
+ MeterValuePhase.L2_N,
+ connectorLocalFallback
),
L3: getSampledValueTemplate(
chargingStation,
measurandsKey,
MeterValueMeasurand.POWER_ACTIVE_IMPORT,
evseId,
- MeterValuePhase.L3_N
+ MeterValuePhase.L3_N,
+ connectorLocalFallback
),
}
}
checkMeasurandPowerDivider(chargingStation, powerTemplate.measurand)
const powerValues: MeasurandValues = {} as MeasurandValues
+ if (idle) {
+ return {
+ perPhaseTemplates,
+ template: powerTemplate,
+ values: { allPhases: 0, L1: 0, L2: 0, L3: 0 },
+ }
+ }
const unitDivider =
powerTemplate.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
+ )
const connectorMaximumPower = Math.round(connectorMaximumAvailablePower)
const connectorMaximumPowerPerPhase = Math.round(
connectorMaximumAvailablePower / chargingStation.getNumberOfPhases()
chargingStation: ChargingStation,
connectorId: number,
evseId?: number,
- measurandsKey?: ConfigurationKeyType
+ measurandsKey?: ConfigurationKeyType,
+ idle = false,
+ connectorLocalFallback = false
): MultiPhaseMeasurandData | null => {
const currentTemplate = getSampledValueTemplate(
chargingStation,
connectorId,
measurandsKey,
MeterValueMeasurand.CURRENT_IMPORT,
- evseId
+ evseId,
+ undefined,
+ connectorLocalFallback
)
if (currentTemplate == null) {
return null
measurandsKey,
MeterValueMeasurand.CURRENT_IMPORT,
evseId,
- MeterValuePhase.L1
+ MeterValuePhase.L1,
+ connectorLocalFallback
),
L2: getSampledValueTemplate(
chargingStation,
measurandsKey,
MeterValueMeasurand.CURRENT_IMPORT,
evseId,
- MeterValuePhase.L2
+ MeterValuePhase.L2,
+ connectorLocalFallback
),
L3: getSampledValueTemplate(
chargingStation,
measurandsKey,
MeterValueMeasurand.CURRENT_IMPORT,
evseId,
- MeterValuePhase.L3
+ MeterValuePhase.L3,
+ connectorLocalFallback
),
}
}
checkMeasurandPowerDivider(chargingStation, currentTemplate.measurand)
const currentValues: MeasurandValues = {} as MeasurandValues
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ if (idle) {
+ return {
+ perPhaseTemplates,
+ template: currentTemplate,
+ values: { allPhases: 0, L1: 0, L2: 0, L3: 0 },
+ }
+ }
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
+ )
const connectorMinimumAmperage = currentTemplate.minimumValue ?? 0
let connectorMaximumAmperage: number
timestamp: new Date(),
})
+/**
+ * Resolved meter-value identity shared by {@link buildIdentifiedMeterValue} and
+ * {@link createVersionedSampledValueDispatcher}. Either transaction-derived
+ * (legacy callers pass only `transactionId`) or provided directly for
+ * clock-aligned reporting (`connectorId` + `evseId` + `snapshot` + optional
+ * UTC slot `timestamp`, #2011 Category 2F).
+ */
+interface ResolvedMeterValueIdentity {
+ advanceEnergy?: boolean
+ commitState?: boolean
+ connectorId?: number
+ deferEnergyInterval?: boolean
+ energyElapsedInterval?: number
+ energyIntervalWhOverride?: number
+ energyNominalInterval?: number
+ energyRegisterWhOverride?: number
+ evseId?: number
+ idle?: boolean
+ /** Projects output-side energy overrides to their configured DC meter location. */
+ projectDcForLocation?: boolean
+ sampledValueBaseline?: OCPP20SampledValue[]
+ sampledValueTemplates?: SampledValueTemplate[]
+ snapshot?: boolean
+ suppressSigning?: boolean
+ timestamp?: Date
+ transactionId?: number | string
+}
+
/**
* Internal dispatch bag returned by {@link createVersionedSampledValueDispatcher}
- * and consumed by {@link buildMeterValue}. Not exported: kept out of the
+ * and consumed by {@link buildIdentifiedMeterValue}. Not exported: kept out of the
* module surface so external callers rely on the higher-level entry point.
*/
interface VersionedSampledValueDispatch {
+ buildUnsignedVersionedSampledValue: BuildVersionedSampledValue
buildVersionedSampledValue: BuildVersionedSampledValue
connectorId: number
evseId: number | undefined
/**
* Resolves the connector/EVSE ids and constructs the OCPP-version dispatcher
- * used by {@link buildMeterValue}. Extracted verbatim from the original
- * `buildMeterValue` switch so behavior is preserved exactly.
+ * used by {@link buildIdentifiedMeterValue}, the shared builder behind {@link buildMeterValue} and {@link buildClockAlignedConnectorMeterValue}.
+ * The identity is either transaction-derived (legacy callers) or provided
+ * directly (clock-aligned reporting for idle connectors, #2011 Category 2F).
* @param chargingStation - Target charging station.
- * @param transactionId - Active transaction identifier.
- * @param context - Optional MeterValue reading context (drives signing
- * configuration for OCPP 2.0.1).
+ * @param identity - Meter value source.
+ * @param identity.connectorId - Direct connector id (clock path) or resolved
+ * from `transactionId`.
+ * @param identity.evseId - Direct EVSE id or resolved from `transactionId`.
+ * @param identity.snapshot - Whether this build serializes state without
+ * advancing physical registers or coherent-session state.
+ * @param identity.transactionId - Active transaction identifier when building
+ * transactional meter values.
+ * @param context - Optional MeterValue reading context.
+ * @param signReadingsComponent - Device Model controller whose signing policy applies.
* @returns The dispatch bundle.
*/
const createVersionedSampledValueDispatcher = (
chargingStation: ChargingStation,
- transactionId: number | string,
- context?: MeterValueContext
+ identity: ResolvedMeterValueIdentity,
+ context: MeterValueContext | undefined,
+ signReadingsComponent: OCPP20ComponentName
): VersionedSampledValueDispatch => {
- const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId)
+ const { transactionId } = identity
+ const connectorId =
+ identity.connectorId ?? chargingStation.getConnectorIdByTransactionId(transactionId)
let evseId: number | undefined
let buildVersionedSampledValue: BuildVersionedSampledValue
+ let buildUnsignedVersionedSampledValue: BuildVersionedSampledValue
let signingConfig: SampledValueSigningConfig | undefined
const signingState = { publicKeyIncluded: false }
switch (chargingStation.stationInfo?.ocppVersion) {
)
}
buildVersionedSampledValue = buildOCPP16SampledValue
+ buildUnsignedVersionedSampledValue = buildOCPP16SampledValue
break
case OCPPVersion.VERSION_20:
case OCPPVersion.VERSION_201:
- evseId = chargingStation.getEvseIdByTransactionId(transactionId)
+ evseId = identity.evseId ?? chargingStation.getEvseIdByTransactionId(transactionId)
if (connectorId == null || evseId == null) {
throw new OCPPError(
ErrorType.INTERNAL_ERROR,
{
const signReadings = isOCPP20FlagEnabled(
chargingStation,
- OCPP20ComponentName.SampledDataCtrlr,
+ signReadingsComponent,
StandardParametersKey.SignReadings
)
+ // Standard SignReadings applies to every configured aligned sample,
+ // including station/EVSE readings that have no transaction identity.
if (signReadings) {
let signingEnabledForContext = true
- if (context === OCPP20ReadingContextEnumType.TRANSACTION_BEGIN) {
+ if (
+ context === OCPP20ReadingContextEnumType.TRANSACTION_BEGIN &&
+ signReadingsComponent === OCPP20ComponentName.SampledDataCtrlr
+ ) {
signingEnabledForContext = isOCPP20FlagEnabled(
chargingStation,
OCPP20ComponentName.SampledDataCtrlr,
} else if (
context == null ||
context === OCPP20ReadingContextEnumType.SAMPLE_PERIODIC ||
- context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ (context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK && transactionId != null)
) {
signingEnabledForContext = isOCPP20FlagEnabled(
chargingStation,
- OCPP20ComponentName.SampledDataCtrlr,
+ signReadingsComponent,
VendorParametersKey.SignUpdatedReadings
)
}
meterSerialNumber: chargingStation.stationInfo.meterSerialNumber ?? 'UNKNOWN',
publicKeyHex,
publicKeySentInTransaction:
- chargingStation.getConnectorStatus(connectorId)?.publicKeySentInTransaction ??
- false,
+ chargingStation.getConnectorStatus(connectorId, evseId)
+ ?.publicKeySentInTransaction ?? false,
publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
publicKeyWithSignedMeterValueStr
),
signingMethod: prerequisiteResult.signingMethod,
- transactionId,
+ transactionId:
+ transactionId ??
+ `${chargingStation.stationInfo.chargingStationId ?? 'UNKNOWN'}:${evseId.toString()}:${connectorId.toString()}`,
}
} else {
logger.warn(
- `${chargingStation.logPrefix()} ${moduleName}.buildMeterValue: Signed meter values disabled: ${prerequisiteResult.reason}`
+ `${chargingStation.logPrefix()} ${moduleName}.createVersionedSampledValueDispatcher: Signed meter values disabled: ${prerequisiteResult.reason}`
)
}
}
)
if (result.publicKeyIncluded) {
signingState.publicKeyIncluded = true
+ if (signingConfig != null) signingConfig.publicKeySentInTransaction = true
}
return result.sampledValue
}
+ buildUnsignedVersionedSampledValue = (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ ctx?: MeterValueContext,
+ phase?: MeterValuePhase
+ ) => buildOCPP20SampledValue(sampledValueTemplate, value, ctx, phase).sampledValue
}
break
default:
RequestCommand.METER_VALUES
)
}
- return { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState }
+ return {
+ buildUnsignedVersionedSampledValue,
+ buildVersionedSampledValue,
+ connectorId,
+ evseId,
+ signingConfig,
+ signingState,
+ }
}
// Module-scope keyed by `ChargingStation` instance (auto-collected on GC).
const enabled = new Set<MeterValueMeasurand>()
for (const entry of rawValue.split(',')) {
const trimmed = entry.trim()
- // Kept as `.length === 0`: entry is already trimmed above; `isEmpty()` here would be
- // semantically identical (its string branch is `value.trim().length === 0`) — direct
- // check avoids a redundant re-trim.
- if (trimmed.length === 0) {
+ if (isEmpty(trimmed)) {
continue
}
if (KNOWN_MEASURANDS.has(trimmed)) {
return enabled
}
+const resolveClockAlignedTemplates = (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number | undefined
+): SampledValueTemplate[] => {
+ const evseTemplates =
+ evseId != null ? chargingStation.getEvseStatus(evseId)?.MeterValues : undefined
+ return isNotEmptyArray(evseTemplates)
+ ? evseTemplates
+ : (chargingStation.getConnectorStatus(connectorId, evseId)?.MeterValues ?? [])
+}
+
+const getEnergyTemplateIdentity = (
+ template: SampledValueTemplate,
+ ocppVersion: OCPPVersion | undefined,
+ context: MeterValueContext | undefined
+): string => {
+ const resolved = resolveSampledValueFields(template, 0, context, template.phase)
+ return JSON.stringify([
+ resolved.phase,
+ resolved.location,
+ resolved.unit,
+ resolved.context,
+ ocppVersion === OCPPVersion.VERSION_16
+ ? (template.format ?? OCPP16MeterValueFormat.RAW)
+ : canonicalizeCustomData(template.customData),
+ ])
+}
+
+const addEnergyIntervalTemplateFallbacks = (
+ templates: readonly SampledValueTemplate[],
+ ocppVersion: OCPPVersion | undefined,
+ context: MeterValueContext | undefined
+): SampledValueTemplate[] => {
+ const explicitIntervalIdentities = new Set(
+ templates
+ .filter(template => template.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ .map(template => getEnergyTemplateIdentity(template, ocppVersion, context))
+ )
+ return templates.flatMap((template): SampledValueTemplate[] => {
+ const measurand = template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ if (
+ measurand !== MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER ||
+ explicitIntervalIdentities.has(getEnergyTemplateIdentity(template, ocppVersion, context))
+ ) {
+ return [template]
+ }
+ return [
+ template,
+ {
+ ...template,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ } as SampledValueTemplate,
+ ]
+ })
+}
+
+const resolveEnergyIntervalTemplates = (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number | undefined,
+ context: MeterValueContext | undefined
+): SampledValueTemplate[] =>
+ addEnergyIntervalTemplateFallbacks(
+ resolveClockAlignedTemplates(chargingStation, connectorId, evseId),
+ chargingStation.stationInfo?.ocppVersion,
+ context
+ ).filter(template => template.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL)
+
+// Only active import/export quantities have a defined DC output-to-AC-input projection.
+export const DC_STATION_AGGREGATION_DIRECTION: ReadonlyMap<
+ MeterValueMeasurand,
+ 'export' | 'import'
+> = new Map([
+ [MeterValueMeasurand.ENERGY_ACTIVE_EXPORT_INTERVAL, 'export'],
+ [MeterValueMeasurand.ENERGY_ACTIVE_EXPORT_REGISTER, 'export'],
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL, 'import'],
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, 'import'],
+ [MeterValueMeasurand.POWER_ACTIVE_EXPORT, 'export'],
+ [MeterValueMeasurand.POWER_ACTIVE_IMPORT, 'import'],
+])
+
+export const projectSnapshotDcOutputValue = (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number | undefined,
+ location: MeterValueLocation | undefined,
+ measurand: MeterValueMeasurand,
+ outputValue: number,
+ baselineAlreadyProjected: boolean
+): number => {
+ const projectionDirection = DC_STATION_AGGREGATION_DIRECTION.get(measurand)
+ if (
+ chargingStation.stationInfo?.currentOutType !== CurrentType.DC ||
+ projectionDirection == null
+ ) {
+ return outputValue
+ }
+ const configuredEfficiency = chargingStation.stationInfo.conversionEfficiency ?? 1
+ const conversionEfficiency = configuredEfficiency > 0 ? configuredEfficiency : 1
+ if (evseId === 0 || (evseId == null && connectorId === 0)) {
+ if (!baselineAlreadyProjected || location !== MeterValueLocation.OUTLET) return outputValue
+ return projectionDirection === 'import'
+ ? outputValue * conversionEfficiency
+ : outputValue / conversionEfficiency
+ }
+ if (baselineAlreadyProjected || location !== MeterValueLocation.INLET) return outputValue
+ return projectionDirection === 'import'
+ ? outputValue / conversionEfficiency
+ : outputValue * conversionEfficiency
+}
+
+const isLinePhase = (phase: MeterValuePhase | undefined): boolean =>
+ resolveLinePhaseIndex(phase) != null
+
+const resolveSnapshotPhaseFamily = (
+ phase: MeterValuePhase | undefined
+): 'Aggregate' | 'Line' | 'LineToLine' | 'Neutral' | 'Unsupported' => {
+ if (phase == null) return 'Aggregate'
+ if (phase === MeterValuePhase.N) return 'Neutral'
+ if (isLinePhase(phase)) return 'Line'
+ switch (phase) {
+ case MeterValuePhase.L1_L2:
+ case MeterValuePhase.L2_L3:
+ case MeterValuePhase.L3_L1:
+ return 'LineToLine'
+ default:
+ return 'Unsupported'
+ }
+}
+
+const buildEnergyIntervalSampledValues = (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number | undefined,
+ templates: readonly SampledValueTemplate[],
+ energyWh: number,
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ context?: MeterValueContext,
+ projectDcForLocation = true
+): SampledValue[] => {
+ const sampledValues: SampledValue[] = []
+ const numberOfPhases = chargingStation.getNumberOfPhases()
+ for (const template of templates) {
+ const phaseFamily = resolveSnapshotPhaseFamily(template.phase)
+ if (phaseFamily !== 'Aggregate' && phaseFamily !== 'Line') continue
+ if (phaseFamily === 'Line') {
+ const linePhaseIndex = resolveLinePhaseIndex(template.phase)
+ if (
+ chargingStation.stationInfo?.currentOutType !== CurrentType.AC ||
+ linePhaseIndex == null ||
+ linePhaseIndex > numberOfPhases
+ ) {
+ continue
+ }
+ }
+ const outputValue = phaseFamily === 'Line' ? energyWh / numberOfPhases : energyWh
+ const physicalValue = projectDcForLocation
+ ? projectSnapshotDcOutputValue(
+ chargingStation,
+ connectorId,
+ evseId,
+ template.location,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ outputValue,
+ false
+ )
+ : outputValue
+ sampledValues.push(
+ buildVersionedSampledValue(
+ template,
+ truncateTransactionIntervalValue(
+ physicalValue /
+ resolveMeterValueUnitDivider(
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ template.unit as string | undefined
+ )
+ ),
+ context,
+ template.phase
+ )
+ )
+ }
+ return sampledValues
+}
+
+const applySnapshotRegisterValuesWithoutPhases = (
+ templates: SampledValueTemplate[],
+ context: MeterValueContext | undefined
+): SampledValueTemplate[] => {
+ const result = templates.filter(
+ template =>
+ (template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) !==
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ const families = new Map<string, SampledValueTemplate[]>()
+ for (const template of templates) {
+ if (
+ (template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) !==
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) {
+ continue
+ }
+ const identity = resolveSampledValueFields(template, 0, context, template.phase)
+ const key = buildSampledValueFamilyKey({
+ context: identity.context,
+ customData: template.customData,
+ location: identity.location,
+ measurand: identity.measurand,
+ unit: identity.unit,
+ })
+ const family = families.get(key) ?? []
+ family.push(template)
+ families.set(key, family)
+ }
+ for (const family of families.values()) {
+ const lineTemplates = family.filter(template => isLinePhase(template.phase))
+ if (isEmpty(lineTemplates)) {
+ result.push(...family)
+ continue
+ }
+ const nonLineTemplates = family.filter(template => !isLinePhase(template.phase))
+ if (nonLineTemplates.some(template => template.phase == null)) {
+ result.push(...nonLineTemplates)
+ } else {
+ result.push({ ...lineTemplates[0], phase: undefined }, ...nonLineTemplates)
+ }
+ }
+ return result
+}
+
+const expandClockAlignedSnapshotSamples = (
+ chargingStation: ChargingStation,
+ connectorId: number,
+ evseId: number | undefined,
+ baseline: OCPP20SampledValue[],
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ measurandsKey: ConfigurationKeyType | undefined,
+ context: MeterValueContext | undefined,
+ registerValuesWithoutPhases: boolean,
+ idle: boolean,
+ transactional: boolean,
+ energyRegisterWhOverride?: number,
+ preferBaseline = false,
+ sampledValueTemplates?: SampledValueTemplate[]
+): SampledValue[] => {
+ const enabledMeasurands = resolveEnabledMeasurands(chargingStation, measurandsKey)
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ const energyRegister = Math.max(
+ 0,
+ energyRegisterWhOverride ??
+ (transactional && chargingStation.stationInfo?.meteringPerTransaction === true
+ ? (connectorStatus?.transactionEnergyActiveImportRegisterValue ?? 0)
+ : (connectorStatus?.energyActiveImportRegisterValue ?? 0))
+ )
+ const numberOfPhases = Math.max(1, chargingStation.getNumberOfPhases())
+ const intervalBaselineKey = isOCPP20x(chargingStation.stationInfo?.ocppVersion)
+ ? (measurandsKey ?? 'default')
+ : 'default'
+ const intervalEnergy =
+ connectorStatus == null || !transactional || !preferBaseline
+ ? undefined
+ : Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ (connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[
+ intervalBaselineKey
+ ] ?? 0)
+ ) + (connectorStatus.transactionEnergyActiveImportIntervalCarry?.[intervalBaselineKey] ?? 0)
+ const expanded: SampledValue[] = []
+ const resolvedTemplates: SampledValueTemplate[] = (
+ sampledValueTemplates ?? resolveClockAlignedTemplates(chargingStation, connectorId, evseId)
+ ).map((template): SampledValueTemplate => {
+ const measurand = template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ return evseId === 0 && template.location == null && isElectricalMeasurand(measurand)
+ ? ({ ...template, location: MeterValueLocation.INLET } as SampledValueTemplate)
+ : template
+ })
+ const templatesWithIntervalFallback =
+ enabledMeasurands?.has(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) === true
+ ? addEnergyIntervalTemplateFallbacks(
+ resolvedTemplates,
+ chargingStation.stationInfo?.ocppVersion,
+ context
+ )
+ : resolvedTemplates
+ const templatesBeforePhaseSuppression = (() => {
+ if (sampledValueTemplates == null) return templatesWithIntervalFallback
+ const templatesByIdentity = new Map<string, SampledValueTemplate>()
+ for (const template of templatesWithIntervalFallback) {
+ const identity = resolveSampledValueFields(template, 0, context, template.phase)
+ const key = JSON.stringify([
+ identity.measurand,
+ identity.phase,
+ identity.location,
+ identity.unit,
+ chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_16
+ ? template.format
+ : canonicalizeCustomData(template.customData),
+ ])
+ if (!templatesByIdentity.has(key)) templatesByIdentity.set(key, template)
+ }
+ return [...templatesByIdentity.values()]
+ })()
+ const templates = registerValuesWithoutPhases
+ ? applySnapshotRegisterValuesWithoutPhases(templatesBeforePhaseSuppression, context)
+ : templatesBeforePhaseSuppression
+
+ for (const template of templates) {
+ const measurand = template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ if (enabledMeasurands != null && !enabledMeasurands.has(measurand)) continue
+ const phaseFamily = resolveSnapshotPhaseFamily(template.phase)
+ if (phaseFamily === 'Unsupported') continue
+ const resolvedIdentity = resolveSampledValueFields(template, 0, context, template.phase)
+ const canUseLocationAgnosticStationSource =
+ preferBaseline &&
+ evseId === 0 &&
+ (chargingStation.stationInfo?.currentOutType !== CurrentType.DC ||
+ DC_STATION_AGGREGATION_DIRECTION.has(measurand))
+ const resolvedLinePhaseIndex = resolveLinePhaseIndex(template.phase)
+ const exactSource = baseline.find(
+ sample =>
+ (sample.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) === measurand &&
+ (sample.phase === template.phase ||
+ (resolvedLinePhaseIndex != null &&
+ resolveLinePhaseIndex(sample.phase) === resolvedLinePhaseIndex)) &&
+ canonicalizeCustomData(sample.customData) === canonicalizeCustomData(template.customData) &&
+ (sample.location === resolvedIdentity.location || canUseLocationAgnosticStationSource) &&
+ (!preferBaseline ||
+ areMeterValueUnitsCompatible(
+ measurand,
+ sample.unitOfMeasure?.unit,
+ resolvedIdentity.unit
+ ))
+ )
+ if (phaseFamily === 'Line') {
+ const linePhaseIndex = resolveLinePhaseIndex(template.phase)
+ if (
+ chargingStation.stationInfo?.currentOutType !== CurrentType.AC ||
+ linePhaseIndex == null ||
+ linePhaseIndex > numberOfPhases
+ ) {
+ continue
+ }
+ }
+ if (
+ phaseFamily === 'Neutral' &&
+ chargingStation.stationInfo?.currentOutType !== CurrentType.AC
+ ) {
+ continue
+ }
+ const aggregateSource = baseline.find(
+ sample =>
+ (sample.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) === measurand &&
+ sample.phase == null &&
+ canonicalizeCustomData(sample.customData) === canonicalizeCustomData(template.customData) &&
+ (sample.location === resolvedIdentity.location || canUseLocationAgnosticStationSource) &&
+ (!preferBaseline ||
+ areMeterValueUnitsCompatible(
+ measurand,
+ sample.unitOfMeasure?.unit,
+ resolvedIdentity.unit
+ ))
+ )
+ const source =
+ exactSource ??
+ (phaseFamily === 'Aggregate' || phaseFamily === 'Line' ? aggregateSource : undefined)
+ const phasedPowerByLine = new Map<number, OCPP20SampledValue>()
+ if (
+ source == null &&
+ preferBaseline &&
+ phaseFamily === 'Aggregate' &&
+ measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ ) {
+ for (const sample of baseline) {
+ const line = resolveLinePhaseIndex(sample.phase)
+ if (
+ line != null &&
+ !phasedPowerByLine.has(line) &&
+ canonicalizeCustomData(sample.customData) ===
+ canonicalizeCustomData(template.customData) &&
+ sample.measurand === measurand &&
+ (sample.location === resolvedIdentity.location || canUseLocationAgnosticStationSource) &&
+ areMeterValueUnitsCompatible(measurand, sample.unitOfMeasure?.unit, resolvedIdentity.unit)
+ ) {
+ phasedPowerByLine.set(line, sample)
+ }
+ }
+ }
+ const phasedPower =
+ phasedPowerByLine.size >= numberOfPhases
+ ? [...phasedPowerByLine.values()].reduce(
+ (total, sample) =>
+ total +
+ sample.value * resolveMeterValueUnitDivider(measurand, sample.unitOfMeasure?.unit),
+ 0
+ )
+ : undefined
+ let rawValue: number | undefined
+ if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL && intervalEnergy != null) {
+ rawValue = phaseFamily === 'Line' ? intervalEnergy / numberOfPhases : intervalEnergy
+ } else if (measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) {
+ if (evseId !== 0 && source != null && preferBaseline) {
+ rawValue =
+ source.value * resolveMeterValueUnitDivider(measurand, source.unitOfMeasure?.unit)
+ } else if (phaseFamily === 'Aggregate') {
+ rawValue = energyRegister
+ } else if (phaseFamily === 'Line') {
+ rawValue = energyRegister / numberOfPhases
+ }
+ } else if (measurand === MeterValueMeasurand.CURRENT_IMPORT && phaseFamily === 'Neutral') {
+ rawValue = 0
+ } else if (measurand === MeterValueMeasurand.VOLTAGE && phaseFamily === 'Neutral') {
+ rawValue = 0
+ } else if (
+ (measurand === MeterValueMeasurand.CURRENT_IMPORT ||
+ measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT) &&
+ (phaseFamily === 'LineToLine' || phaseFamily === 'Neutral')
+ ) {
+ continue
+ } else if (
+ measurand === MeterValueMeasurand.VOLTAGE &&
+ phaseFamily === 'LineToLine' &&
+ (numberOfPhases !== 3 || chargingStation.stationInfo?.currentOutType !== CurrentType.AC)
+ ) {
+ continue
+ } else if (
+ measurand === MeterValueMeasurand.STATE_OF_CHARGE &&
+ (idle || phaseFamily !== 'Aggregate')
+ ) {
+ continue
+ } else if (source != null && preferBaseline) {
+ rawValue = source.value * resolveMeterValueUnitDivider(measurand, source.unitOfMeasure?.unit)
+ if (
+ (measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT ||
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) &&
+ source.phase == null &&
+ phaseFamily === 'Line'
+ ) {
+ rawValue /= numberOfPhases
+ }
+ } else if (phasedPower != null) {
+ rawValue = phasedPower
+ } else if (measurand === MeterValueMeasurand.VOLTAGE && isNotEmptyString(template.value)) {
+ rawValue = getRandomFloatFluctuatedRounded(
+ convertToFloat(template.value),
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ )
+ } else if (
+ measurand === MeterValueMeasurand.STATE_OF_CHARGE &&
+ isNotEmptyString(template.value)
+ ) {
+ rawValue = getRandomFloatFluctuatedRounded(
+ convertToInt(template.value),
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ )
+ } else if (
+ (measurand === MeterValueMeasurand.CURRENT_IMPORT ||
+ measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT) &&
+ (isNotEmptyString(template.value) ||
+ (source == null && (phaseFamily === 'Aggregate' || phaseFamily === 'Line')))
+ ) {
+ if (idle) {
+ rawValue = 0
+ } else {
+ const divider = resolveMeterValueUnitDivider(measurand, template.unit as string | undefined)
+ const maximumValue =
+ measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ ? chargingStation.getConnectorMaximumAvailablePower(connectorId, evseId) /
+ (phaseFamily === 'Line' ? numberOfPhases : 1) /
+ divider
+ : chargingStation.stationInfo?.currentOutType === CurrentType.AC
+ ? ACElectricUtils.amperagePerPhaseFromPower(
+ numberOfPhases,
+ chargingStation.getConnectorMaximumAvailablePower(connectorId, evseId),
+ chargingStation.getVoltageOut()
+ )
+ : DCElectricUtils.amperage(
+ chargingStation.getConnectorMaximumAvailablePower(connectorId, evseId),
+ chargingStation.getVoltageOut()
+ )
+ const minimumValue = template.minimumValue ?? 0
+ const value = isNotEmptyString(template.value)
+ ? getRandomFloatFluctuatedRounded(
+ getLimitFromSampledValueTemplateCustomValue(
+ template.value,
+ maximumValue,
+ minimumValue,
+ {
+ fallbackValue: minimumValue,
+ limitationEnabled: chargingStation.stationInfo?.customValueLimitationMeterValues,
+ }
+ ),
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ )
+ : getRandomFloatRounded(minimumValue, maximumValue)
+ rawValue = value * divider
+ }
+ } else if (
+ measurand === MeterValueMeasurand.STATE_OF_CHARGE &&
+ source == null &&
+ isValidRandomIntBounds(template.minimumValue ?? 0, Constants.SOC_MAXIMUM_PERCENT)
+ ) {
+ rawValue = randomInt(template.minimumValue ?? 0, Constants.SOC_MAXIMUM_PERCENT + 1)
+ } else if (source != null) {
+ rawValue = source.value * resolveMeterValueUnitDivider(measurand, source.unitOfMeasure?.unit)
+ if (
+ (measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT ||
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) &&
+ source.phase == null &&
+ phaseFamily === 'Line'
+ ) {
+ rawValue /= numberOfPhases
+ }
+ } else if (isNotEmptyString(template.value)) {
+ rawValue =
+ getRandomFloatFluctuatedRounded(
+ convertToFloat(template.value),
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ ) * resolveMeterValueUnitDivider(measurand, template.unit as string | undefined)
+ } else if (measurand === MeterValueMeasurand.VOLTAGE) {
+ const nominal =
+ phaseFamily === 'LineToLine'
+ ? chargingStation.getVoltageOut() * Math.sqrt(3)
+ : chargingStation.getVoltageOut()
+ rawValue = getRandomFloatFluctuatedRounded(
+ nominal,
+ template.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
+ )
+ }
+ if (rawValue == null) continue
+ const physicalValue = projectSnapshotDcOutputValue(
+ chargingStation,
+ connectorId,
+ evseId,
+ resolvedIdentity.location,
+ measurand,
+ rawValue,
+ preferBaseline
+ )
+ const value = roundTo(
+ physicalValue / resolveMeterValueUnitDivider(measurand, template.unit as string | undefined),
+ 2
+ )
+ expanded.push(buildVersionedSampledValue(template, value, context))
+ }
+ return applyClockAlignedVoltageControls(
+ chargingStation,
+ expanded as OCPP20SampledValue[],
+ buildVersionedSampledValue,
+ context
+ )
+}
+
+const applyClockAlignedVoltageControls = (
+ chargingStation: ChargingStation,
+ sampledValues: OCPP20SampledValue[],
+ buildVersionedSampledValue: BuildVersionedSampledValue,
+ context: MeterValueContext | undefined
+): OCPP20SampledValue[] => {
+ if (
+ chargingStation.getNumberOfPhases() !== 3 ||
+ chargingStation.stationInfo?.currentOutType !== CurrentType.AC
+ ) {
+ return sampledValues
+ }
+ const aggregateVoltages = sampledValues.filter(
+ sample => sample.measurand === MeterValueMeasurand.VOLTAGE && sample.phase == null
+ )
+ if (isEmpty(aggregateVoltages)) return sampledValues
+ const configuredPhaseSamples = sampledValues.filter(
+ sample => sample.measurand === MeterValueMeasurand.VOLTAGE && sample.phase != null
+ )
+ const automaticSamples: OCPP20SampledValue[] = []
+ for (const aggregateVoltage of aggregateVoltages) {
+ const configuredPhases = new Set(
+ configuredPhaseSamples
+ .filter(
+ sample =>
+ sample.context === aggregateVoltage.context &&
+ canonicalizeCustomData(sample.customData) ===
+ canonicalizeCustomData(aggregateVoltage.customData) &&
+ sample.format === aggregateVoltage.format &&
+ sample.location === aggregateVoltage.location &&
+ sample.unitOfMeasure?.unit === aggregateVoltage.unitOfMeasure?.unit
+ )
+ .map(sample => sample.phase)
+ )
+ const template = {
+ ...aggregateVoltage,
+ unit: aggregateVoltage.unitOfMeasure?.unit,
+ } as SampledValueTemplate
+ const addPhase = (phase: OCPP20PhaseEnumType, value: number): void => {
+ if (!configuredPhases.has(phase)) {
+ automaticSamples.push(
+ buildVersionedSampledValue(
+ template,
+ roundTo(value, 2),
+ context,
+ phase
+ ) as OCPP20SampledValue
+ )
+ }
+ }
+ addPhase(OCPP20PhaseEnumType.L1_N, aggregateVoltage.value)
+ addPhase(OCPP20PhaseEnumType.L2_N, aggregateVoltage.value)
+ addPhase(OCPP20PhaseEnumType.L3_N, aggregateVoltage.value)
+ if (chargingStation.stationInfo.phaseLineToLineVoltageMeterValues === true) {
+ const lineToLineVoltage = aggregateVoltage.value * Math.sqrt(3)
+ addPhase(OCPP20PhaseEnumType.L1_L2, lineToLineVoltage)
+ addPhase(OCPP20PhaseEnumType.L2_L3, lineToLineVoltage)
+ addPhase(OCPP20PhaseEnumType.L3_L1, lineToLineVoltage)
+ }
+ }
+ const retainedSamples =
+ chargingStation.stationInfo.mainVoltageMeterValues === true
+ ? sampledValues
+ : sampledValues.filter(
+ sample => !(sample.measurand === MeterValueMeasurand.VOLTAGE && sample.phase == null)
+ )
+ return [...retainedSamples, ...automaticSamples]
+}
/**
* Builds a complete MeterValue with all configured measurands for a transaction.
* @param chargingStation - Target charging station
* @param measurandsKey - Configuration key for the sampled measurands list
* @param context - Meter value reading context
* @param debug - Enable debug logging for measurand validation
+ * @param identity - Optional direct connector/EVSE identity for EVSE-local connector ids
* @returns Populated MeterValue object
*/
export const buildMeterValue = (
interval: number,
measurandsKey?: ConfigurationKeyType,
context?: MeterValueContext,
- debug = false
+ debug = false,
+ identity?: Pick<
+ ResolvedMeterValueIdentity,
+ | 'advanceEnergy'
+ | 'connectorId'
+ | 'deferEnergyInterval'
+ | 'energyElapsedInterval'
+ | 'energyIntervalWhOverride'
+ | 'energyNominalInterval'
+ | 'energyRegisterWhOverride'
+ | 'evseId'
+ | 'projectDcForLocation'
+ | 'snapshot'
+ | 'suppressSigning'
+ | 'timestamp'
+ >
): MeterValue => {
- if (transactionId == null) {
+ if (transactionId == null && identity?.connectorId == null) {
return buildEmptyMeterValue()
}
- const { buildVersionedSampledValue, connectorId, evseId, signingConfig, signingState } =
- createVersionedSampledValueDispatcher(chargingStation, transactionId, context)
+ return buildIdentifiedMeterValue(
+ chargingStation,
+ {
+ ...identity,
+ ...(identity?.snapshot === true && { commitState: false }),
+ ...(transactionId != null && { transactionId }),
+ },
+ interval,
+ measurandsKey,
+ context,
+ debug,
+ OCPP20ComponentName.SampledDataCtrlr
+ )
+}
+
+/**
+ * Builds a complete OCPP 2.0 MeterValue for a directly identified connector.
+ * Active connectors may provide a transaction id to preserve coherent and
+ * aligned-signing semantics; idle connectors omit it and remain governed by
+ * the aligned `SignReadings` policy.
+ * @param chargingStation - Target charging station.
+ * @param identity - Direct connector/EVSE identification.
+ * @param identity.advanceEnergy - Whether this aligned sample owns energy accumulation.
+ * @param identity.commitState - Whether the rendered snapshot commits meter state.
+ * @param identity.connectorId - Connector identifier.
+ * @param identity.deferEnergyInterval - Whether to retain interval energy for later delivery.
+ * @param identity.energyElapsedInterval - Physical energy interval for this observation.
+ * @param identity.energyIntervalWhOverride - Authoritative interval energy for this sample.
+ * @param identity.energyNominalInterval - Physical energy integration interval in milliseconds.
+ * @param identity.energyRegisterWhOverride - Optional station-level aggregate energy in Wh.
+ * @param identity.idle - Whether the aggregate meter point is idle.
+ * @param identity.sampledValueBaseline - Optional pre-aggregated physical samples.
+ * @param identity.sampledValueTemplates - Optional aggregate template union.
+ * @param identity.suppressSigning - Whether this sample is not delivered and must not consume signing state.
+ * @param identity.evseId - EVSE identifier.
+ * @param identity.timestamp - Optional UTC slot timestamp shared across the aligned sweep.
+ * @param identity.transactionId - Optional active transaction identifier.
+ * @param interval - Clock-aligned data interval in milliseconds
+ * @param measurandsKey - Configuration key for the sampled measurands list
+ * @param context - Meter value reading context (Sample.Clock for aligned data)
+ * @returns Populated OCPP 2.0 MeterValue object
+ */
+export const buildClockAlignedConnectorMeterValue = (
+ chargingStation: ChargingStation,
+ identity: {
+ advanceEnergy?: boolean
+ commitState?: boolean
+ connectorId: number
+ deferEnergyInterval?: boolean
+ energyElapsedInterval?: number
+ energyIntervalWhOverride?: number
+ energyNominalInterval?: number
+ energyRegisterWhOverride?: number
+ evseId: number
+ idle?: boolean
+ sampledValueBaseline?: OCPP20SampledValue[]
+ sampledValueTemplates?: SampledValueTemplate[]
+ suppressSigning?: boolean
+ timestamp?: Date
+ transactionId?: number | string
+ },
+ interval: number,
+ measurandsKey?: ConfigurationKeyType,
+ context?: MeterValueContext
+): OCPP20MeterValue => {
+ if (!isOCPP20x(chargingStation.stationInfo?.ocppVersion)) {
+ throw new OCPPError(
+ ErrorType.INTERNAL_ERROR,
+ `Cannot build clock-aligned MeterValue for OCPP version ${String(chargingStation.stationInfo?.ocppVersion)}`,
+ RequestCommand.METER_VALUES
+ )
+ }
+ return buildIdentifiedMeterValue(
+ chargingStation,
+ { ...identity, commitState: identity.commitState ?? true, snapshot: true },
+ interval,
+ measurandsKey,
+ context,
+ false,
+ OCPP20ComponentName.AlignedDataCtrlr
+ ) as OCPP20MeterValue
+}
+
+const buildIdentifiedMeterValue = (
+ chargingStation: ChargingStation,
+ identity: ResolvedMeterValueIdentity,
+ interval: number,
+ measurandsKey: ConfigurationKeyType | undefined,
+ context: MeterValueContext | undefined,
+ debug: boolean,
+ signReadingsComponent: OCPP20ComponentName
+): MeterValue => {
+ const {
+ buildUnsignedVersionedSampledValue,
+ buildVersionedSampledValue: buildConfiguredVersionedSampledValue,
+ connectorId,
+ evseId,
+ signingConfig,
+ signingState,
+ } = createVersionedSampledValueDispatcher(
+ chargingStation,
+ identity,
+ context,
+ signReadingsComponent
+ )
+ const snapshot = identity.snapshot === true
+ const commitState = identity.commitState !== false
+ const suppressSigning = identity.suppressSigning === true
+ const buildSignedVersionedSampledValue = suppressSigning
+ ? buildUnsignedVersionedSampledValue
+ : buildConfiguredVersionedSampledValue
// Coherent MeterValues strategy gate. Placed AFTER the versioned dispatcher
// is available (so the coherent path can emit versioned SampledValues) and
// BEFORE the random/fixed measurand generation runs. When coherent mode
// is not active for this station or no session exists for the transaction,
// this is a no-op and the random/fixed code path is unchanged.
- const coherentSession = chargingStation.getCoherentSession(transactionId)
+ const coherentSession =
+ identity.transactionId != null
+ ? chargingStation.getCoherentSession(identity.transactionId)
+ : undefined
+ const registerValuesWithoutPhases = isOCPP20FlagEnabled(
+ chargingStation,
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ )
+ if (snapshot && identity.sampledValueBaseline != null) {
+ const timestamp = identity.timestamp ?? new Date()
+ if (signingConfig != null) signingConfig.timestamp = timestamp
+ return {
+ sampledValue: expandClockAlignedSnapshotSamples(
+ chargingStation,
+ connectorId,
+ evseId,
+ identity.sampledValueBaseline,
+ buildSignedVersionedSampledValue,
+ measurandsKey,
+ context,
+ registerValuesWithoutPhases,
+ identity.idle ?? true,
+ identity.transactionId != null,
+ identity.energyRegisterWhOverride,
+ true,
+ identity.sampledValueTemplates
+ ),
+ timestamp,
+ } as OCPP20MeterValue
+ }
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId, evseId)
+ const transactionBegin = context === MeterValueContext.TRANSACTION_BEGIN
+ const intervalBaselineKey = isOCPP20x(chargingStation.stationInfo?.ocppVersion)
+ ? (measurandsKey ?? 'default')
+ : 'default'
if (isCoherentModeActive(coherentSession)) {
- // OCPP 2.0.1 SampledDataCtrlr.RegisterValuesWithoutPhases: threaded
- // through to the coherent builder so per-phase L-N
- // Energy.Active.Import.Register templates are skipped when the
- // variable resolves to true. OCPP 1.6 stations do not carry the
- // component-scoped key by default: `getConfigurationKey` returns
- // `undefined`, `convertToBoolean(undefined) === false`, so current
- // behavior is preserved.
- const registerValuesWithoutPhases = isOCPP20FlagEnabled(
- chargingStation,
- OCPP20ComponentName.SampledDataCtrlr,
- OCPP20OptionalVariableName.RegisterValuesWithoutPhases
- )
- return buildCoherentMeterValue(
- chargingStation,
- coherentSession,
- buildVersionedSampledValue,
- {
- intervalMs: interval,
- nowMs: Date.now(),
- rootSeed: resolveRootSeed(chargingStation.stationInfo),
- },
- context,
- resolveEnabledMeasurands(chargingStation, measurandsKey),
- registerValuesWithoutPhases
- )
+ const timestamp = identity.timestamp ?? new Date()
+ if (signingConfig != null) signingConfig.timestamp = timestamp
+ const enabledMeasurands = resolveEnabledMeasurands(chargingStation, measurandsKey)
+ const coherentMeterValue = commitState
+ ? buildCoherentMeterValue(
+ chargingStation,
+ coherentSession,
+ buildSignedVersionedSampledValue,
+ {
+ intervalMs: interval,
+ nowMs: identity.timestamp?.getTime() ?? Date.now(),
+ rootSeed: resolveRootSeed(chargingStation.stationInfo),
+ },
+ context,
+ enabledMeasurands,
+ registerValuesWithoutPhases,
+ timestamp,
+ connectorStatus,
+ evseId,
+ identity.transactionId != null &&
+ chargingStation.stationInfo?.meteringPerTransaction === true,
+ identity.energyRegisterWhOverride,
+ identity.snapshot === true
+ ? identity.advanceEnergy === true
+ : identity.advanceEnergy !== false,
+ identity.deferEnergyInterval === true,
+ intervalBaselineKey
+ )
+ : buildCoherentMeterValueSnapshot(
+ chargingStation,
+ coherentSession,
+ buildSignedVersionedSampledValue,
+ context,
+ enabledMeasurands,
+ registerValuesWithoutPhases,
+ timestamp,
+ connectorStatus,
+ evseId,
+ identity.energyRegisterWhOverride,
+ intervalBaselineKey
+ )
+ if (commitState && transactionBegin && connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt =
+ coherentMeterValue.timestamp
+ }
+ if (snapshot) {
+ const coherentOcpp20MeterValue = coherentMeterValue as OCPP20MeterValue
+ coherentOcpp20MeterValue.sampledValue = applyClockAlignedVoltageControls(
+ chargingStation,
+ coherentOcpp20MeterValue.sampledValue,
+ buildSignedVersionedSampledValue,
+ context
+ )
+ }
+ // Only transactional builds may flip the one-time public-key flag. A
+ // coherent session always carries a transactionId, but guard explicitly to
+ // stay symmetric with the random/fixed path below and robust to any future
+ // coherent-session model change.
+ if (
+ !snapshot &&
+ !suppressSigning &&
+ identity.transactionId != null &&
+ signingState.publicKeyIncluded &&
+ connectorStatus != null
+ ) {
+ connectorStatus.publicKeySentInTransaction = true
+ }
+ return coherentMeterValue
}
- const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const buildVersionedSampledValue = snapshot
+ ? buildUnsignedVersionedSampledValue
+ : buildSignedVersionedSampledValue
const meterValue: { sampledValue: SampledValue[]; timestamp: Date } = buildEmptyMeterValue()
+ if (identity.timestamp != null) meterValue.timestamp = identity.timestamp
if (signingConfig != null) {
signingConfig.timestamp = meterValue.timestamp
}
- // SoC measurand
- const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId, evseId, measurandsKey)
- if (socMeasurand != null) {
- const socSampledValue = buildVersionedSampledValue(
- socMeasurand.template,
- socMeasurand.value,
- context
- )
- meterValue.sampledValue.push(socSampledValue)
- validateMeasurandValue(
+ // SoC only has transaction semantics in the simulator. An idle connector
+ // has no EV state to report.
+ if (identity.transactionId != null) {
+ const socMeasurand = buildSocMeasurandValue(
chargingStation,
connectorId,
- convertToInt(socSampledValue.value),
- socMeasurand.template.minimumValue ?? 0,
- Constants.SOC_MAXIMUM_PERCENT,
- socSampledValue.measurand,
- debug
+ evseId,
+ measurandsKey,
+ snapshot
)
+ if (socMeasurand != null) {
+ const socSampledValue = buildVersionedSampledValue(
+ socMeasurand.template,
+ socMeasurand.value,
+ context
+ )
+ meterValue.sampledValue.push(socSampledValue)
+ validateMeasurandValue(
+ chargingStation,
+ connectorId,
+ convertToInt(socSampledValue.value),
+ socMeasurand.template.minimumValue ?? 0,
+ Constants.SOC_MAXIMUM_PERCENT,
+ socSampledValue.measurand,
+ debug
+ )
+ }
}
// Voltage measurand
const voltageMeasurand = buildVoltageMeasurandValue(
chargingStation,
connectorId,
evseId,
- measurandsKey
+ measurandsKey,
+ snapshot
)
if (voltageMeasurand != null) {
addMainVoltageToMeterValue(
buildVersionedSampledValue,
measurandsKey,
context,
- voltageMeasurand.value
+ voltageMeasurand.value,
+ evseId,
+ snapshot
)
if (chargingStation.stationInfo?.phaseLineToLineVoltageMeterValues === true) {
const nextPhase =
lineToLineNominalVoltage,
buildVersionedSampledValue,
measurandsKey,
- context
+ context,
+ undefined,
+ evseId,
+ snapshot
)
}
}
}
// Power.Active.Import measurand
+ const idle = identity.transactionId == null
const powerMeasurand = buildPowerMeasurandValue(
chargingStation,
connectorId,
evseId,
- measurandsKey
+ measurandsKey,
+ idle,
+ snapshot
)
if (powerMeasurand?.values.allPhases != null) {
const unitDivider =
powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? Constants.UNIT_DIVIDER_KILO : 1
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
+ )
const connectorMaximumPower = Math.round(connectorMaximumAvailablePower)
- const connectorMinimumPower = Math.round(powerMeasurand.template.minimumValue ?? 0)
+ const connectorMinimumPower = idle ? 0 : Math.round(powerMeasurand.template.minimumValue ?? 0)
meterValue.sampledValue.push(
buildVersionedSampledValue(powerMeasurand.template, powerMeasurand.values.allPhases, context)
chargingStation,
connectorId,
evseId,
- measurandsKey
+ measurandsKey,
+ idle,
+ snapshot
)
if (currentMeasurand?.values.allPhases != null) {
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
+ )
const connectorMaximumAmperage =
chargingStation.stationInfo?.currentOutType === CurrentType.AC
? ACElectricUtils.amperagePerPhaseFromPower(
chargingStation.getVoltageOut()
)
: DCElectricUtils.amperage(connectorMaximumAvailablePower, chargingStation.getVoltageOut())
- const connectorMinimumAmperage = currentMeasurand.template.minimumValue ?? 0
+ const connectorMinimumAmperage = idle ? 0 : (currentMeasurand.template.minimumValue ?? 0)
meterValue.sampledValue.push(
buildVersionedSampledValue(
}
}
// Energy.Active.Import.Register measurand (default)
+ const advanceEnergy = identity.advanceEnergy === true
+ const ownsEnergy =
+ commitState &&
+ !transactionBegin &&
+ identity.transactionId != null &&
+ identity.advanceEnergy !== false &&
+ (!snapshot || advanceEnergy)
+ let snapshotEnergyRegisterWhOverride = identity.energyRegisterWhOverride
+ const previousEnergyUpdate =
+ connectorStatus?.transactionEnergyActiveImportRegisterLastUpdatedAt ??
+ connectorStatus?.transactionStart
+ const transactionEnergyInterval = transactionBegin
+ ? 0
+ : identity.transactionId != null && previousEnergyUpdate != null
+ ? Math.max(0, meterValue.timestamp.getTime() - previousEnergyUpdate.getTime())
+ : interval
+ const energyInterval =
+ !transactionBegin && identity.energyElapsedInterval != null
+ ? Math.max(0, identity.energyElapsedInterval)
+ : transactionEnergyInterval
+ const energyNominalInterval =
+ identity.energyNominalInterval ??
+ (isOCPP20x(chargingStation.stationInfo?.ocppVersion) && interval > 0 ? interval : undefined)
+ const energyValueTimeScale =
+ energyNominalInterval != null && energyNominalInterval > 0
+ ? energyInterval / energyNominalInterval
+ : 1
const energyMeasurand = buildEnergyMeasurandValue(
chargingStation,
connectorId,
- interval,
+ energyInterval,
evseId,
- measurandsKey
+ measurandsKey,
+ transactionBegin || (snapshot && identity.advanceEnergy == null),
+ snapshot,
+ energyValueTimeScale,
+ true
)
if (energyMeasurand != null) {
- updateConnectorEnergyValues(connectorStatus, energyMeasurand.value)
+ const transactionEnergyBeforeBuild =
+ connectorStatus?.transactionEnergyActiveImportRegisterValue ?? 0
+ const intervalBaseline =
+ connectorStatus?.transactionEnergyActiveImportIntervalBaselines?.[intervalBaselineKey] ?? 0
+ const carriedIntervalEnergy =
+ connectorStatus?.transactionEnergyActiveImportIntervalCarry?.[intervalBaselineKey] ?? 0
+ const pendingIntervalEnergy =
+ Math.max(0, transactionEnergyBeforeBuild - intervalBaseline) + carriedIntervalEnergy
+ const deferEnergyInterval = identity.deferEnergyInterval === true
+ const transactionEnergyValue =
+ identity.energyElapsedInterval != null && energyInterval > 0
+ ? roundTo(
+ energyMeasurand.value * Math.min(1, transactionEnergyInterval / energyInterval),
+ 2
+ )
+ : energyMeasurand.value
+ // Aligned snapshots may own accumulation when periodic TxUpdated samples
+ // do not include the cumulative energy register.
+ if (ownsEnergy) {
+ updateConnectorEnergyValues(
+ chargingStation,
+ connectorStatus,
+ energyMeasurand.value,
+ evseId,
+ transactionEnergyValue,
+ meterValue.timestamp
+ )
+ if (
+ connectorStatus != null &&
+ (previousEnergyUpdate == null || meterValue.timestamp > previousEnergyUpdate)
+ ) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = meterValue.timestamp
+ }
+ } else if (
+ commitState &&
+ identity.advanceEnergy === false &&
+ connectorStatus?.transactionEnergyActiveImportRegisterLastUpdatedAt?.getTime() !==
+ meterValue.timestamp.getTime() &&
+ !transactionBegin &&
+ identity.transactionId != null &&
+ connectorStatus != null
+ ) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue =
+ Math.max(0, connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) +
+ energyMeasurand.value
+ if (previousEnergyUpdate == null || meterValue.timestamp > previousEnergyUpdate) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = meterValue.timestamp
+ }
+ }
+ if (ownsEnergy && snapshotEnergyRegisterWhOverride != null) {
+ snapshotEnergyRegisterWhOverride += energyMeasurand.value
+ }
+ const intervalEnergyValue =
+ identity.energyIntervalWhOverride ??
+ (connectorStatus != null && identity.transactionId != null
+ ? Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) - intervalBaseline
+ ) + carriedIntervalEnergy
+ : transactionEnergyValue)
const unitDivider =
energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR
? Constants.UNIT_DIVIDER_KILO
: 1
- const energySampledValue = buildVersionedSampledValue(
- energyMeasurand.template,
- roundTo(
- chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) / unitDivider,
- 2
- ),
- context
+ if (energyMeasurand.template.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL) {
+ const intervalTemplates = resolveEnergyIntervalTemplates(
+ chargingStation,
+ connectorId,
+ evseId,
+ context
+ )
+ const intervalSampledValues = buildEnergyIntervalSampledValues(
+ chargingStation,
+ connectorId,
+ evseId,
+ isNotEmptyArray(intervalTemplates) ? intervalTemplates : [energyMeasurand.template],
+ intervalEnergyValue,
+ buildVersionedSampledValue,
+ context,
+ !snapshot
+ )
+ meterValue.sampledValue.push(...intervalSampledValues)
+ } else {
+ const registerEnergyWh = Math.max(
+ 0,
+ snapshotEnergyRegisterWhOverride ??
+ (snapshot
+ ? (connectorStatus?.energyActiveImportRegisterValue ?? 0)
+ : chargingStation.getEnergyActiveImportRegisterByConnectorId(
+ connectorId,
+ false,
+ evseId
+ ))
+ )
+ const energySampledValue = buildVersionedSampledValue(
+ energyMeasurand.template,
+ roundTo(
+ projectSnapshotDcOutputValue(
+ chargingStation,
+ connectorId,
+ evseId,
+ energyMeasurand.template.location,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ registerEnergyWh,
+ false
+ ) / unitDivider,
+ 2
+ ),
+ context
+ )
+ meterValue.sampledValue.push(energySampledValue)
+ if (
+ resolveEnabledMeasurands(chargingStation, measurandsKey)?.has(
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ) === true
+ ) {
+ const intervalEnergyTemplates = resolveEnergyIntervalTemplates(
+ chargingStation,
+ connectorId,
+ evseId,
+ context
+ )
+ meterValue.sampledValue.push(
+ ...buildEnergyIntervalSampledValues(
+ chargingStation,
+ connectorId,
+ evseId,
+ intervalEnergyTemplates,
+ intervalEnergyValue,
+ buildVersionedSampledValue,
+ context,
+ !snapshot
+ )
+ )
+ }
+ }
+ if (connectorStatus != null && identity.transactionId != null) {
+ const emitsIntervalEnergy = meterValue.sampledValue.some(
+ sampledValue => sampledValue.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ if (commitState && emitsIntervalEnergy && !deferEnergyInterval) {
+ recordTransactionIntervalEmission(
+ connectorStatus,
+ meterValue,
+ intervalBaselineKey,
+ intervalEnergyValue,
+ chargingStation.getNumberOfPhases(),
+ resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency,
+ evseId
+ )
+ )
+ }
+ }
+ const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
+ connectorId,
+ evseId
)
- meterValue.sampledValue.push(energySampledValue)
- const connectorMaximumAvailablePower =
- chargingStation.getConnectorMaximumAvailablePower(connectorId)
+ const validationInterval =
+ pendingIntervalEnergy > 0 ? (energyNominalInterval ?? interval) : energyInterval
+ const validationTimeScale =
+ energyNominalInterval != null && energyNominalInterval > 0
+ ? validationInterval / energyNominalInterval
+ : 1
const connectorMaximumEnergyRounded = roundTo(
- (connectorMaximumAvailablePower * interval) / Constants.MS_PER_HOUR,
+ (connectorMaximumAvailablePower * validationInterval) / Constants.MS_PER_HOUR,
+ 2
+ )
+ const connectorMinimumEnergyRounded = roundTo(
+ (energyMeasurand.template.minimumValue ?? 0) * unitDivider * validationTimeScale,
2
)
- const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2)
validateMeasurandValue(
chargingStation,
connectorId,
- energyMeasurand.value,
+ intervalEnergyValue,
connectorMinimumEnergyRounded,
connectorMaximumEnergyRounded,
- energySampledValue.measurand,
+ energyMeasurand.template.measurand ?? MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
debug,
- { interval }
+ { interval: energyInterval }
+ )
+ }
+ // Snapshot builds re-project the full configured template identity set
+ // (location/unit/context/format families, fixed-value measurands,
+ // RegisterValuesWithoutPhases) from the base samples above, which only serve
+ // as the value source. The second pass is intentional (per-identity/phase
+ // coherence), not redundant computation.
+ if (snapshot) {
+ meterValue.sampledValue = expandClockAlignedSnapshotSamples(
+ chargingStation,
+ connectorId,
+ evseId,
+ meterValue.sampledValue as OCPP20SampledValue[],
+ buildSignedVersionedSampledValue,
+ measurandsKey,
+ context,
+ registerValuesWithoutPhases,
+ idle,
+ identity.transactionId != null,
+ snapshotEnergyRegisterWhOverride
)
}
- if (signingState.publicKeyIncluded && connectorStatus != null) {
+ // Transactional snapshots defer this flag until their request is delivered.
+ // Other transactional builds preserve the existing eager-commit behavior.
+ if (
+ !snapshot &&
+ !suppressSigning &&
+ identity.transactionId != null &&
+ signingState.publicKeyIncluded &&
+ connectorStatus != null
+ ) {
connectorStatus.publicKeySentInTransaction = true
}
+ if (commitState && transactionBegin && connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = meterValue.timestamp
+ }
return meterValue as MeterValue
}
* @param measurand - Meter value measurand to match
* @param evseId - Optional EVSE ID for OCPP 2.0.1 template lookup
* @param phase - Optional phase to match in the template
+ * @param connectorLocalFallback - Use only the identified connector when EVSE templates are empty.
* @returns Matching sampled value template, or undefined if not found
*/
export const getSampledValueTemplate = (
measurandsKey: ConfigurationKeyType = StandardParametersKey.MeterValuesSampledData,
measurand: MeterValueMeasurand = MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
evseId?: number,
- phase?: MeterValuePhase
+ phase?: MeterValuePhase,
+ connectorLocalFallback = false
): SampledValueTemplate | undefined => {
const onPhaseStr = phase != null ? `on phase ${phase} ` : ''
if (!isMeasurandSupported(measurand)) {
if (evseStatus != null) {
if (isNotEmptyArray(evseStatus.MeterValues)) {
sampledValueTemplates = evseStatus.MeterValues
+ } else if (connectorLocalFallback) {
+ sampledValueTemplates = chargingStation.getConnectorStatus(connectorId, evseId)?.MeterValues
} else {
const connectorTemplates: SampledValueTemplate[] = []
for (const connectorStatus of evseStatus.connectors.values()) {
}
}
+const isElectricalMeasurand = (measurand: MeterValueMeasurand): boolean =>
+ measurand.startsWith('Current.') ||
+ measurand.startsWith('Energy.') ||
+ measurand.startsWith('Power.') ||
+ measurand === MeterValueMeasurand.FREQUENCY ||
+ measurand === MeterValueMeasurand.VOLTAGE
+
const getMeasurandDefaultLocation = (
measurandType: MeterValueMeasurand
): MeterValueLocation | undefined => {
import { BaseError } from '../../exception/index.js'
import {
+ type ConnectorStatus,
+ OCPP16MeterValueFormat,
+ type OCPP16SampledValue,
PublicKeyWithSignedMeterValueEnumType,
type SampledValue,
SigningMethodEnumType,
} from '../../types/index.js'
-import { getErrorMessage, isNotEmptyString, logger } from '../../utils/index.js'
+import { getErrorMessage, isJsonObject, isNotEmptyString, logger } from '../../utils/index.js'
+
+export const getOCPP16SignedMeterValuePublicKey = (
+ sampledValue: OCPP16SampledValue
+): string | undefined => {
+ if (sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA) return undefined
+ try {
+ const signedMeterValue: unknown = JSON.parse(sampledValue.value)
+ return isJsonObject(signedMeterValue) && typeof signedMeterValue.publicKey === 'string'
+ ? signedMeterValue.publicKey
+ : undefined
+ } catch {
+ return undefined
+ }
+}
+
+export interface PublicKeyDeliveryToken {
+ readonly carrier: object
+ readonly connectorStatus: ConnectorStatus
+ readonly transactionId: string
+}
+
+const publicKeyDeliveryOwners = new WeakMap<ConnectorStatus, PublicKeyDeliveryToken>()
+
+export const claimPublicKeyDelivery = (
+ connectorStatus: ConnectorStatus,
+ transactionId: number | string,
+ carrier: object,
+ carriesPublicKey: boolean
+): PublicKeyDeliveryToken | undefined => {
+ const normalizedTransactionId = transactionId.toString()
+ if (!carriesPublicKey || connectorStatus.transactionId?.toString() !== normalizedTransactionId) {
+ return undefined
+ }
+ const token = {
+ carrier,
+ connectorStatus,
+ transactionId: normalizedTransactionId,
+ }
+ publicKeyDeliveryOwners.set(connectorStatus, token)
+ connectorStatus.publicKeySentInTransaction = true
+ return token
+}
+
+export const retainPublicKeyDelivery = (token: PublicKeyDeliveryToken | undefined): void => {
+ if (token == null || publicKeyDeliveryOwners.get(token.connectorStatus) !== token) return
+ publicKeyDeliveryOwners.delete(token.connectorStatus)
+}
+
+export const releasePublicKeyDelivery = (token: PublicKeyDeliveryToken | undefined): boolean => {
+ if (token == null || publicKeyDeliveryOwners.get(token.connectorStatus) !== token) return false
+ publicKeyDeliveryOwners.delete(token.connectorStatus)
+ if (token.connectorStatus.transactionId?.toString() !== token.transactionId) return false
+ token.connectorStatus.publicKeySentInTransaction = false
+ return true
+}
+
+export const transferPublicKeyDelivery = (
+ token: PublicKeyDeliveryToken | undefined,
+ carrier: object
+): PublicKeyDeliveryToken | undefined => {
+ if (
+ token == null ||
+ publicKeyDeliveryOwners.get(token.connectorStatus) !== token ||
+ token.connectorStatus.transactionId?.toString() !== token.transactionId
+ ) {
+ return undefined
+ }
+ const replacement = { ...token, carrier }
+ publicKeyDeliveryOwners.set(token.connectorStatus, replacement)
+ token.connectorStatus.publicKeySentInTransaction = true
+ return replacement
+}
export interface SampledValueSigningConfig extends SigningConfig {
enabled: boolean
type SignedMeterDataParams,
} from './OCPPSignedMeterDataGenerator.js'
export {
+ claimPublicKeyDelivery,
+ getOCPP16SignedMeterValuePublicKey,
parsePublicKeyWithSignedMeterValue,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
type SampledValueSigningConfig,
shouldIncludePublicKey,
type SignedSampledValueResult,
availability: AvailabilityType
bootStatus?: ConnectorStatusEnum
chargingProfiles?: ChargingProfile[]
+ energyActiveImportIntervalBaselines?: Record<string, number> // In Wh
energyActiveImportRegisterValue?: number // In Wh
errorCode?: OCPP16ChargePointErrorCode
idTagAuthorized?: boolean
locked?: boolean
maximumPower?: number // In W
MeterValues: SampledValueTemplate[]
+ postTransactionDelayTransactionId?: number | string
publicKeySentInTransaction?: boolean
remoteStartId?: number
reservation?: Reservation
transactionDeauthorizedEnergyWh?: number
transactionEndedMeterValues?: MeterValue[]
transactionEndedMeterValuesSetInterval?: NodeJS.Timeout
+ transactionEnding?: boolean
+ transactionEnergyActiveImportIntervalBaselines?: Record<string, number> // In Wh
+ transactionEnergyActiveImportIntervalCarry?: Record<string, number> // In Wh
+ transactionEnergyActiveImportRegisterLastUpdatedAt?: Date
transactionEnergyActiveImportRegisterValue?: number // In Wh
transactionEventQueue?: QueuedTransactionEvent[]
transactionEvseSent?: boolean
transactionIdTokenSent?: boolean
transactionPending?: boolean
transactionRemoteStarted?: boolean
+ transactionRestored?: boolean
transactionSeqNo?: number
transactionStart?: Date
transactionStarted?: boolean
+ transactionStartedExhaustedTransactionId?: string
+ transactionStarting?: boolean
transactionUpdatedMeterValuesSetInterval?: NodeJS.Timeout
type?: ConnectorEnumType
}
export interface QueuedTransactionEvent {
+ /** Durable write-ahead marker: the exact request bytes may have reached the transport. */
+ deliveryAttempted?: boolean
+ /** The terminal payload must be rebuilt after earlier MeterValues outcomes settle. */
+ meterValuePredecessorsPending?: boolean
+ /** Canonical connector that owns this durable queue entry. */
+ ownerConnectorId?: number
+ /** Canonical EVSE that owns this durable queue entry. */
+ ownerEvseId?: number
request: OCPP20TransactionEventRequest
seqNo: number
timestamp: Date
+ transactionEnergyActiveImportIntervalBaselines?: Record<string, number>
+ transactionEnergyActiveImportIntervalConsumption?: Record<string, number>
+ transactionEnergyActiveImportRegisterValue?: number
}
export interface EvseStatus {
availability: AvailabilityType
connectors: Map<number, ConnectorStatus>
+ energyActiveImportIntervalBaseline?: number // In Wh
+ energyActiveImportRegisterLastUpdatedAt?: Date
+ energyActiveImportRegisterValue?: number // In Wh
MeterValues?: SampledValueTemplate[]
}
type UIServerConfiguration,
type WorkerConfiguration,
} from './ConfigurationData.js'
-export type { ConnectorEntry, ConnectorStatus } from './ConnectorStatus.js'
+export type { ConnectorEntry, ConnectorStatus, QueuedTransactionEvent } from './ConnectorStatus.js'
export type { EmptyObject } from './EmptyObject.js'
export type { HandleErrorParams } from './Error.js'
export type { EvseEntry, EvseEntryData, EvseStatus, EvseStatusData, EvseTemplate } from './Evse.js'
MessageTrigger,
type MeterValuesRequest,
type OutgoingRequest,
+ type PendingRequestCancellationCallback,
+ type PendingRequestCancellationOptions,
RequestCommand,
type RequestParams,
type ResponseCallback,
remoteStartId?: number
reservationId?: number
stoppedReason?: OCPP20ReasonEnumType
+ timestamp?: Date
transactionId?: string
triggerReason?: OCPP20TriggerReasonEnumType
}
RetryBackOffRandomRange = 'RetryBackOffRandomRange',
RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes',
RetryBackOffWaitMinimum = 'RetryBackOffWaitMinimum',
+ SendDuringIdle = 'SendDuringIdle',
SignReadings = 'SignReadings',
ValueSize = 'ValueSize',
WebSocketPingInterval = 'WebSocketPingInterval',
ResponseCallback,
ErrorCallback,
IncomingRequestCommand | RequestCommand,
- JsonType
+ JsonType,
+ PendingRequestCancellationCallback?,
+ (() => void)?,
+ (() => void)?,
+ number?
]
export type DataTransferRequest = OCPP16DataTransferRequest | OCPP20DataTransferRequest
export type OutgoingRequest = [MessageType.CALL_MESSAGE, string, RequestCommand, JsonType]
+/**
+ * Cancels a pending request while retaining its serialized CALL for replay when requested.
+ * @param ocppError - Cancellation reported to the pending caller
+ * @param options - Transport-aware cancellation policy
+ * @returns Whether cancellation was handled without invoking the cached error callback
+ */
+export type PendingRequestCancellationCallback = (
+ ocppError: OCPPError,
+ options?: PendingRequestCancellationOptions
+) => boolean
+
+export interface PendingRequestCancellationOptions {
+ bufferInFlightSend?: boolean
+ handleTransportStartedSend?: boolean
+ preserveRetainableWaiter?: boolean
+}
+
export const IncomingRequestCommand = {
...OCPP16IncomingRequestCommand,
...OCPP20IncomingRequestCommand,
export type RequestCommand = OCPP16RequestCommand | OCPP20RequestCommand
export interface RequestParams {
+ /** Bytes retained exclusively for a buffered response's delivery callbacks. */
+ bufferedResponseRetainedBytes?: number
+ /** Preserve a pending CALL for reconnect replay when station shutdown is in progress. */
+ bufferOnErrorDuringStationStop?: boolean
+ /** Internal CALL-only path: cache the built frame without attempting a transport send. */
+ bufferWithoutSending?: boolean
+ /** Materialize this shutdown-critical CALL if cancellation occurs before transport setup. */
+ materializeOnCancellationBeforeSend?: boolean
+ /** Called when a CALL ends with CALLERROR or a local cancellation/timeout. */
+ onError?: (error: OCPPError, isCallError: boolean) => void
+ onMessageSent?: () => void
+ /** Called when the serialized CALL is retained for reconnect replay. */
+ onRequestBuffered?: () => void
+ onResponseReceived?: () => void
+ /**
+ * Called when the transport reports a CALL send failure or timeout.
+ * `deliveryAmbiguous` is true when the transport cannot prove that no bytes were sent.
+ */
+ onTransportError?: (error: OCPPError, deliveryAmbiguous: boolean) => void
rawPayload?: boolean
+ responseTimeoutMs?: number
skipBufferingOnError?: boolean
throwError?: boolean
triggerMessage?: boolean
+ /** Keep a pending CALL alive during graceful stop until its response or timeout settles. */
+ waitForResponseOnStationStop?: boolean
}
export const MessageTrigger = {
import type { ChargingStation } from '../charging-station/index.js'
-import type {
- ATGEntry,
- ChargingStationAutomaticTransactionGeneratorConfiguration,
- ConnectorEntry,
- ConnectorStatus,
- EvseEntryData,
- EvseStatusConfiguration,
+
+import {
+ type ATGEntry,
+ AvailabilityType,
+ type ChargingStationAutomaticTransactionGeneratorConfiguration,
+ type ConnectorEntry,
+ type ConnectorStatus,
+ type EvseEntryData,
+ type EvseStatusConfiguration,
+ OCPP20ComponentName,
+ OCPP20ConnectorStatusEnumType,
+ OCPP20RequiredVariableName,
} from '../types/index.js'
+import { isEmpty } from './Utils.js'
+
+const TRANSIENT_TX_ENDED_INTERVAL_BASELINE_KEY = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+
+export const buildPersistentTransactionEnergyIntervalState = (
+ baselines: ConnectorStatus['transactionEnergyActiveImportIntervalBaselines']
+): Pick<ConnectorStatus, 'transactionEnergyActiveImportIntervalBaselines'> => {
+ if (baselines == null) return {}
+ const persistentBaselines = Object.fromEntries(
+ Object.entries(baselines).filter(([key]) => key !== TRANSIENT_TX_ENDED_INTERVAL_BASELINE_KEY)
+ )
+ return !isEmpty(persistentBaselines)
+ ? { transactionEnergyActiveImportIntervalBaselines: persistentBaselines }
+ : {}
+}
+
+const buildPersistentTransactionEnergyIntervalCarry = (
+ carry: ConnectorStatus['transactionEnergyActiveImportIntervalCarry']
+): Pick<ConnectorStatus, 'transactionEnergyActiveImportIntervalCarry'> => {
+ if (carry == null) return {}
+ const persistentCarry = Object.fromEntries(
+ Object.entries(carry).filter(([key]) => key !== TRANSIENT_TX_ENDED_INTERVAL_BASELINE_KEY)
+ )
+ return !isEmpty(persistentCarry)
+ ? { transactionEnergyActiveImportIntervalCarry: persistentCarry }
+ : {}
+}
+
+const hasOnlyTransientPostTransactionDelay = (
+ connectorStatus: ConnectorStatus,
+ postTransactionDelayTransactionId: number | string | undefined,
+ transactionEnding: boolean | undefined,
+ transactionStarting: boolean | undefined
+): boolean =>
+ postTransactionDelayTransactionId != null &&
+ connectorStatus.transactionId == null &&
+ connectorStatus.transactionPending !== true &&
+ connectorStatus.transactionStarted !== true &&
+ transactionEnding !== true &&
+ transactionStarting !== true
export const buildATGEntries = (chargingStation: ChargingStation): ATGEntry[] => {
if (chargingStation.automaticTransactionGenerator?.connectorsStatus == null) {
({
connectorId,
connectorStatus: {
+ postTransactionDelayTransactionId,
transactionEndedMeterValues,
transactionEndedMeterValuesSetInterval,
+ transactionEnding,
transactionEventQueue,
transactionUpdatedMeterValuesSetInterval,
...connectorStatus
({
connectorId,
connectorStatus: {
+ locked,
+ postTransactionDelayTransactionId,
transactionEndedMeterValues,
transactionEndedMeterValuesSetInterval,
+ transactionEnding,
+ transactionEnergyActiveImportIntervalBaselines,
+ transactionEnergyActiveImportIntervalCarry,
transactionEventQueue,
+ transactionRestored,
+ transactionStarting,
transactionUpdatedMeterValuesSetInterval,
...connectorStatus
},
- }) => [connectorId, connectorStatus] as [number, ConnectorStatus]
+ }) =>
+ [
+ connectorId,
+ {
+ ...connectorStatus,
+ ...buildPersistentTransactionEnergyIntervalState(
+ transactionEnergyActiveImportIntervalBaselines
+ ),
+ ...buildPersistentTransactionEnergyIntervalCarry(
+ transactionEnergyActiveImportIntervalCarry
+ ),
+ ...(Array.isArray(transactionEventQueue) &&
+ !isEmpty(transactionEventQueue) && { transactionEventQueue }),
+ locked: hasOnlyTransientPostTransactionDelay(
+ connectorStatus,
+ postTransactionDelayTransactionId,
+ transactionEnding,
+ transactionStarting
+ )
+ ? false
+ : locked,
+ status: hasOnlyTransientPostTransactionDelay(
+ connectorStatus,
+ postTransactionDelayTransactionId,
+ transactionEnding,
+ transactionStarting
+ )
+ ? chargingStation.isChargingStationAvailable() &&
+ connectorStatus.availability === AvailabilityType.Operative
+ ? OCPP20ConnectorStatusEnumType.Available
+ : OCPP20ConnectorStatusEnumType.Unavailable
+ : connectorStatus.status,
+ },
+ ] as [number, ConnectorStatus]
)
.toArray()
}
([
connectorId,
{
+ postTransactionDelayTransactionId,
transactionEndedMeterValues,
transactionEndedMeterValuesSetInterval,
+ transactionEnding,
+ transactionEnergyActiveImportIntervalBaselines,
+ transactionEnergyActiveImportIntervalCarry,
transactionEventQueue,
+ transactionRestored,
+ transactionStarting,
transactionUpdatedMeterValuesSetInterval,
...connectorStatus
},
- ]) => ({ connectorId, connectorStatus, evseId })
+ ]) => ({
+ connectorId,
+ connectorStatus: {
+ ...connectorStatus,
+ ...buildPersistentTransactionEnergyIntervalState(
+ transactionEnergyActiveImportIntervalBaselines
+ ),
+ ...buildPersistentTransactionEnergyIntervalCarry(
+ transactionEnergyActiveImportIntervalCarry
+ ),
+ },
+ evseId,
+ })
),
},
}))
([
connectorId,
{
+ locked,
+ postTransactionDelayTransactionId,
transactionEndedMeterValues,
transactionEndedMeterValuesSetInterval,
- transactionEventQueue,
+ transactionEnding,
+ transactionEnergyActiveImportIntervalBaselines,
+ transactionEnergyActiveImportIntervalCarry,
+ transactionRestored,
+ transactionStarting,
transactionUpdatedMeterValuesSetInterval,
...connector
},
- ]) => [connectorId, connector]
+ ]) => [
+ connectorId,
+ {
+ ...connector,
+ ...buildPersistentTransactionEnergyIntervalState(
+ transactionEnergyActiveImportIntervalBaselines
+ ),
+ ...buildPersistentTransactionEnergyIntervalCarry(
+ transactionEnergyActiveImportIntervalCarry
+ ),
+ locked: hasOnlyTransientPostTransactionDelay(
+ connector,
+ postTransactionDelayTransactionId,
+ transactionEnding,
+ transactionStarting
+ )
+ ? false
+ : locked,
+ status: hasOnlyTransientPostTransactionDelay(
+ connector,
+ postTransactionDelayTransactionId,
+ transactionEnding,
+ transactionStarting
+ )
+ ? chargingStation.isChargingStationAvailable() &&
+ connector.availability === AvailabilityType.Operative
+ ? OCPP20ConnectorStatusEnumType.Available
+ : OCPP20ConnectorStatusEnumType.Unavailable
+ : connector.status,
+ },
+ ]
)
- const { connectors: _, ...evseStatusRest } = evseStatus
+ const {
+ connectors: _,
+ energyActiveImportRegisterLastUpdatedAt: _energyActiveImportRegisterLastUpdatedAt,
+ ...evseStatusRest
+ } = evseStatus
return [
evseId,
{
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class Constants {
+ static readonly DEFAULT_ALIGNED_DATA_INTERVAL_SECONDS = 900
+
static readonly DEFAULT_ATG_CONFIGURATION: Readonly<AutomaticTransactionGeneratorConfiguration> =
Object.freeze({
enable: false,
static readonly ENV_SIMULATOR_COLD_START = 'SIMULATOR_COLD_START'
+ /** Maximum station-local outgoing OCPP CALLs retained behind the active CALL. */
+ static readonly MAX_OUTGOING_CALL_WAITERS = 1024
+
static readonly MAX_RANDOM_INTEGER = 281_474_976_710_655 // 2^48 - 1 (randomInt() limit)
// Node.js setInterval/setTimeout maximum safe delay value (2^31-1 ms ≈ 24.8 days)
// Values exceeding this limit cause Node.js to reset the delay to 1ms
static readonly MAX_SETINTERVAL_DELAY_MS = 2_147_483_647
+
+ /** Serialized-size compaction target for durable OCPP 2.0.1 TransactionEvent backlogs. */
+ static readonly MAX_TRANSACTION_EVENT_QUEUE_BYTES = 1024 * 1024
+ /** Queue-length compaction target for durable OCPP 2.0.1 TransactionEvent backlogs. */
+ static readonly MAX_TRANSACTION_EVENT_QUEUE_LENGTH = 10_000
/** Milliseconds per day; equal to `24 * MS_PER_HOUR`. */
static readonly MS_PER_DAY = DAY_IN_MS
buildConnectorsStatus,
buildEvseEntries,
buildEvsesStatus,
+ buildPersistentTransactionEnergyIntervalState,
} from './ChargingStationConfigurationUtils.js'
export { Configuration } from './Configuration.js'
export {
* voltageOut` on DC) are left unchanged and are not reduced by the factor.
*/
import assert from 'node:assert/strict'
+import { readFileSync, writeFileSync } from 'node:fs'
import { afterEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { OCPPVersion } from '../../src/types/index.js'
+import { flushMicrotasks, standardCleanup } from '../helpers/TestLifecycleHelpers.js'
import {
cleanupStationTemplates,
createStationFromTemplate,
+ resolvePersistedConfigurationFile,
writeStationTemplate,
} from './helpers/StationHelpers.realStation.js'
connectorMaximumPower?: number
conversionEfficiency?: number
currentOutType?: string
+ numberOfPhases?: number
+ ocppVersion?: OCPPVersion
}
// Fresh DC template with powerSharedByConnectors:false (deterministic
const buildTemplate = (overrides: TemplateOverrides = {}): Record<string, unknown> => {
const connectorMaximumPower =
overrides.connectorMaximumPower != null ? { maximumPower: overrides.connectorMaximumPower } : {}
+ const connectorTopology =
+ overrides.ocppVersion === OCPPVersion.VERSION_201
+ ? {
+ Evses: {
+ 0: { Connectors: { 0: {} } },
+ 1: {
+ Connectors: {
+ 1: { bootStatus: 'Available', ...connectorMaximumPower },
+ },
+ },
+ },
+ }
+ : {
+ Connectors: {
+ 0: {},
+ 1: { bootStatus: 'Available', ...connectorMaximumPower },
+ 2: { bootStatus: 'Available', ...connectorMaximumPower },
+ },
+ }
return {
$schemaVersion: 1,
baseName: 'TEST-CONVERSION-EFFICIENCY',
chargePointModel: 'Simulator simple',
chargePointVendor: 'Simulator',
- Connectors: {
- 0: {},
- 1: { bootStatus: 'Available', ...connectorMaximumPower },
- 2: { bootStatus: 'Available', ...connectorMaximumPower },
- },
+ ...connectorTopology,
currentOutType: overrides.currentOutType ?? 'DC',
- numberOfConnectors: 2,
+ numberOfConnectors: overrides.ocppVersion === OCPPVersion.VERSION_201 ? 1 : 2,
+ ...(overrides.numberOfPhases != null ? { numberOfPhases: overrides.numberOfPhases } : {}),
+ ...(overrides.ocppVersion != null ? { ocppVersion: overrides.ocppVersion } : {}),
power: POWER_W,
powerSharedByConnectors: false,
powerUnit: 'W',
assert.strictEqual(acWithEfficiency, acBaseline)
})
+ for (const rehydrationCase of [
+ {
+ conversionEfficiency: 1,
+ currentOutType: 'AC',
+ expectedCurrentOutType: 'AC',
+ expectedEnergyWh: 2000,
+ numberOfPhases: 1,
+ persistentConfiguration: true,
+ },
+ {
+ conversionEfficiency: 0.8,
+ currentOutType: 'DC',
+ expectedCurrentOutType: 'DC',
+ expectedEnergyWh: 1600,
+ numberOfPhases: 0,
+ persistentConfiguration: true,
+ },
+ {
+ conversionEfficiency: 0.8,
+ currentOutType: 'DC',
+ expectedCurrentOutType: 'AC',
+ expectedEnergyWh: 6000,
+ numberOfPhases: 0,
+ persistentConfiguration: false,
+ },
+ ]) {
+ await it(`rehydrates legacy interval energy with ${rehydrationCase.persistentConfiguration ? 'persisted' : 'template'} electrical properties`, async () => {
+ const templateFile = writeStationTemplate(
+ buildTemplate({
+ currentOutType: 'AC',
+ numberOfPhases: 3,
+ ocppVersion: OCPPVersion.VERSION_201,
+ })
+ )
+ createStationFromTemplate(templateFile, {
+ baseName: 'TEST-CONVERSION-EFFICIENCY',
+ fixedName: true,
+ persistentConfiguration: true,
+ })
+ await flushMicrotasks()
+ const configurationFile = resolvePersistedConfigurationFile(templateFile)
+ const configuration = JSON.parse(readFileSync(configurationFile, 'utf8')) as {
+ evsesStatus: [number, { connectorsStatus: [number, Record<string, unknown>][] }][]
+ stationInfo: Record<string, unknown>
+ }
+ configuration.stationInfo.currentOutType = rehydrationCase.currentOutType
+ configuration.stationInfo.numberOfPhases = rehydrationCase.numberOfPhases
+ configuration.stationInfo.conversionEfficiency = rehydrationCase.conversionEfficiency
+ const connectorStatus = configuration.evsesStatus
+ .find(([evseId]) => evseId === 1)?.[1]
+ .connectorsStatus.find(([connectorId]) => connectorId === 1)?.[1]
+ assert.ok(connectorStatus != null)
+ const transactionId = '00000000-0000-4000-8000-000000000030'
+ const timestamp = '2026-09-01T12:00:00.000Z'
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEventQueue = [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: {
+ eventType: 'Updated',
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: 'Sample.Clock',
+ location: 'Inlet',
+ measurand: 'Energy.Active.Import.Interval',
+ phase: 'L1-N',
+ unitOfMeasure: { unit: 'kWh' },
+ value: 2,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ seqNo: 1,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: 'MeterValueClock',
+ },
+ seqNo: 1,
+ timestamp,
+ },
+ ]
+ writeFileSync(configurationFile, JSON.stringify(configuration), 'utf8')
+
+ const reloaded = createStationFromTemplate(templateFile, {
+ baseName: 'TEST-CONVERSION-EFFICIENCY',
+ fixedName: true,
+ persistentConfiguration: rehydrationCase.persistentConfiguration,
+ })
+
+ const reloadedStationInfo = reloaded.stationInfo
+ assert.ok(reloadedStationInfo != null)
+ assert.strictEqual(reloadedStationInfo.currentOutType, rehydrationCase.expectedCurrentOutType)
+ assert.ok(
+ Number.isFinite(reloadedStationInfo.maximumAmperage) &&
+ (reloadedStationInfo.maximumAmperage ?? 0) > 0
+ )
+ assert.strictEqual(
+ reloaded.getConnectorStatus(1, 1)?.transactionEventQueue?.[0]
+ .transactionEnergyActiveImportIntervalConsumption?.['AlignedDataCtrlr.Measurands'],
+ rehydrationCase.expectedEnergyWh
+ )
+ })
+ }
+
await it('does not reduce stationInfo.maximumPower or maximumAmperage', () => {
const baseline = newStation()
const reduced = newStation({ conversionEfficiency: 0.9 })
* @description Unit tests for charging station start/stop/restart and delete operations
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation } from '../../src/charging-station/index.js'
+import type { ConnectorStatus, RequestParams } from '../../src/types/index.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import { ChargingStation } from '../../src/charging-station/ChargingStation.js'
+import { OCPP16RequestService } from '../../src/charging-station/ocpp/1.6/OCPP16RequestService.js'
+import { OCPP16ResponseService } from '../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
+import { OCPP16ServiceUtils } from '../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import { OCPP20IncomingRequestService } from '../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20RequestService } from '../../src/charging-station/ocpp/2.0/OCPP20RequestService.js'
+import { OCPP20ResponseService } from '../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import { OCPP20ServiceUtils } from '../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { stopRunningTransactions } from '../../src/charging-station/ocpp/OCPPServiceOperations.js'
+import {
+ AvailabilityType,
+ MessageType,
+ OCPP16AuthorizationStatus,
+ OCPP16ChargePointStatus,
+ OCPP16IncomingRequestCommand,
+ OCPP16MeterValueMeasurand,
+ OCPP16MeterValueUnit,
+ OCPP16RequestCommand,
+ type OCPP16StopTransactionRequest,
+ type OCPP16StopTransactionResponse,
+ OCPP16VendorParametersKey,
+ OCPP20IncomingRequestCommand,
+ OCPP20RequestCommand,
+ OCPP20TransactionEventEnumType,
+ type OCPP20TransactionEventRequest,
+ OCPP20TriggerReasonEnumType,
+ OCPPVersion,
+} from '../../src/types/index.js'
+import { AsyncLock, AsyncLockType, Constants } from '../../src/utils/index.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../helpers/TestLifecycleHelpers.js'
+import { TEST_PUBLIC_KEY_HEX } from './ChargingStationTestConstants.js'
import { cleanupChargingStation, createMockChargingStation } from './helpers/StationHelpers.js'
+import {
+ createMeterValuesTemplate,
+ createOCPP16RequestTestContext,
+ upsertConfigurationKey,
+} from './ocpp/1.6/OCPP16TestUtils.js'
+
+const installBufferedMessageCallbackState = (target: object): void => {
+ const callbackState = target as { bufferedMessageEntries?: unknown[] }
+ const prototype = ChargingStation.prototype as unknown as {
+ releaseAllBufferedMessageCallbacks: unknown
+ releaseBufferedMessageCallbacks: unknown
+ reserveBufferedMessageCallbacks: unknown
+ }
+ const queuePersistenceState = target as {
+ transactionEventQueueCommittedVersion?: number
+ transactionEventQueueMutationVersion?: number
+ transactionEventQueueSaveWaiters?: Map<number, Set<unknown>>
+ }
+ Object.assign(target, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageCallbackReservations: new Set(),
+ bufferedMessageEntries: callbackState.bufferedMessageEntries ?? [],
+ releaseAllBufferedMessageCallbacks: prototype.releaseAllBufferedMessageCallbacks,
+ releaseBufferedMessageCallbacks: prototype.releaseBufferedMessageCallbacks,
+ reserveBufferedMessageCallbacks: prototype.reserveBufferedMessageCallbacks,
+ transactionEventQueueCommittedVersion:
+ queuePersistenceState.transactionEventQueueCommittedVersion ?? 0,
+ transactionEventQueueMutationVersion:
+ queuePersistenceState.transactionEventQueueMutationVersion ?? 0,
+ transactionEventQueueSaveWaiters:
+ queuePersistenceState.transactionEventQueueSaveWaiters ?? new Map(),
+ })
+}
await describe('ChargingStation Lifecycle', async () => {
await describe('Start/Stop Operations', async () => {
assert.strictEqual(finalStarted, true)
})
+ await it('should coalesce starts requested while stop is still settling', async () => {
+ const stopGate = Promise.withResolvers<undefined>()
+ const restart = mock.fn()
+ const stationLike = {
+ deleteAbortController: new AbortController(),
+ logPrefix: () => '',
+ start: restart,
+ stopPromise: stopGate.promise,
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ ChargingStation.prototype.start.call(stationLike)
+ ChargingStation.prototype.start.call(stationLike)
+ const deferredStart = (stationLike as unknown as { startAfterStopPromise?: Promise<void> })
+ .startAfterStopPromise
+ assert.ok(deferredStart != null)
+ stopGate.resolve(undefined)
+ await deferredStart
+
+ assert.strictEqual(restart.mock.callCount(), 1)
+ })
+
await it('should not restart when already started', () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
station.start()
// Assert initial state
- assert.strictEqual((station as unknown as { stopping: boolean }).stopping, false)
+ assert.strictEqual(ChargingStation.prototype.isStopping.call(station), false)
// Act
await station.stop()
// Assert - after stop() completes, stopping should be false
- assert.strictEqual((station as unknown as { stopping: boolean }).stopping, false)
+ assert.strictEqual(ChargingStation.prototype.isStopping.call(station), false)
assert.strictEqual(station.started, false)
})
+ await it('should join an in-progress stop operation', async () => {
+ const stopGate = Promise.withResolvers<undefined>()
+ let performStopCalls = 0
+ const stationLike = {
+ logPrefix: () => '',
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ performStop: (): Promise<undefined> => {
+ performStopCalls++
+ return stopGate.promise
+ },
+ started: true,
+ stopping: false,
+ }
+
+ const firstStop = ChargingStation.prototype.stop.call(stationLike)
+ const secondStop = ChargingStation.prototype.stop.call(stationLike)
+ let secondStopSettled = false
+ secondStop
+ .then(() => {
+ secondStopSettled = true
+ return undefined
+ })
+ .catch(() => undefined)
+ await Promise.resolve()
+
+ assert.strictEqual(performStopCalls, 1)
+ assert.strictEqual(secondStopSettled, false)
+ stopGate.resolve(undefined)
+ await Promise.all([firstStop, secondStop])
+ assert.strictEqual(secondStopSettled, true)
+ assert.strictEqual(stationLike.stopping, false)
+ })
+
+ await it('should publish the stop single-flight before cancellation invokes onError', async () => {
+ const stopGate = Promise.withResolvers<undefined>()
+ const requestSequence: string[] = []
+ let cancelCalls = 0
+ let performStopCalls = 0
+ let reentrantStop: Promise<void> | undefined
+ const stationLike = {
+ logPrefix: () => '',
+ ocppRequestService: {
+ cancelPendingRequests: () => {
+ cancelCalls++
+ reentrantStop ??= ChargingStation.prototype.stop.call(stationLike)
+ },
+ },
+ performStop: async (): Promise<void> => {
+ performStopCalls++
+ requestSequence.push('StatusNotification', 'StopTransaction')
+ await stopGate.promise
+ },
+ started: true,
+ stopping: false,
+ }
+
+ const owningStop = ChargingStation.prototype.stop.call(stationLike)
+ assert.ok(reentrantStop != null)
+ let reentrantStopSettled = false
+ reentrantStop
+ .then(() => {
+ reentrantStopSettled = true
+ return undefined
+ })
+ .catch(() => undefined)
+
+ assert.strictEqual(cancelCalls, 1)
+ assert.strictEqual(performStopCalls, 1)
+ assert.deepStrictEqual(requestSequence, ['StatusNotification', 'StopTransaction'])
+ assert.strictEqual(stationLike.stopping, true)
+ assert.strictEqual(reentrantStopSettled, false)
+
+ stopGate.resolve(undefined)
+ await Promise.all([owningStop, reentrantStop])
+ assert.strictEqual(stationLike.stopping, false)
+ assert.strictEqual(reentrantStopSettled, true)
+ })
+
+ await it('should retract an in-flight OCPP 1.6 CALL when shutdown begins', async () => {
+ const inFlight = {
+ isRequest: true,
+ message: '[2,"terminal","StopTransaction",{}]',
+ retracted: false,
+ stopDrain: false,
+ }
+ const stationLike = {
+ bufferedMessageInFlight: inFlight,
+ lifecycleAbortController: new AbortController(),
+ logPrefix: () => '',
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ performStop: () => Promise.resolve(),
+ started: true,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ stopping: false,
+ }
+
+ await ChargingStation.prototype.stop.call(stationLike)
+
+ assert.strictEqual(inFlight.retracted, true)
+ assert.strictEqual(inFlight.stopDrain, true)
+ })
+
+ await it('should cancel old non-buffered requests before installing the shutdown lifecycle', async () => {
+ const oldLifecycle = new AbortController()
+ const stopGate = Promise.withResolvers<undefined>()
+ const order: string[] = []
+ const stationLike = {
+ lifecycleAbortController: oldLifecycle,
+ logPrefix: () => '',
+ ocppRequestService: {
+ cancelPendingRequests: (
+ _station: ChargingStation,
+ _message?: string,
+ discardBufferedRequests?: boolean,
+ options?: { handleTransportStartedSends?: boolean }
+ ) => {
+ assert.strictEqual(oldLifecycle.signal.aborted, true)
+ assert.strictEqual(discardBufferedRequests, false)
+ assert.deepStrictEqual(options, { handleTransportStartedSends: true })
+ order.push('cancel')
+ },
+ },
+ performStop: function (): Promise<undefined> {
+ assert.notStrictEqual(this.lifecycleAbortController, oldLifecycle)
+ assert.strictEqual(this.lifecycleAbortController.signal.aborted, false)
+ order.push('performStop')
+ return stopGate.promise
+ },
+ started: true,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_20 },
+ stopping: false,
+ }
+
+ const stopPromise = ChargingStation.prototype.stop.call(stationLike)
+
+ assert.deepStrictEqual(order, ['cancel', 'performStop'])
+ stopGate.resolve(undefined)
+ await stopPromise
+ assert.strictEqual(stationLike.stopping, false)
+ })
+
+ await it('should send an OCPP 1.6 StopTransaction after cancelling an unrelated pending CALL', async () => {
+ const context = createOCPP16RequestTestContext({
+ stationInfo: { beginEndMeterValues: false },
+ })
+ const activeStation = context.station
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = context.requestService
+ activeStation.recordRequestStatistic = () => undefined
+ activeStation.emitChargingStationEvent = () => undefined
+ activeStation.started = true
+ activeStation.isStopping = () => ChargingStation.prototype.isStopping.call(activeStation)
+ setupConnectorWithTransaction(activeStation, 1, { transactionId: 101 })
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ const sentCommands: OCPP16RequestCommand[] = []
+ const unrelatedCallSent = Promise.withResolvers<undefined>()
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ const [, messageId, command] = JSON.parse(String(data)) as [
+ number,
+ string,
+ OCPP16RequestCommand
+ ]
+ sentCommands.push(command)
+ callback?.()
+ if (command === OCPP16RequestCommand.HEARTBEAT) {
+ unrelatedCallSent.resolve(undefined)
+ } else {
+ queueMicrotask(() => {
+ const cachedRequest = activeStation.requests.get(messageId)
+ if (cachedRequest != null) {
+ cachedRequest[0](
+ command === OCPP16RequestCommand.STOP_TRANSACTION
+ ? { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ : {},
+ cachedRequest[3]
+ )
+ }
+ })
+ }
+ })
+ ;(
+ activeStation as unknown as {
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).performStop = async (reason, stopTransactions) => {
+ stopTransactions === true && (await stopRunningTransactions(activeStation, reason))
+ activeStation.ocppRequestService.cancelPendingRequests(activeStation)
+ activeStation.started = false
+ }
+
+ const unrelatedCall = context.requestService.requestHandler(
+ activeStation,
+ OCPP16RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await unrelatedCallSent.promise
+ const rejectedUnrelatedCall = assert.rejects(
+ unrelatedCall,
+ /Charging station stopped while awaiting an OCPP response/
+ )
+
+ await ChargingStation.prototype.stop.call(activeStation, undefined, true)
+ await rejectedUnrelatedCall
+
+ assert.strictEqual(sentCommands[0], OCPP16RequestCommand.HEARTBEAT)
+ assert.ok(sentCommands.includes(OCPP16RequestCommand.STOP_TRANSACTION))
+ assert.strictEqual(activeStation.requests.size, 0)
+ assert.strictEqual(activeStation.getConnectorStatus(1)?.transactionId, undefined)
+ })
+
+ await it('should wait for an in-flight OCPP 1.6 StartTransaction before shutdown StopTransaction', async () => {
+ const context = createOCPP16RequestTestContext({
+ stationInfo: { beginEndMeterValues: false },
+ })
+ const activeStation = context.station
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = context.requestService
+ activeStation.recordRequestStatistic = () => undefined
+ activeStation.emitChargingStationEvent = () => undefined
+ activeStation.started = true
+ activeStation.isStopping = () => ChargingStation.prototype.isStopping.call(activeStation)
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ const sentCommands: OCPP16RequestCommand[] = []
+ const startRequestSent = Promise.withResolvers<string>()
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ const [, messageId, command] = JSON.parse(String(data)) as [
+ number,
+ string,
+ OCPP16RequestCommand
+ ]
+ sentCommands.push(command)
+ callback?.()
+ if (command === OCPP16RequestCommand.START_TRANSACTION) {
+ startRequestSent.resolve(messageId)
+ return
+ }
+ queueMicrotask(() => {
+ const cachedRequest = activeStation.requests.get(messageId)
+ if (cachedRequest == null) return
+ cachedRequest[0](
+ command === OCPP16RequestCommand.STOP_TRANSACTION
+ ? { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ : {},
+ cachedRequest[3]
+ )
+ })
+ })
+ ;(
+ activeStation as unknown as {
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).performStop = async (reason, stopTransactions) => {
+ stopTransactions === true && (await stopRunningTransactions(activeStation, reason))
+ activeStation.ocppRequestService.cancelPendingRequests(activeStation)
+ activeStation.started = false
+ }
+
+ const startTransaction = OCPP16ServiceUtils.startTransactionOnConnector(
+ activeStation,
+ 1,
+ 'RFID-1'
+ )
+ const startMessageId = await startRequestSent.promise
+ const connectorStatus = activeStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+
+ const shutdown = ChargingStation.prototype.stop.call(activeStation, undefined, true)
+ await Promise.resolve()
+ assert.strictEqual(sentCommands.includes(OCPP16RequestCommand.STOP_TRANSACTION), false)
+ const cachedStartRequest = activeStation.requests.get(startMessageId)
+ assert.ok(cachedStartRequest != null)
+ cachedStartRequest[0](
+ {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ transactionId: 2026,
+ },
+ cachedStartRequest[3]
+ )
+
+ await Promise.all([startTransaction, shutdown])
+
+ const startIndex = sentCommands.indexOf(OCPP16RequestCommand.START_TRANSACTION)
+ const stopIndex = sentCommands.indexOf(OCPP16RequestCommand.STOP_TRANSACTION)
+ assert.ok(startIndex >= 0)
+ assert.ok(stopIndex > startIndex)
+ assert.strictEqual(connectorStatus.transactionStarting, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(activeStation.requests.size, 0)
+ })
+
+ await it('should join an in-flight OCPP 1.6 StopTransaction during station stop', async () => {
+ const responseService = new OCPP16ResponseService()
+ const stopResponse = Promise.withResolvers<OCPP16StopTransactionResponse>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ let stopRequestPending = false
+ let cancellationDuringStopTransaction = false
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.STOP_TRANSACTION) {
+ return {}
+ }
+ const {
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ...requestOptions
+ } = args[3] as RequestParams
+ for (const callback of [
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ]) {
+ assert.strictEqual(typeof callback, 'function')
+ }
+ assert.deepStrictEqual(requestOptions, {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ rawPayload: true,
+ skipBufferingOnError: false,
+ throwError: true,
+ })
+ stopRequestPending = true
+ stopRequestStarted.resolve(undefined)
+ try {
+ const response = await stopResponse.promise
+ await responseService.responseHandler(
+ args[0] as ChargingStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ response,
+ args[2] as OCPP16StopTransactionRequest
+ )
+ return response
+ } finally {
+ stopRequestPending = false
+ }
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: { beginEndMeterValues: false, ocppVersion: OCPPVersion.VERSION_16 },
+ })
+ const activeStation = result.station
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.started = true
+ activeStation.isStopping = () => ChargingStation.prototype.isStopping.call(activeStation)
+ setupConnectorWithTransaction(activeStation, 1, { transactionId: 101 })
+ let cancelCalls = 0
+ mock.method(activeStation.ocppRequestService, 'cancelPendingRequests', () => {
+ cancelCalls++
+ if (stopRequestPending) {
+ cancellationDuringStopTransaction = true
+ }
+ })
+ ;(
+ activeStation as unknown as {
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).performStop = async (reason, stopTransactions) => {
+ stopTransactions === true && (await stopRunningTransactions(activeStation, reason))
+ activeStation.ocppRequestService.cancelPendingRequests(activeStation)
+ activeStation.started = false
+ }
+
+ const transactionStop = OCPP16ServiceUtils.stopTransactionOnConnector(activeStation, 1)
+ await stopRequestStarted.promise
+ const stationStop = ChargingStation.prototype.stop.call(activeStation, undefined, true)
+ await Promise.resolve()
+
+ assert.strictEqual(cancelCalls, 1)
+ assert.strictEqual(stopRequestPending, true)
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+
+ stopResponse.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ await Promise.all([transactionStop, stationStop])
+
+ const connectorStatus = activeStation.getConnectorStatus(1)
+ if (connectorStatus == null) {
+ assert.fail('Expected connector to be defined')
+ }
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(cancellationDuringStopTransaction, true)
+ assert.strictEqual(cancelCalls, 2)
+ })
+
+ await it('should buffer StopTransaction behind cached terminal MeterValues when shutdown interrupts replay', async () => {
+ const transportFailure = new Error('terminal MeterValues transport failure')
+ const firstSendFailed = Promise.withResolvers<undefined>()
+ const unavailableConnectorIds: number[] = []
+ const wireMessages: string[] = []
+ const replayCallbacks: (() => void)[] = []
+ const replayedCommands: OCPP16RequestCommand[] = []
+ let replaying = false
+ const { requestService, station: activeStation } = createOCPP16RequestTestContext({
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const lifecycleAbortController = new AbortController()
+ Object.assign(activeStation, {
+ internalStopMessageSequence: () => undefined,
+ lifecycleAbortController,
+ ocppIncomingRequestService: { stop: () => undefined },
+ stopAlignedMeterValues: () => undefined,
+ })
+ Object.defineProperty(activeStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () =>
+ (
+ activeStation as unknown as {
+ lifecycleAbortController: AbortController
+ }
+ ).lifecycleAbortController.signal,
+ })
+ activeStation.started = true
+ activeStation.isStopping = () => ChargingStation.prototype.isStopping.call(activeStation)
+ activeStation.recordRequestStatistic = () => undefined
+ activeStation.ocppRequestService = requestService
+ const acceptedBootNotificationResponse = activeStation.bootNotificationResponse
+ assert.ok(acceptedBootNotificationResponse != null)
+ setupConnectorWithTransaction(activeStation, 1, { transactionId: 103 })
+ const connectorStatus = activeStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ upsertConfigurationKey(
+ activeStation,
+ OCPP16VendorParametersKey.SampledDataSignReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ activeStation,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ activeStation,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ const message = String(data)
+ wireMessages.push(message)
+ const [, messageId, command, payload] = JSON.parse(message) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ Record<string, unknown>
+ ]
+ if (replaying) {
+ replayedCommands.push(command)
+ replayCallbacks.push(() => {
+ callback?.()
+ })
+ return
+ }
+ if (command === OCPP16RequestCommand.METER_VALUES) {
+ callback?.(transportFailure)
+ firstSendFailed.resolve(undefined)
+ return
+ }
+ callback?.()
+ if (
+ command === OCPP16RequestCommand.STATUS_NOTIFICATION &&
+ payload.status === OCPP16ChargePointStatus.Unavailable
+ ) {
+ unavailableConnectorIds.push(payload.connectorId as number)
+ }
+ const cachedRequest = activeStation.requests.get(messageId)
+ cachedRequest?.[0](
+ command === OCPP16RequestCommand.STOP_TRANSACTION
+ ? { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ : {},
+ cachedRequest[3]
+ )
+ }
+ )
+ const performStop = (
+ ChargingStation.prototype as unknown as {
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).performStop
+ ;(
+ activeStation as unknown as {
+ performStop: typeof performStop
+ }
+ ).performStop = performStop
+ Object.assign(activeStation, {
+ configurationFileHash: 'terminal-meter-values-shutdown-regression',
+ saveConfiguration: () => undefined,
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ })
+ const stopMessageSequence = (
+ ChargingStation.prototype as unknown as {
+ stopMessageSequence: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).stopMessageSequence
+ ;(
+ activeStation as unknown as {
+ stopMessageSequence: typeof stopMessageSequence
+ }
+ ).stopMessageSequence = async (reason, stopTransactions) => {
+ assert.strictEqual(lifecycleAbortController.signal.aborted, true)
+ assert.notStrictEqual(activeStation.lifecycleAbortSignal, lifecycleAbortController.signal)
+ assert.strictEqual(activeStation.lifecycleAbortSignal.aborted, false)
+ await stopMessageSequence.call(activeStation, reason, stopTransactions)
+ }
+
+ const callerStop = OCPP16ServiceUtils.stopTransactionOnConnector(activeStation, 1)
+ const rejectedCallerStop = assert.rejects(
+ callerStop,
+ /Buffered message id .* without sending/
+ )
+ await firstSendFailed.promise
+ const stationStop = ChargingStation.prototype.stop.call(activeStation, undefined, true)
+ await Promise.all([rejectedCallerStop, stationStop])
+
+ const stationInternals = activeStation as unknown as { messageQueue: string[] }
+ const bufferedCommands = stationInternals.messageQueue.map(
+ message => (JSON.parse(message) as [number, string, OCPP16RequestCommand])[2]
+ )
+ assert.deepStrictEqual(bufferedCommands, [
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ ])
+ const wireCommands = wireMessages.map(
+ message => (JSON.parse(message) as [number, string, OCPP16RequestCommand])[2]
+ )
+ assert.deepStrictEqual(
+ wireCommands.filter(
+ command =>
+ command === OCPP16RequestCommand.METER_VALUES ||
+ command === OCPP16RequestCommand.STOP_TRANSACTION
+ ),
+ [OCPP16RequestCommand.METER_VALUES]
+ )
+ assert.deepStrictEqual(
+ unavailableConnectorIds.sort((a, b) => a - b),
+ [1, 2]
+ )
+ assert.strictEqual(activeStation.requests.size, 2)
+ for (const message of stationInternals.messageQueue) {
+ const [, messageId] = JSON.parse(message) as [number, string]
+ assert.strictEqual(activeStation.requests.has(messageId), true)
+ }
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ replaying = true
+ activeStation.bootNotificationResponse = acceptedBootNotificationResponse
+ activeStation.wsConnection = wsConnection
+ activeStation.isWebSocketConnectionOpened = () => true
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ activeStation as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await Promise.resolve()
+ assert.deepStrictEqual(replayedCommands, [OCPP16RequestCommand.METER_VALUES])
+ const [, replayedMeterValuesId] = JSON.parse(stationInternals.messageQueue[0]) as [
+ number,
+ string
+ ]
+ const replayedMeterValues = activeStation.requests.get(replayedMeterValuesId)
+ assert.ok(replayedMeterValues != null)
+ replayCallbacks.shift()?.()
+ replayedMeterValues[0]({}, replayedMeterValues[3])
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await Promise.resolve()
+ assert.deepStrictEqual(replayedCommands, [
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ ])
+ replayCallbacks.shift()?.()
+ assert.strictEqual(stationInternals.messageQueue.length, 0)
+ })
+
+ await it('should replay a replacement payload selected behind the outgoing-call gate', async () => {
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const messageId = 'selected-buffered-stop'
+ const previousPayload = { meterStop: 100, transactionId: 42 }
+ const replacementPayload = { meterStop: 125, transactionId: 42 }
+ const previousMessage = JSON.stringify([
+ 2,
+ messageId,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ ])
+ const gate = Promise.withResolvers<undefined>()
+ const sentMessages: string[] = []
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageInFlight?: { stopDrain?: boolean }
+ messageQueue: string[]
+ saveConfiguration: () => void
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ stationInternals.messageQueue = [previousMessage]
+ stationInternals.saveConfiguration = () => undefined
+ activeStation.requests.set(messageId, [
+ () => undefined,
+ () => undefined,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ ])
+ activeStation.isWebSocketConnectionOpened = () => true
+ mock.method(activeStation, 'inAcceptedState', () => true)
+ mock.method(activeStation.ocppRequestService, 'acquireOutgoingCall', () => gate.promise)
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (message: unknown, callback?: (error?: Error) => void) => {
+ sentMessages.push(String(message))
+ callback?.()
+ })
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ let completed = false
+
+ sendMessageBuffer.call(activeStation, () => {
+ completed = true
+ })
+ assert.strictEqual(
+ ChargingStation.prototype.replaceBufferedRequestPayload.call(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ replacementPayload
+ ),
+ true
+ )
+ if (stationInternals.bufferedMessageInFlight != null) {
+ stationInternals.bufferedMessageInFlight.stopDrain = true
+ }
+ gate.resolve(undefined)
+ await flushMicrotasks()
+
+ assert.strictEqual(completed, true)
+ assert.strictEqual(sentMessages.length, 1)
+ const [, , command, payload] = JSON.parse(sentMessages[0]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ Record<string, unknown>
+ ]
+ assert.strictEqual(command, OCPP16RequestCommand.STOP_TRANSACTION)
+ assert.deepStrictEqual(payload, replacementPayload)
+ })
+
+ await it('should remove a reconciled buffered request after its replay response', async () => {
+ const { requestService, station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = requestService
+ activeStation.recordRequestStatistic = () => undefined
+ setupConnectorWithTransaction(activeStation, 1, { transactionId: 42 })
+ const previousPayload: OCPP16StopTransactionRequest = {
+ meterStop: 100,
+ timestamp: new Date('2026-09-13T10:00:00.000Z'),
+ transactionId: 42,
+ }
+ const replacementPayload: OCPP16StopTransactionRequest = {
+ ...previousPayload,
+ meterStop: 125,
+ }
+ const stationInternals = activeStation as unknown as {
+ acknowledgedBufferedMessages: Set<string>
+ messageQueue: string[]
+ saveConfiguration: () => void
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ stationInternals.saveConfiguration = () => undefined
+ activeStation.isWebSocketConnectionOpened = () => false
+
+ await assert.rejects(
+ requestService.requestHandler<OCPP16StopTransactionRequest, OCPP16StopTransactionResponse>(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ {
+ rawPayload: true,
+ skipBufferingOnError: false,
+ throwError: true,
+ }
+ ),
+ /WebSocket closed/
+ )
+ const [previousMessage] = stationInternals.messageQueue
+ const [, messageId] = JSON.parse(previousMessage) as [number, string]
+ assert.strictEqual(
+ ChargingStation.prototype.replaceBufferedRequestPayload.call(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ replacementPayload
+ ),
+ true
+ )
+
+ const sentMessages: string[] = []
+ const sendCallbacks: (() => void)[] = []
+ activeStation.isWebSocketConnectionOpened = () => true
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (message: unknown, callback?: (error?: Error) => void) => {
+ sentMessages.push(String(message))
+ if (callback != null) sendCallbacks.push(callback)
+ })
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ stationInternals.sendMessageBuffer = sendMessageBuffer
+
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await flushMicrotasks()
+ sendCallbacks.shift()?.()
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ (JSON.parse(sentMessages[0]) as [number, string, OCPP16RequestCommand, unknown])[3],
+ { ...replacementPayload, timestamp: replacementPayload.timestamp.toISOString() }
+ )
+ assert.strictEqual(stationInternals.acknowledgedBufferedMessages.size, 1)
+
+ const cachedRequest = activeStation.requests.get(messageId)
+ assert.ok(cachedRequest != null)
+ cachedRequest[0](
+ { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } },
+ cachedRequest[3]
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(activeStation.requests.has(messageId), false)
+ assert.strictEqual(stationInternals.acknowledgedBufferedMessages.size, 0)
+ assert.strictEqual(activeStation.getBufferedRequestIds().size, 0)
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(
+ sentMessages.filter(message => (JSON.parse(message) as [number, string])[1] === messageId)
+ .length,
+ 1
+ )
+ })
+
+ await it('should not duplicate a reconciled buffered request during cancellation', async () => {
+ const { requestService, station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = requestService
+ activeStation.recordRequestStatistic = () => undefined
+ const previousPayload: OCPP16StopTransactionRequest = {
+ meterStop: 100,
+ timestamp: new Date('2026-09-13T10:00:00.000Z'),
+ transactionId: 42,
+ }
+ const replacementPayload: OCPP16StopTransactionRequest = {
+ ...previousPayload,
+ meterStop: 125,
+ }
+ const stationInternals = activeStation as unknown as {
+ messageQueue: string[]
+ saveConfiguration: () => void
+ }
+ stationInternals.saveConfiguration = () => undefined
+ activeStation.isWebSocketConnectionOpened = () => false
+
+ await assert.rejects(
+ requestService.requestHandler<OCPP16StopTransactionRequest, OCPP16StopTransactionResponse>(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ {
+ rawPayload: true,
+ skipBufferingOnError: false,
+ throwError: true,
+ }
+ ),
+ /WebSocket closed/
+ )
+ assert.strictEqual(
+ ChargingStation.prototype.replaceBufferedRequestPayload.call(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ replacementPayload
+ ),
+ true
+ )
+
+ requestService.cancelPendingRequests(activeStation)
+
+ assert.strictEqual(stationInternals.messageQueue.length, 1)
+ const [, , command, payload] = JSON.parse(stationInternals.messageQueue[0]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ OCPP16StopTransactionRequest
+ ]
+ assert.strictEqual(command, OCPP16RequestCommand.STOP_TRANSACTION)
+ assert.deepStrictEqual(payload, {
+ ...replacementPayload,
+ timestamp: replacementPayload.timestamp.toISOString(),
+ })
+ })
+
+ await it('should refuse payload replacement after replay reaches the transport', async () => {
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const messageId = 'sending-buffered-stop'
+ const previousPayload = { meterStop: 100, transactionId: 42 }
+ const replacementPayload = { meterStop: 125, transactionId: 42 }
+ const previousMessage = JSON.stringify([
+ 2,
+ messageId,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ ])
+ const sendCallbacks: ((error?: Error) => void)[] = []
+ const sentMessages: string[] = []
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageInFlight?: { stopDrain?: boolean }
+ messageQueue: string[]
+ }
+ stationInternals.messageQueue = [previousMessage]
+ activeStation.requests.set(messageId, [
+ () => undefined,
+ () => undefined,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ ])
+ activeStation.isWebSocketConnectionOpened = () => true
+ mock.method(activeStation, 'inAcceptedState', () => true)
+ mock.method(activeStation.ocppRequestService, 'acquireOutgoingCall', () =>
+ Promise.resolve(true)
+ )
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (message: unknown, callback?: (error?: Error) => void) => {
+ sentMessages.push(String(message))
+ if (callback != null) sendCallbacks.push(callback)
+ })
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(
+ ChargingStation.prototype.replaceBufferedRequestPayload.call(
+ activeStation,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ previousPayload,
+ replacementPayload
+ ),
+ false
+ )
+
+ const [, , , payload] = JSON.parse(sentMessages[0]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ Record<string, unknown>
+ ]
+ assert.deepStrictEqual(payload, previousPayload)
+ assert.strictEqual(activeStation.requests.get(messageId)?.[3], previousPayload)
+ if (stationInternals.bufferedMessageInFlight != null) {
+ stationInternals.bufferedMessageInFlight.stopDrain = true
+ }
+ sendCallbacks[0]?.()
+ await flushMicrotasks()
+ })
+
+ await it('should keep callbacks distinct for duplicate buffered response frames', () => {
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageCallbackBytes: number
+ bufferedMessageCallbackCount: number
+ bufferedMessageEntries: unknown[]
+ clearIntervalFlushMessageBuffer: () => void
+ messageQueue: string[]
+ setIntervalFlushMessageBuffer: () => void
+ }
+ Object.assign(stationInternals, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageEntries: [],
+ clearIntervalFlushMessageBuffer: () => undefined,
+ messageQueue: [],
+ removeBufferedMessageEntry: (
+ ChargingStation.prototype as unknown as {
+ removeBufferedMessageEntry: (messageIndex: number, notifyDiscarded: boolean) => unknown
+ }
+ ).removeBufferedMessageEntry.bind(activeStation),
+ setIntervalFlushMessageBuffer: () => undefined,
+ })
+ const duplicateResponse = JSON.stringify([3, 'duplicate-inbound-id', { status: 'Accepted' }])
+ const discarded: string[] = []
+
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(activeStation, duplicateResponse, false, {
+ onDiscarded: () => discarded.push('first'),
+ }),
+ true
+ )
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(activeStation, duplicateResponse, false, {
+ onDiscarded: () => discarded.push('second'),
+ }),
+ true
+ )
+ assert.strictEqual(
+ ChargingStation.prototype.removeBufferedMessage.call(activeStation, duplicateResponse),
+ true
+ )
+ assert.deepStrictEqual(discarded, ['first'])
+ assert.strictEqual(
+ ChargingStation.prototype.removeBufferedMessage.call(activeStation, duplicateResponse),
+ true
+ )
+ assert.deepStrictEqual(discarded, ['first', 'second'])
+ })
+
+ await it('should bound callback-bearing responses without rejecting durable calls', () => {
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageCallbackBytes: number
+ bufferedMessageCallbackCount: number
+ bufferedMessageEntries: unknown[]
+ clearIntervalFlushMessageBuffer: () => void
+ messageQueue: string[]
+ setIntervalFlushMessageBuffer: () => void
+ }
+ Object.assign(stationInternals, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageEntries: [],
+ clearIntervalFlushMessageBuffer: () => undefined,
+ messageQueue: [],
+ removeBufferedMessageEntry: (
+ ChargingStation.prototype as unknown as {
+ removeBufferedMessageEntry: (messageIndex: number, notifyDiscarded: boolean) => unknown
+ }
+ ).removeBufferedMessageEntry.bind(activeStation),
+ setIntervalFlushMessageBuffer: () => undefined,
+ })
+ let discarded = 0
+ for (let index = 0; index < 1024; index++) {
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(
+ activeStation,
+ JSON.stringify([3, `response-${index.toString()}`, {}]),
+ false,
+ { onDiscarded: () => discarded++ }
+ ),
+ true
+ )
+ }
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(
+ activeStation,
+ JSON.stringify([3, 'response-over-count-cap', {}]),
+ false,
+ { onDiscarded: () => discarded++ }
+ ),
+ false
+ )
+ assert.strictEqual(discarded, 1)
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(
+ activeStation,
+ JSON.stringify([2, 'durable-call', OCPP16RequestCommand.HEARTBEAT, {}])
+ ),
+ true
+ )
+ assert.strictEqual(stationInternals.messageQueue.length, 1025)
+
+ ChargingStation.prototype.clearMessageBuffer.call(activeStation)
+ assert.strictEqual(discarded, 1025)
+ assert.strictEqual(
+ ChargingStation.prototype.bufferMessage.call(
+ activeStation,
+ JSON.stringify([3, 'response-over-byte-cap', { value: 'x'.repeat(1024 * 1024) }]),
+ false,
+ { onDiscarded: () => discarded++ }
+ ),
+ false
+ )
+ assert.strictEqual(discarded, 1026)
+ assert.deepStrictEqual(stationInternals.messageQueue, [])
+ })
+
+ await it('should discard buffered response callbacks when lifecycle ownership is released', async () => {
+ const { requestService, station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = requestService
+ activeStation.recordRequestStatistic = () => undefined
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageCallbackBytes: number
+ bufferedMessageCallbackCount: number
+ messageQueue: string[]
+ }
+ const sentMessages: string[] = []
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (message: unknown): never => {
+ sentMessages.push(String(message))
+ throw new Error('connection unavailable before send')
+ })
+ let discarded = 0
+ let sent = 0
+
+ await assert.rejects(
+ requestService.sendResponse(
+ activeStation,
+ 'cancelled-response',
+ {},
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ {
+ bufferedResponseRetainedBytes: 900 * 1024,
+ onError: () => {
+ discarded++
+ },
+ onMessageSent: () => {
+ sent++
+ },
+ }
+ )
+ )
+ const durableCall = JSON.stringify([2, 'durable-call', OCPP16RequestCommand.HEARTBEAT, {}])
+ const rawFrame = 'unrelated raw frame'
+ activeStation.bufferMessage(durableCall)
+ activeStation.bufferMessage(rawFrame)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 1)
+
+ ChargingStation.prototype.releaseAllBufferedMessageCallbacks.call(activeStation)
+
+ assert.deepStrictEqual(stationInternals.messageQueue, [durableCall, rawFrame])
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 0)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackBytes, 0)
+ assert.strictEqual(discarded, 1)
+ assert.strictEqual(sent, 0)
+ assert.strictEqual(sentMessages.length, 1)
+
+ assert.strictEqual(
+ activeStation.bufferMessage(JSON.stringify([3, 'next-lifecycle-response', {}]), false, {
+ onDiscarded: () => {
+ discarded++
+ },
+ retainedBytes: 900 * 1024,
+ }),
+ true
+ )
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 1)
+ ChargingStation.prototype.releaseAllBufferedMessageCallbacks.call(activeStation)
+ assert.deepStrictEqual(stationInternals.messageQueue, [durableCall, rawFrame])
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 0)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackBytes, 0)
+ assert.strictEqual(discarded, 2)
+ assert.strictEqual(sent, 0)
+ })
+
+ await it('should reserve callback capacity before sending a response', async () => {
+ const { requestService, station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.ocppRequestService = requestService
+ activeStation.recordRequestStatistic = () => undefined
+ const stationInternals = activeStation as unknown as {
+ bufferedMessageCallbackBytes: number
+ bufferedMessageCallbackCount: number
+ }
+ const sendCallbacks: ((error?: Error) => void)[] = []
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (_message: unknown, callback?: (error?: Error) => void) => {
+ if (callback != null) sendCallbacks.push(callback)
+ })
+
+ const firstResponse = activeStation.ocppRequestService.sendResponse(
+ activeStation,
+ 'stalled-response',
+ {},
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ { bufferedResponseRetainedBytes: 900 * 1024 }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(sendCallbacks.length, 1)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 1)
+ assert.ok(stationInternals.bufferedMessageCallbackBytes >= 900 * 1024)
+
+ await assert.rejects(
+ activeStation.ocppRequestService.sendResponse(
+ activeStation,
+ 'response-over-byte-cap',
+ {},
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ { bufferedResponseRetainedBytes: 200 * 1024 }
+ ),
+ /Response callback capacity exceeded/
+ )
+ assert.strictEqual(sendCallbacks.length, 1)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 1)
+
+ sendCallbacks[0]?.()
+ await firstResponse
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 0)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackBytes, 0)
+
+ const resumedResponse = activeStation.ocppRequestService.sendResponse(
+ activeStation,
+ 'response-after-release',
+ {},
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ { bufferedResponseRetainedBytes: 200 * 1024 }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(sendCallbacks.length, 2)
+ sendCallbacks[1]?.()
+ await resumedResponse
+ })
+
+ await it('should release a buffered-message drain when the connection closes', () => {
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const bufferedMessage = JSON.stringify([
+ 2,
+ 'interrupted-buffer-message',
+ OCPP16RequestCommand.HEARTBEAT,
+ {},
+ ])
+ const stationInternals = activeStation as unknown as {
+ flushingMessageBuffer: boolean
+ messageQueue: string[]
+ }
+ stationInternals.flushingMessageBuffer = true
+ stationInternals.messageQueue = [bufferedMessage]
+ activeStation.wsConnection = null
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ let completed = false
+
+ sendMessageBuffer.call(activeStation, () => {
+ completed = true
+ stationInternals.flushingMessageBuffer = false
+ })
+
+ assert.strictEqual(completed, true)
+ assert.strictEqual(stationInternals.flushingMessageBuffer, false)
+ assert.deepStrictEqual(stationInternals.messageQueue, [bufferedMessage])
+ })
+
+ await it('should schedule buffered response replay while registration is pending', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const { station: activeStation } = createOCPP16RequestTestContext()
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ const flushMessageBufferSpy = mock.fn()
+ const stationInternals = activeStation as unknown as {
+ flushMessageBuffer: () => void
+ flushMessageBufferSetInterval?: NodeJS.Timeout
+ messageQueue: string[]
+ setIntervalFlushMessageBuffer: () => void
+ }
+ stationInternals.flushMessageBuffer = flushMessageBufferSpy
+ stationInternals.messageQueue = ['[3,"pending-response",{}]']
+ activeStation.isWebSocketConnectionOpened = () => true
+ mock.method(activeStation, 'inAcceptedState', () => false)
+ stationInternals.setIntervalFlushMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ setIntervalFlushMessageBuffer: (this: ChargingStation) => void
+ }
+ ).setIntervalFlushMessageBuffer
+
+ stationInternals.setIntervalFlushMessageBuffer.call(activeStation)
+ t.mock.timers.tick(Constants.DEFAULT_MESSAGE_BUFFER_FLUSH_INTERVAL_MS)
+
+ assert.strictEqual(flushMessageBufferSpy.mock.callCount(), 1)
+ if (stationInternals.flushMessageBufferSetInterval != null) {
+ clearInterval(stationInternals.flushMessageBufferSetInterval)
+ }
+ })
+
+ await it('should buffer an unacknowledged StopTransaction once when station stop times out', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const responseService = new OCPP16ResponseService()
+ const requestService = new OCPP16RequestService(responseService)
+ const initialSendStarted = Promise.withResolvers<undefined>()
+ const wireMessages: string[] = []
+ let initialSendCallback: ((error?: Error) => void) | undefined
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: { beginEndMeterValues: false, ocppVersion: OCPPVersion.VERSION_16 },
+ })
+ const activeStation = result.station
+ station = activeStation
+ installBufferedMessageCallbackState(activeStation)
+ activeStation.started = true
+ activeStation.isStopping = () => ChargingStation.prototype.isStopping.call(activeStation)
+ activeStation.recordRequestStatistic = () => undefined
+ activeStation.ocppRequestService = requestService
+ const acceptedBootNotificationResponse = activeStation.bootNotificationResponse
+ assert.ok(acceptedBootNotificationResponse != null)
+ setupConnectorWithTransaction(activeStation, 1, { transactionId: 102 })
+ const connectorStatus = activeStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const meterValuesTimer = setInterval(() => undefined, 60_000)
+ t.after(() => {
+ clearInterval(meterValuesTimer)
+ })
+ connectorStatus.transactionUpdatedMeterValuesSetInterval = meterValuesTimer
+ const wsConnection = activeStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ initialSendCallback = callback
+ initialSendStarted.resolve(undefined)
+ } else {
+ callback?.()
+ }
+ }
+ )
+ const performStop = (
+ ChargingStation.prototype as unknown as {
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).performStop
+ ;(
+ activeStation as unknown as {
+ performStop: typeof performStop
+ }
+ ).performStop = performStop
+ Object.assign(activeStation, {
+ configurationFileHash: 'timeout-regression',
+ saveConfiguration: () => undefined,
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ })
+ ;(
+ activeStation as unknown as {
+ stopMessageSequence: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean
+ ) => Promise<void>
+ }
+ ).stopMessageSequence = async (reason, stopTransactions) => {
+ stopTransactions === true && (await stopRunningTransactions(activeStation, reason))
+ }
+ const transactionData = [
+ {
+ sampledValue: [{ value: '1024' }],
+ timestamp: new Date('2026-09-08T12:34:56.000Z'),
+ },
+ ]
+
+ const callerStop = OCPP16ServiceUtils.stopTransactionOnConnector(
+ activeStation,
+ 1,
+ undefined,
+ { idTag: 'SHUTDOWN-TAG', transactionData }
+ )
+ await initialSendStarted.promise
+ const rejectedCallerStop = assert.rejects(
+ callerStop,
+ /Charging station stopped while awaiting an OCPP response/
+ )
+ const stationStop = ChargingStation.prototype.stop.call(activeStation, undefined, true)
+ await Promise.resolve()
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) {
+ await Promise.resolve()
+ }
+
+ await rejectedCallerStop
+ await stationStop
+ const stationInternals = activeStation as unknown as { messageQueue: string[] }
+ assert.strictEqual(stationInternals.messageQueue.length, 1)
+ assert.strictEqual(activeStation.requests.size, 1)
+ const sendFailure = new Error('late transport failure after shutdown cancellation')
+ initialSendCallback?.(sendFailure)
+ initialSendCallback?.(sendFailure)
+ transactionData[0].sampledValue[0].value = 'mutated-after-rejection'
+ transactionData.push({ sampledValue: [{ value: 'late' }], timestamp: new Date() })
+
+ assert.strictEqual(stationInternals.messageQueue.length, 1)
+ assert.strictEqual(activeStation.requests.size, 1)
+ assert.strictEqual(wireMessages.length, 1)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionId, 102)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ const cachedRequest = [...activeStation.requests.values()][0]
+ const [responseCallback, , commandName, cachedPayload] = cachedRequest
+ assert.strictEqual(commandName, OCPP16RequestCommand.STOP_TRANSACTION)
+ assert.strictEqual(Object.isFrozen(cachedPayload), true)
+ assert.deepStrictEqual((cachedPayload as OCPP16StopTransactionRequest).transactionData, [
+ {
+ sampledValue: [{ value: '1024' }],
+ timestamp: new Date('2026-09-08T12:34:56.000Z'),
+ },
+ ])
+ const bufferedMessage = JSON.parse(stationInternals.messageQueue[0]) as [
+ number,
+ string,
+ string,
+ OCPP16StopTransactionRequest
+ ]
+ assert.strictEqual(bufferedMessage[2], OCPP16RequestCommand.STOP_TRANSACTION)
+ assert.deepStrictEqual(bufferedMessage[3].transactionData, [
+ {
+ sampledValue: [{ value: '1024' }],
+ timestamp: '2026-09-08T12:34:56.000Z',
+ },
+ ])
+
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ activeStation as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+ activeStation.bootNotificationResponse = acceptedBootNotificationResponse
+ activeStation.wsConnection = wsConnection
+ activeStation.isWebSocketConnectionOpened = () => true
+ sendMessageBuffer.call(activeStation, () => undefined)
+ await Promise.resolve()
+
+ assert.strictEqual(stationInternals.messageQueue.length, 0)
+ assert.strictEqual(wireMessages.length, 2)
+ assert.strictEqual(wireMessages[1], wireMessages[0])
+ assert.strictEqual(
+ wireMessages.filter(message => {
+ const frame = JSON.parse(message) as unknown[]
+ return frame[2] === OCPP16RequestCommand.STOP_TRANSACTION
+ }).length,
+ 2
+ )
+
+ ;(activeStation as unknown as { stopping: boolean }).stopping = true
+ responseCallback({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }, cachedPayload)
+ await new Promise(resolve => setImmediate(resolve))
+ await new Promise(resolve => setImmediate(resolve))
+ assert.strictEqual(activeStation.requests.size, 0)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ })
+
+ await it('should persist a reverted snapshot queued behind a stale pending save', async () => {
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const configurationDirectory = mkdtempSync(join(tmpdir(), 'configuration-save-race-'))
+ const configurationFile = join(configurationDirectory, 'station.json')
+ const saveConfiguration = (
+ ChargingStation.prototype as unknown as {
+ saveConfiguration: (this: ChargingStation) => void
+ }
+ ).saveConfiguration
+ const stationPersistence = station as unknown as {
+ configurationFile: string
+ configurationFileHash: string
+ getAutomaticTransactionGeneratorConfiguration: () => undefined
+ getConfigurationFromFile: () => Record<string, never>
+ pendingConfigurationSave: Promise<void>
+ persistenceDiscarded: boolean
+ }
+ Object.assign(stationPersistence, {
+ configurationFile,
+ configurationFileHash: '',
+ getAutomaticTransactionGeneratorConfiguration: () => undefined,
+ getConfigurationFromFile: () => ({}),
+ pendingConfigurationSave: Promise.resolve(),
+ persistenceDiscarded: false,
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.availability = AvailabilityType.Operative
+
+ try {
+ saveConfiguration.call(station)
+ await stationPersistence.pendingConfigurationSave
+
+ const lockAcquired = Promise.withResolvers<undefined>()
+ const releaseLock = Promise.withResolvers<undefined>()
+ const heldLock = AsyncLock.runExclusive(AsyncLockType.configuration, async () => {
+ lockAcquired.resolve(undefined)
+ await releaseLock.promise
+ })
+ await lockAcquired.promise
+
+ connectorStatus.availability = AvailabilityType.Inoperative
+ saveConfiguration.call(station)
+ const staleSave = stationPersistence.pendingConfigurationSave
+ connectorStatus.availability = AvailabilityType.Operative
+ saveConfiguration.call(station)
+ const revertedSave = stationPersistence.pendingConfigurationSave
+
+ releaseLock.resolve(undefined)
+ await revertedSave
+ await heldLock
+ assert.notStrictEqual(revertedSave, staleSave)
+
+ const persistedConfiguration = JSON.parse(readFileSync(configurationFile, 'utf8')) as {
+ connectorsStatus: [number, ConnectorStatus][]
+ }
+ assert.strictEqual(
+ persistedConfiguration.connectorsStatus.find(([connectorId]) => connectorId === 1)?.[1]
+ .availability,
+ AvailabilityType.Operative
+ )
+ } finally {
+ rmSync(configurationDirectory, { force: true, recursive: true })
+ }
+ })
+
+ await it('should coalesce transaction queue persistence to one dirty follow-up save', async () => {
+ const firstSave = Promise.withResolvers<undefined>()
+ const saveConfiguration = mock.fn()
+ const stationLike = {
+ pendingConfigurationSave: firstSave.promise,
+ saveConfiguration,
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+ const saveState = stationLike as unknown as {
+ transactionEventQueueSavePromise?: Promise<void>
+ }
+
+ for (let index = 0; index < 100; index++) {
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike)
+ }
+
+ assert.strictEqual(saveConfiguration.mock.callCount(), 1)
+ firstSave.resolve(undefined)
+ await saveState.transactionEventQueueSavePromise
+ assert.strictEqual(saveConfiguration.mock.callCount(), 2)
+ })
+
+ await it('should acknowledge a saved queue snapshot before a later dirty save fails', async () => {
+ const firstSave = Promise.withResolvers<undefined>()
+ const secondSave = Promise.withResolvers<undefined>()
+ const secondFailure = new Error('later persistence failure')
+ let saveAttempts = 0
+ const stationState = {
+ lifecycleAbortSignal: new AbortController().signal,
+ pendingConfigurationSave: Promise.resolve(),
+ persistenceDiscarded: false,
+ saveConfiguration: (onError?: (error: Error) => void) => {
+ saveAttempts++
+ if (saveAttempts === 2) onError?.(secondFailure)
+ stationState.pendingConfigurationSave =
+ saveAttempts === 1 ? firstSave.promise : secondSave.promise
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(
+ stationState as unknown as ChargingStation,
+ deferred
+ )
+ },
+ }
+ const stationLike = stationState as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ const firstPersistence =
+ ChargingStation.prototype.persistTransactionEventQueues.call(stationLike)
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike)
+ firstSave.resolve(undefined)
+ await firstPersistence
+ assert.strictEqual(saveAttempts, 2)
+
+ secondSave.resolve(undefined)
+ const outcome = await (
+ stationLike as unknown as {
+ transactionEventQueueSavePromise: Promise<{ error?: Error }>
+ }
+ ).transactionEventQueueSavePromise
+ assert.strictEqual(outcome.error, secondFailure)
+ })
+
+ await it('should acknowledge a saved queue snapshot while a later dirty save remains pending', async () => {
+ const firstSave = Promise.withResolvers<undefined>()
+ const hangingSave = Promise.withResolvers<undefined>()
+ let saveAttempts = 0
+ const stationState = {
+ lifecycleAbortSignal: new AbortController().signal,
+ pendingConfigurationSave: Promise.resolve(),
+ persistenceDiscarded: false,
+ saveConfiguration: () => {
+ saveAttempts++
+ stationState.pendingConfigurationSave =
+ saveAttempts === 1 ? firstSave.promise : hangingSave.promise
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(
+ stationState as unknown as ChargingStation,
+ deferred
+ )
+ },
+ }
+ const stationLike = stationState as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ const firstPersistence =
+ ChargingStation.prototype.persistTransactionEventQueues.call(stationLike)
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike)
+ firstSave.resolve(undefined)
+ await firstPersistence
+ assert.strictEqual(saveAttempts, 2)
+
+ stationState.persistenceDiscarded = true
+ hangingSave.resolve(undefined)
+ await (stationLike as unknown as { transactionEventQueueSavePromise: Promise<unknown> })
+ .transactionEventQueueSavePromise
+ })
+
+ await it('should retry a failed transaction queue checkpoint without another mutation', async () => {
+ const firstSave = Promise.withResolvers<undefined>()
+ const secondSave = Promise.withResolvers<undefined>()
+ const saveFailure = new Error('transient persistence failure')
+ let saveAttempts = 0
+ const stationState = {
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: (onError?: (error: Error) => void) => {
+ saveAttempts++
+ if (saveAttempts === 1) onError?.(saveFailure)
+ stationState.pendingConfigurationSave =
+ saveAttempts === 1 ? firstSave.promise : secondSave.promise
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(
+ stationState as unknown as ChargingStation,
+ deferred
+ )
+ },
+ }
+ const stationLike = stationState as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ ;(
+ ChargingStation.prototype as unknown as {
+ releaseTransactionEventQueueSaveDelay: (this: ChargingStation) => void
+ }
+ ).releaseTransactionEventQueueSaveDelay.call(stationLike)
+ const firstCheckpoint = (
+ stationLike as unknown as {
+ transactionEventQueueSavePromise: Promise<{ error?: Error }>
+ }
+ ).transactionEventQueueSavePromise
+ firstSave.resolve(undefined)
+ assert.strictEqual((await firstCheckpoint).error, saveFailure)
+ assert.strictEqual(saveAttempts, 1)
+
+ ;(
+ ChargingStation.prototype as unknown as {
+ releaseTransactionEventQueueSaveDelay: (this: ChargingStation) => void
+ }
+ ).releaseTransactionEventQueueSaveDelay.call(stationLike)
+ for (let index = 0; index < 5; index++) await Promise.resolve()
+ assert.strictEqual(saveAttempts, 2)
+ secondSave.resolve(undefined)
+ await (stationLike as unknown as { transactionEventQueueSavePromise: Promise<unknown> })
+ .transactionEventQueueSavePromise
+ })
+
+ await it('should surface a persistent queue checkpoint failure without hot-looping', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const saveFailure = new Error('persistent storage failure')
+ let saveAttempts = 0
+ const stationState = {
+ lifecycleAbortSignal: new AbortController().signal,
+ pendingConfigurationSave: Promise.resolve(),
+ persistenceDiscarded: false,
+ saveConfiguration: (onError?: (error: Error) => void) => {
+ saveAttempts++
+ onError?.(saveFailure)
+ stationState.pendingConfigurationSave = Promise.resolve()
+ if (saveAttempts === 2) stationState.persistenceDiscarded = true
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(
+ stationState as unknown as ChargingStation,
+ deferred
+ )
+ },
+ }
+ const stationLike = stationState as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+ const saveState = stationLike as unknown as {
+ transactionEventQueueSaveDirty: boolean
+ transactionEventQueueSavePromise?: Promise<unknown>
+ }
+
+ await assert.rejects(
+ ChargingStation.prototype.persistTransactionEventQueues.call(stationLike),
+ saveFailure
+ )
+ await flushMicrotasks()
+ assert.strictEqual(saveAttempts, 1)
+ assert.strictEqual(saveState.transactionEventQueueSaveDirty, true)
+ assert.ok(saveState.transactionEventQueueSavePromise != null)
+
+ t.mock.timers.tick(59_999)
+ await flushMicrotasks()
+ assert.strictEqual(saveAttempts, 1)
+ const pacedRetry = saveState.transactionEventQueueSavePromise
+ t.mock.timers.tick(1)
+ await flushMicrotasks()
+ assert.strictEqual(saveAttempts, 2)
+ await pacedRetry
+ })
+
+ for (const stopTransactions of [false, true]) {
+ await it(`waits for an accepted RequestStopTransaction released during stop(${stopTransactions.toString()})`, async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000025'
+ const predecessorStarted = Promise.withResolvers<undefined>()
+ const releasePredecessor = Promise.withResolvers<undefined>()
+ const responseSendStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ const endedStarted = Promise.withResolvers<undefined>()
+ const releaseEnded = Promise.withResolvers<undefined>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ let responseParams: RequestParams | undefined
+ const requestHandler = mock.fn(
+ async (...args: unknown[]): Promise<Record<string, never>> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return {}
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ sentEventTypes.push(request.eventType)
+ requestParams?.onMessageSent?.()
+ if (request.eventType === OCPP20TransactionEventEnumType.Updated) {
+ predecessorStarted.resolve(undefined)
+ await releasePredecessor.promise
+ } else if (request.eventType === OCPP20TransactionEventEnumType.Ended) {
+ endedStarted.resolve(undefined)
+ await releaseEnded.promise
+ }
+ requestParams?.onResponseReceived?.()
+ return {}
+ }
+ )
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ stationInfo: { enableStatistics: false },
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const requestService = new OCPP20RequestService(new OCPP20ResponseService())
+ mock.method(requestService, 'requestHandler', requestHandler)
+ mock.method(requestService, 'sendResponse', (...args: unknown[]): Promise<void> => {
+ responseParams = args[4] as RequestParams | undefined
+ responseSendStarted.resolve(undefined)
+ return releaseResponse.promise
+ })
+ const stationLifecycle = activeStation as unknown as {
+ lifecycleAbortController: AbortController
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ ocppRequestService: OCPP20RequestService
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stop?: boolean
+ ) => Promise<void>
+ sharedLRUCache: { deleteChargingStationConfiguration: (hash?: string) => void }
+ stopMessageSequence: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stop?: boolean
+ ) => Promise<void>
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(activeStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.ocppRequestService = requestService
+ stationLifecycle.performStop = (
+ ChargingStation.prototype as unknown as {
+ performStop: typeof stationLifecycle.performStop
+ }
+ ).performStop
+ stationLifecycle.sharedLRUCache = { deleteChargingStationConfiguration: () => undefined }
+ stationLifecycle.stopMessageSequence = async (reason, shouldStopTransactions) => {
+ if (shouldStopTransactions === true) await stopRunningTransactions(activeStation, reason)
+ }
+ const admittedLifecycleSignal = activeStation.lifecycleAbortSignal
+ const stopIncomingRequestService = incomingRequestService.stop.bind(incomingRequestService)
+ const responseReleased = Promise.withResolvers<undefined>()
+ let responseReleasedAfterLifecycleReplacement = false
+ mock.method(incomingRequestService, 'stop', (stoppingStation: ChargingStation) => {
+ stopIncomingRequestService(stoppingStation)
+ responseReleasedAfterLifecycleReplacement =
+ admittedLifecycleSignal.aborted &&
+ activeStation.lifecycleAbortSignal !== admittedLifecycleSignal
+ responseParams?.onMessageSent?.()
+ releaseResponse.resolve(undefined)
+ responseReleased.resolve(undefined)
+ })
+ activeStation.isWebSocketConnectionOpened = () => true
+ activeStation.inAcceptedState = () => true
+ activeStation.recordRequestStatistic = () => undefined
+ setupConnectorWithTransaction(activeStation, 1, { transactionId })
+ const predecessor = OCPP20ServiceUtils.sendTransactionEvent(
+ activeStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId,
+ { evseId: 1 }
+ )
+ await predecessorStarted.promise
+ const incomingResponse = incomingRequestService.incomingRequestHandler(
+ activeStation,
+ 'buffered-request-stop-response',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId }
+ )
+ await responseSendStarted.promise
+
+ let stopSettled = false
+ const stopped = ChargingStation.prototype.stop.call(
+ activeStation,
+ undefined,
+ stopTransactions
+ )
+ void stopped.then(() => {
+ stopSettled = true
+ return undefined
+ })
+ await responseReleased.promise
+ assert.strictEqual(responseReleasedAfterLifecycleReplacement, true)
+ assert.strictEqual(stopSettled, false)
+ assert.deepStrictEqual(sentEventTypes, [OCPP20TransactionEventEnumType.Updated])
+
+ releasePredecessor.resolve(undefined)
+ await endedStarted.promise
+ assert.strictEqual(stopSettled, false)
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+
+ releaseEnded.resolve(undefined)
+ await Promise.allSettled([predecessor])
+ await Promise.all([incomingResponse, stopped])
+ assert.strictEqual(stopSettled, true)
+ assert.strictEqual(
+ sentEventTypes.filter(eventType => eventType === OCPP20TransactionEventEnumType.Ended)
+ .length,
+ 1
+ )
+ })
+ }
+
+ await it('should reject RequestStopTransaction after the shutdown termination fixed point', async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000026'
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ const requestHandler = mock.fn((): Promise<Record<string, never>> => Promise.resolve({}))
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler, sendResponse },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ stationInfo: { enableStatistics: false },
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const stationLifecycle = activeStation as unknown as {
+ lifecycleAbortController: AbortController
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ performStop: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stop?: boolean
+ ) => Promise<void>
+ sharedLRUCache: { deleteChargingStationConfiguration: (hash?: string) => void }
+ stopMessageSequence: () => Promise<void>
+ transactionEventQueueSaveDirty: boolean
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.performStop = (
+ ChargingStation.prototype as unknown as {
+ performStop: typeof stationLifecycle.performStop
+ }
+ ).performStop
+ stationLifecycle.sharedLRUCache = { deleteChargingStationConfiguration: () => undefined }
+ stationLifecycle.stopMessageSequence = () => Promise.resolve()
+ stationLifecycle.transactionEventQueueSaveDirty = true
+ activeStation.persistTransactionEventQueues = async () => {
+ persistenceStarted.resolve(undefined)
+ await releasePersistence.promise
+ stationLifecycle.transactionEventQueueSaveDirty = false
+ }
+ setupConnectorWithTransaction(activeStation, 1, { transactionId })
+ const closeConnection = mock.method(activeStation, 'closeWSConnection', () => undefined)
+
+ const stopped = ChargingStation.prototype.stop.call(activeStation, undefined, false)
+ await persistenceStarted.promise
+ assert.strictEqual(closeConnection.mock.callCount(), 0)
+ assert.strictEqual(activeStation.started, true)
+
+ try {
+ await assert.rejects(
+ incomingRequestService.incomingRequestHandler(
+ activeStation,
+ 'late-request-stop',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId }
+ ),
+ /charging station is stopping/
+ )
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ const connectorStatus = activeStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ } finally {
+ releasePersistence.resolve(undefined)
+ await stopped
+ }
+
+ assert.strictEqual(closeConnection.mock.callCount(), 1)
+ })
+
+ await it('should drop a late CALL at the websocket boundary without buffering and accepts it after restart', async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000027'
+ const requestHandler = mock.fn((...args: unknown[]): Promise<Record<string, never>> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const sendError = mock.fn((): Promise<void> => Promise.resolve())
+ const sendResponse = mock.fn((...args: unknown[]): Promise<void> => {
+ const requestParams = args[4] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ return Promise.resolve()
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler, sendError, sendResponse },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const handleIncomingMessage = mock.fn((): Promise<void> => Promise.resolve())
+ const stationLifecycle = activeStation as unknown as {
+ bufferedMessageCallbackCount: number
+ bufferedMessageEntries: unknown[]
+ handleIncomingMessage: (request: unknown) => Promise<void>
+ messageQueue: string[]
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ onMessage: (
+ data: string,
+ sourceConnection?: NonNullable<typeof activeStation.wsConnection>
+ ) => Promise<void>
+ stopping: boolean
+ }
+ stationLifecycle.handleIncomingMessage = handleIncomingMessage
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.onMessage = (
+ ChargingStation.prototype as unknown as {
+ onMessage: typeof stationLifecycle.onMessage
+ }
+ ).onMessage
+ activeStation.isWebSocketConnectionOpened = () => true
+ activeStation.inAcceptedState = () => true
+ activeStation.persistTransactionEventQueues = () => Promise.resolve()
+ activeStation.recordRequestStatistic = () => undefined
+ setupConnectorWithTransaction(activeStation, 1, { transactionId })
+ const request = JSON.stringify([
+ 2,
+ 'late-request-stop-boundary',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId },
+ ])
+
+ const admittedConnection = activeStation.wsConnection
+ assert.ok(admittedConnection != null)
+ stationLifecycle.stopping = true
+ await stationLifecycle.onMessage.call(activeStation, request, admittedConnection)
+ await stationLifecycle.onMessage.call(activeStation, '{', admittedConnection)
+ await stationLifecycle.onMessage.call(activeStation, '{}', admittedConnection)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(sendError.mock.callCount(), 0)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(stationLifecycle.messageQueue.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageEntries.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageCallbackCount, 0)
+
+ stationLifecycle.stopping = false
+ activeStation.wsConnection = {} as NonNullable<typeof activeStation.wsConnection>
+ await stationLifecycle.onMessage.call(activeStation, request, admittedConnection)
+ assert.strictEqual(handleIncomingMessage.mock.callCount(), 0)
+ await stationLifecycle.onMessage.call(activeStation, request, activeStation.wsConnection)
+ await flushMicrotasks()
+ assert.strictEqual(handleIncomingMessage.mock.callCount(), 1)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(sendError.mock.callCount(), 0)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ })
+
+ await it('should route a terminal CALL_RESULT received while stopping', async () => {
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ })
+ const activeStation = result.station
+ station = activeStation
+ const stationLifecycle = activeStation as unknown as {
+ getCachedRequest: (...args: unknown[]) => unknown
+ handleResponseMessage: (response: unknown) => void
+ onMessage: (
+ data: string,
+ sourceConnection?: NonNullable<typeof activeStation.wsConnection>
+ ) => Promise<void>
+ stopping: boolean
+ }
+ const stationPrototype = ChargingStation.prototype as unknown as {
+ getCachedRequest: typeof stationLifecycle.getCachedRequest
+ handleResponseMessage: typeof stationLifecycle.handleResponseMessage
+ onMessage: typeof stationLifecycle.onMessage
+ }
+ stationLifecycle.getCachedRequest = stationPrototype.getCachedRequest
+ stationLifecycle.handleResponseMessage = stationPrototype.handleResponseMessage
+ stationLifecycle.onMessage = stationPrototype.onMessage
+ const messageId = 'terminal-response-during-stop'
+ const responseReceived = Promise.withResolvers<unknown>()
+ activeStation.requests.set(messageId, [
+ response => {
+ activeStation.requests.delete(messageId)
+ responseReceived.resolve(response)
+ },
+ responseReceived.reject,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ {},
+ ])
+ const sourceConnection = activeStation.wsConnection
+ assert.ok(sourceConnection != null)
+ stationLifecycle.stopping = true
+
+ await stationLifecycle.onMessage.call(
+ activeStation,
+ JSON.stringify([MessageType.CALL_RESULT_MESSAGE, messageId, {}]),
+ sourceConnection
+ )
+
+ assert.deepStrictEqual(await responseReceived.promise, {})
+ assert.strictEqual(activeStation.requests.has(messageId), false)
+ })
+
+ await it('should drop an admitted CALL result when its async handler finishes during stop', async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000029'
+ const handlerStarted = Promise.withResolvers<undefined>()
+ const releaseHandler = Promise.withResolvers<undefined>()
+ const sendError = mock.fn((): Promise<void> => Promise.resolve())
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { sendError, sendResponse },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const incomingHandlers = incomingRequestService as unknown as {
+ incomingRequestHandlers: Map<
+ OCPP20IncomingRequestCommand,
+ (...args: unknown[]) => Promise<Record<string, never>>
+ >
+ }
+ incomingHandlers.incomingRequestHandlers.set(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ async () => {
+ handlerStarted.resolve(undefined)
+ await releaseHandler.promise
+ return {}
+ }
+ )
+ const stationLifecycle = activeStation as unknown as {
+ bufferedMessageCallbackCount: number
+ bufferedMessageEntries: unknown[]
+ handleIncomingMessage: (request: unknown) => Promise<void>
+ lifecycleAbortController: AbortController
+ messageQueue: string[]
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ onMessage: (data: string) => Promise<void>
+ performStop: () => Promise<void>
+ stopping: boolean
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(activeStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ stationLifecycle.handleIncomingMessage = (
+ ChargingStation.prototype as unknown as {
+ handleIncomingMessage: typeof stationLifecycle.handleIncomingMessage
+ }
+ ).handleIncomingMessage
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.onMessage = (
+ ChargingStation.prototype as unknown as {
+ onMessage: typeof stationLifecycle.onMessage
+ }
+ ).onMessage
+ activeStation.inAcceptedState = () => true
+ activeStation.recordRequestStatistic = () => undefined
+ const handled = stationLifecycle.onMessage.call(
+ activeStation,
+ JSON.stringify([
+ 2,
+ 'admitted-call-result',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId },
+ ])
+ )
+ await handlerStarted.promise
+
+ const admittedLifecycleSignal = activeStation.lifecycleAbortSignal
+ stationLifecycle.performStop = () => {
+ stationLifecycle.lifecycleAbortController.abort()
+ activeStation.started = false
+ return Promise.resolve()
+ }
+ await ChargingStation.prototype.stop.call(activeStation, undefined, false)
+ activeStation.start()
+ assert.strictEqual(activeStation.started, true)
+ assert.notStrictEqual(activeStation.lifecycleAbortSignal, admittedLifecycleSignal)
+ releaseHandler.resolve(undefined)
+ await handled
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(sendError.mock.callCount(), 0)
+ assert.strictEqual(stationLifecycle.messageQueue.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageEntries.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageCallbackCount, 0)
+ })
+
+ await it('should drop an admitted CALL error when its async handler fails during stop', async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000030'
+ const handlerStarted = Promise.withResolvers<undefined>()
+ const releaseHandler = Promise.withResolvers<undefined>()
+ const sendError = mock.fn((): Promise<void> => Promise.resolve())
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { sendError, sendResponse },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const incomingHandlers = incomingRequestService as unknown as {
+ incomingRequestHandlers: Map<
+ OCPP20IncomingRequestCommand,
+ (...args: unknown[]) => Promise<Record<string, never>>
+ >
+ }
+ incomingHandlers.incomingRequestHandlers.set(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ async () => {
+ handlerStarted.resolve(undefined)
+ await releaseHandler.promise
+ throw new Error('async handler failed during stop')
+ }
+ )
+ const stationLifecycle = activeStation as unknown as {
+ bufferedMessageCallbackCount: number
+ bufferedMessageEntries: unknown[]
+ handleIncomingMessage: (request: unknown) => Promise<void>
+ lifecycleAbortController: AbortController
+ messageQueue: string[]
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ onMessage: (data: string) => Promise<void>
+ performStop: () => Promise<void>
+ stopping: boolean
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(activeStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ stationLifecycle.handleIncomingMessage = (
+ ChargingStation.prototype as unknown as {
+ handleIncomingMessage: typeof stationLifecycle.handleIncomingMessage
+ }
+ ).handleIncomingMessage
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.onMessage = (
+ ChargingStation.prototype as unknown as {
+ onMessage: typeof stationLifecycle.onMessage
+ }
+ ).onMessage
+ activeStation.inAcceptedState = () => true
+ activeStation.recordRequestStatistic = () => undefined
+ const handled = stationLifecycle.onMessage.call(
+ activeStation,
+ JSON.stringify([
+ 2,
+ 'admitted-call-error',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId },
+ ])
+ )
+ await handlerStarted.promise
+
+ const admittedLifecycleSignal = activeStation.lifecycleAbortSignal
+ stationLifecycle.performStop = () => {
+ stationLifecycle.lifecycleAbortController.abort()
+ activeStation.started = false
+ return Promise.resolve()
+ }
+ await ChargingStation.prototype.stop.call(activeStation, undefined, false)
+ activeStation.start()
+ assert.strictEqual(activeStation.started, true)
+ assert.notStrictEqual(activeStation.lifecycleAbortSignal, admittedLifecycleSignal)
+ releaseHandler.resolve(undefined)
+ await handled
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(sendError.mock.callCount(), 0)
+ assert.strictEqual(stationLifecycle.messageQueue.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageEntries.length, 0)
+ assert.strictEqual(stationLifecycle.bufferedMessageCallbackCount, 0)
+ })
+
+ await it('should drop a CALLERROR when its async handler rejects after reconnect', async () => {
+ const transactionId = '00000000-0000-4000-8000-000000000031'
+ const handlerStarted = Promise.withResolvers<undefined>()
+ const releaseHandler = Promise.withResolvers<undefined>()
+ const sendError = mock.fn((): Promise<void> => Promise.resolve())
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { sendError, sendResponse },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ })
+ const activeStation = result.station
+ station = activeStation
+ const incomingRequestService = new OCPP20IncomingRequestService()
+ const incomingHandlers = incomingRequestService as unknown as {
+ incomingRequestHandlers: Map<
+ OCPP20IncomingRequestCommand,
+ (...args: unknown[]) => Promise<Record<string, never>>
+ >
+ }
+ incomingHandlers.incomingRequestHandlers.set(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ async () => {
+ handlerStarted.resolve(undefined)
+ await releaseHandler.promise
+ throw new Error('async handler failed after reconnect')
+ }
+ )
+ const stationLifecycle = activeStation as unknown as {
+ handleIncomingMessage: (request: unknown) => Promise<void>
+ ocppIncomingRequestService: OCPP20IncomingRequestService
+ onMessage: (
+ data: string,
+ sourceConnection?: NonNullable<typeof activeStation.wsConnection>
+ ) => Promise<void>
+ }
+ stationLifecycle.handleIncomingMessage = (
+ ChargingStation.prototype as unknown as {
+ handleIncomingMessage: typeof stationLifecycle.handleIncomingMessage
+ }
+ ).handleIncomingMessage
+ stationLifecycle.ocppIncomingRequestService = incomingRequestService
+ stationLifecycle.onMessage = (
+ ChargingStation.prototype as unknown as {
+ onMessage: typeof stationLifecycle.onMessage
+ }
+ ).onMessage
+ activeStation.inAcceptedState = () => true
+ activeStation.recordRequestStatistic = () => undefined
+ const admittedConnection = activeStation.wsConnection
+ assert.ok(admittedConnection != null)
+ const handled = stationLifecycle.onMessage.call(
+ activeStation,
+ JSON.stringify([
+ 2,
+ 'reconnected-call-error',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ { transactionId },
+ ]),
+ admittedConnection
+ )
+ await handlerStarted.promise
+
+ activeStation.wsConnection = {} as NonNullable<typeof activeStation.wsConnection>
+ releaseHandler.resolve(undefined)
+ await handled
+
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ assert.strictEqual(sendError.mock.callCount(), 0)
+ })
+
+ await it('should persist events queued while transaction delivery settles during stop', async () => {
+ const transactionEventQueue: unknown[] = []
+ const connectorStatus = { transactionEventQueue } as unknown as ConnectorStatus
+ let savedQueueLength = -1
+ let stoppedEventEmitted = false
+ const configurationSave = Promise.withResolvers<undefined>()
+ const waitMock = mock.method(
+ OCPP20ServiceUtils,
+ 'waitForTransactionEventDelivery',
+ (status: ConnectorStatus) => {
+ assert.strictEqual(status, connectorStatus)
+ transactionEventQueue.push({})
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ return Promise.resolve()
+ }
+ )
+ const stationLike = {
+ bootNotificationResponse: {},
+ closeWSConnection: () => undefined,
+ configurationFileHash: 'test-configuration',
+ emitChargingStationEvent: () => {
+ stoppedEventEmitted = true
+ },
+ iterateConnectors: () => [{ connectorStatus }],
+ lifecycleAbortController: new AbortController(),
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ savedQueueLength = transactionEventQueue.length
+ stationLike.pendingConfigurationSave = configurationSave.promise
+ },
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => undefined,
+ stopMessageSequence: () => Promise.resolve(),
+ }
+ installBufferedMessageCallbackState(stationLike)
+
+ try {
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+ await new Promise(resolve => {
+ setImmediate(resolve)
+ })
+ assert.strictEqual(savedQueueLength, 1)
+ configurationSave.resolve(undefined)
+ await stopPromise
+ } finally {
+ waitMock.mock.restore()
+ }
+
+ assert.strictEqual(stoppedEventEmitted, true)
+ })
+
+ await it('should finish a timed-out never-settling stop sequence after cancellation', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const stopSequence = new Promise<undefined>(() => {
+ // Deliberately never settles.
+ })
+ const closeCalls: { byRequest?: boolean }[] = []
+ let cancelCalls = 0
+ let stoppedEvents = 0
+ let stopSettled = false
+ const lifecycleAbortController = new AbortController()
+ const stationLike = {
+ closeWSConnection: (options?: { byRequest?: boolean }) => {
+ closeCalls.push(options ?? {})
+ },
+ configurationFileHash: 'test-configuration',
+ emitChargingStationEvent: () => {
+ stoppedEvents++
+ },
+ iterateConnectors: () => [],
+ lifecycleAbortController,
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: {
+ cancelPendingRequests: () => {
+ cancelCalls++
+ },
+ },
+ saveConfiguration: () => undefined,
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => undefined,
+ stopMessageSequence: () => stopSequence,
+ }
+ installBufferedMessageCallbackState(stationLike)
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+ void stopPromise.then(() => {
+ stopSettled = true
+ return undefined
+ })
+ await Promise.resolve()
+
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) {
+ await Promise.resolve()
+ }
+ assert.strictEqual(stopSettled, true)
+ await stopPromise
+ assert.strictEqual(cancelCalls, 1)
+ assert.deepStrictEqual(closeCalls, [{ byRequest: true }])
+ assert.strictEqual(lifecycleAbortController.signal.aborted, true)
+ assert.strictEqual(stoppedEvents, 1)
+ })
+
+ await it('should tear down the clock-aligned timer when the stop sequence times out before completing', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const stopSequence = new Promise<undefined>(() => {
+ // Never settles: finalizeShutdown times out before stopMessageSequence,
+ // the nominal clock-aligned timer teardown path, ever runs.
+ })
+ let alignedStopCalls = 0
+ const lifecycleAbortController = new AbortController()
+ const stationLike = {
+ closeWSConnection: () => undefined,
+ configurationFileHash: 'aligned-timer-teardown',
+ emitChargingStationEvent: () => undefined,
+ iterateConnectors: () => [],
+ lifecycleAbortController,
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ saveConfiguration: () => undefined,
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => {
+ alignedStopCalls++
+ },
+ stopMessageSequence: () => stopSequence,
+ }
+ installBufferedMessageCallbackState(stationLike)
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+ await Promise.resolve()
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) {
+ await Promise.resolve()
+ }
+ await stopPromise
+ // finalizeTransport must tear down the autonomous clock-aligned timer even
+ // when the stop sequence never reached stopMessageSequence, otherwise the
+ // timer re-arms indefinitely and keeps emitting after the station stopped.
+ assert.strictEqual(alignedStopCalls, 1)
+ })
+
+ await it('should cancel a never-settling termination at the shutdown deadline and allow retry', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const transactionId = '00000000-0000-4000-8000-000000000028'
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const neverSettles = new Promise<void>(() => {
+ // Deliberately never settles.
+ })
+ let blockPersistence = true
+ const requestHandler = mock.fn((...args: unknown[]): Promise<Record<string, never>> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_20,
+ started: true,
+ stationInfo: { enableStatistics: false },
+ })
+ const activeStation = result.station
+ station = activeStation
+ const stationLifecycle = activeStation as unknown as {
+ lifecycleAbortController: AbortController
+ performStop: (reason?: Parameters<ChargingStation['stop']>[0]) => Promise<void>
+ sharedLRUCache: { deleteChargingStationConfiguration: (hash?: string) => void }
+ stopMessageSequence: () => Promise<void>
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ stationLifecycle.performStop = (
+ ChargingStation.prototype as unknown as {
+ performStop: typeof stationLifecycle.performStop
+ }
+ ).performStop
+ stationLifecycle.sharedLRUCache = { deleteChargingStationConfiguration: () => undefined }
+ stationLifecycle.stopMessageSequence = () => Promise.resolve()
+ activeStation.closeWSConnection = () => undefined
+ activeStation.inAcceptedState = () => true
+ activeStation.isWebSocketConnectionOpened = () => true
+ activeStation.persistTransactionEventQueues = () => {
+ if (!blockPersistence) return Promise.resolve()
+ persistenceStarted.resolve(undefined)
+ return neverSettles
+ }
+ setupConnectorWithTransaction(activeStation, 1, { transactionId })
+
+ const firstStop = OCPP20ServiceUtils.requestStopTransaction(
+ activeStation,
+ 1,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const firstStopRejected = assert.rejects(firstStop, /shutdown finalized/)
+ await persistenceStarted.promise
+ const shutdown = stationLifecycle.performStop.call(activeStation)
+ blockPersistence = false
+ for (let index = 0; index < 10; index++) {
+ t.mock.timers.runAll()
+ await Promise.resolve()
+ }
+ await shutdown
+ await firstStopRejected
+
+ const connectorStatus = activeStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue?.some(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ) ?? false,
+ false
+ )
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ const startUpdated = mock.method(
+ OCPP20ServiceUtils,
+ 'startUpdatedMeterValues',
+ () => undefined
+ )
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(activeStation)
+ assert.strictEqual(startUpdated.mock.callCount(), 1)
+ assert.strictEqual(startEnded.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+
+ activeStation.started = true
+ const retry = OCPP20ServiceUtils.requestStopTransaction(
+ activeStation,
+ 1,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ for (let index = 0; index < 10; index++) {
+ t.mock.timers.runAll()
+ await Promise.resolve()
+ }
+ await retry
+ const endedCalls = requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(endedCalls.length, 1)
+ })
+
+ await it('should stop a stale timed-out message sequence before it mutates a restarted lifecycle', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const firstStatusRequest = Promise.withResolvers<Record<string, never>>()
+ const firstStatusRequestStarted = Promise.withResolvers<undefined>()
+ const staleSequenceSettled = Promise.withResolvers<undefined>()
+ const unavailableConnectorIds: number[] = []
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ return Promise.resolve({})
+ }
+ const payload = args[2] as { connectorId: number; status: OCPP16ChargePointStatus }
+ if (payload.status !== OCPP16ChargePointStatus.Unavailable) {
+ return Promise.resolve({})
+ }
+ unavailableConnectorIds.push(payload.connectorId)
+ if (unavailableConnectorIds.length === 1) {
+ firstStatusRequestStarted.resolve(undefined)
+ return firstStatusRequest.promise
+ }
+ return Promise.resolve({})
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 2,
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ started: true,
+ stationInfo: { enableStatistics: false },
+ })
+ station = result.station
+ installBufferedMessageCallbackState(station)
+ const shutdownLifecycle = new AbortController()
+ const stopMessageSequence = (
+ ChargingStation.prototype as unknown as {
+ stopMessageSequence: (
+ reason?: Parameters<ChargingStation['stop']>[0],
+ stopTransactions?: boolean,
+ lifecycleAbortSignal?: AbortSignal
+ ) => Promise<void>
+ }
+ ).stopMessageSequence
+ const stationLifecycle = station as unknown as {
+ configurationFileHash: string
+ internalStopMessageSequence: () => void
+ lifecycleAbortController: AbortController
+ saveConfiguration: () => void
+ sharedLRUCache: { deleteChargingStationConfiguration: (hash: string) => void }
+ stopAlignedMeterValues: () => void
+ stopMessageSequence: typeof stopMessageSequence
+ }
+ stationLifecycle.configurationFileHash = 'stale-stop-sequence'
+ stationLifecycle.internalStopMessageSequence = () => undefined
+ stationLifecycle.lifecycleAbortController = shutdownLifecycle
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ stationLifecycle.saveConfiguration = () => undefined
+ stationLifecycle.sharedLRUCache = { deleteChargingStationConfiguration: () => undefined }
+ stationLifecycle.stopAlignedMeterValues = () => undefined
+ stationLifecycle.stopMessageSequence = async (reason, stopTransactions, lifecycleSignal) => {
+ try {
+ await stopMessageSequence.call(station, reason, stopTransactions, lifecycleSignal)
+ } finally {
+ staleSequenceSettled.resolve(undefined)
+ }
+ }
+
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(station, undefined, false)
+ await firstStatusRequestStarted.promise
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ await stopPromise
+ assert.notStrictEqual(station.lifecycleAbortSignal, shutdownLifecycle.signal)
+
+ firstStatusRequest.resolve({})
+ await staleSequenceSettled.promise
+
+ assert.deepStrictEqual(unavailableConnectorIds, [1])
+ assert.strictEqual(station.getConnectorStatus(1)?.status, OCPP16ChargePointStatus.Available)
+ assert.strictEqual(station.getConnectorStatus(2)?.status, OCPP16ChargePointStatus.Available)
+ })
+
+ await it('should flush a dirty queue before sealing a timed-out delivery chain', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const transactionEventQueue: unknown[] = []
+ const connectorStatus = { transactionEventQueue } as unknown as ConnectorStatus
+ let savedQueueLength = -1
+ const waitMock = mock.method(OCPP20ServiceUtils, 'waitForTransactionEventDelivery', () => {
+ transactionEventQueue.push({})
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ return new Promise<void>(() => undefined)
+ })
+ let stopped = false
+ const stationLike = {
+ closeWSConnection: () => undefined,
+ configurationFileHash: 'test-configuration',
+ emitChargingStationEvent: () => {
+ stopped = true
+ },
+ iterateConnectors: () => [{ connectorStatus }],
+ lifecycleAbortController: new AbortController(),
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ savedQueueLength = transactionEventQueue.length
+ },
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => undefined,
+ stopMessageSequence: () => Promise.resolve(),
+ }
+ installBufferedMessageCallbackState(stationLike)
+
+ try {
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ await stopPromise
+ } finally {
+ waitMock.mock.restore()
+ }
+ assert.strictEqual(stopped, true)
+ assert.strictEqual(savedQueueLength, 1)
+ })
+
+ await it('should bound shutdown when final queue persistence never settles', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const waitMock = mock.method(OCPP20ServiceUtils, 'waitForTransactionEventDelivery', () =>
+ Promise.resolve()
+ )
+ let stopped = false
+ const stationLike = {
+ closeWSConnection: () => undefined,
+ configurationFileHash: 'test-configuration',
+ emitChargingStationEvent: () => {
+ stopped = true
+ },
+ iterateConnectors: () => [],
+ lifecycleAbortController: new AbortController(),
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => undefined,
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => undefined,
+ stopMessageSequence: () => Promise.resolve(),
+ transactionEventQueueSavePromise: new Promise<void>(() => undefined),
+ }
+ installBufferedMessageCallbackState(stationLike)
+
+ try {
+ const stopPromise = (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ t.mock.timers.tick(Constants.STOP_MESSAGE_SEQUENCE_TIMEOUT_MS)
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ await stopPromise
+ } finally {
+ waitMock.mock.restore()
+ }
+ assert.strictEqual(stopped, true)
+ })
+
await it('should clear bootNotificationResponse on stop()', async () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
// Assert - the real ChargingStation guards against this
// (mock implementation doesn't fully replicate guard, but state is verified)
})
+
+ await it('should defer paced transaction queue checkpoints to one save per minute', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const saveConfiguration = mock.fn()
+ const stationLike = {
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration,
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+ const saveState = stationLike as unknown as {
+ transactionEventQueueSavePromise?: Promise<void>
+ }
+
+ for (let index = 0; index < 100; index++) {
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ }
+ const savePromise = saveState.transactionEventQueueSavePromise
+
+ t.mock.timers.tick(59_999)
+ await Promise.resolve()
+ assert.strictEqual(saveConfiguration.mock.callCount(), 0)
+
+ t.mock.timers.tick(1)
+ await savePromise
+ assert.strictEqual(saveConfiguration.mock.callCount(), 1)
+ })
+
+ await it('should flush the latest deferred queue state when an immediate checkpoint is requested', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ let queueLength = 1
+ const savedQueueLengths: number[] = []
+ const stationLike = {
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ savedQueueLengths.push(queueLength)
+ },
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+ const saveState = stationLike as unknown as {
+ transactionEventQueueSavePromise?: Promise<void>
+ }
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ queueLength = 2
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ assert.deepStrictEqual(savedQueueLengths, [])
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike)
+ await saveState.transactionEventQueueSavePromise
+ assert.deepStrictEqual(savedQueueLengths, [2])
+ })
+
+ await it('should persist a dirty queue with the current lifecycle generation', async () => {
+ const staleLifecycle = new AbortController()
+ const currentLifecycle = new AbortController()
+ const savedGenerations: AbortSignal[] = []
+ const stationLike = {
+ lifecycleAbortSignal: staleLifecycle.signal,
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ savedGenerations.push(stationLike.lifecycleAbortSignal)
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, deferred)
+ },
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ ;(stationLike as unknown as { lifecycleAbortSignal: AbortSignal }).lifecycleAbortSignal =
+ currentLifecycle.signal
+
+ await ChargingStation.prototype.persistTransactionEventQueues.call(stationLike)
+ assert.deepStrictEqual(savedGenerations, [currentLifecycle.signal])
+ })
+
+ await it('should persist a queue save superseded by shutdown with the current generation', async () => {
+ const staleLifecycle = new AbortController()
+ const shutdownLifecycle = new AbortController()
+ const staleSave = Promise.withResolvers<undefined>()
+ const savedSnapshots: { generation: AbortSignal; queueLength: number }[] = []
+ let queueLength = 1
+ let saveAttempts = 0
+ const stationLike = {
+ lifecycleAbortSignal: staleLifecycle.signal,
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ saveAttempts++
+ savedSnapshots.push({
+ generation: stationLike.lifecycleAbortSignal,
+ queueLength,
+ })
+ ;(
+ stationLike as unknown as { pendingConfigurationSave: Promise<void> }
+ ).pendingConfigurationSave = saveAttempts === 1 ? staleSave.promise : Promise.resolve()
+ },
+ saveTransactionEventQueues: (deferred?: boolean) => {
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, deferred)
+ },
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+ const saveState = stationLike as unknown as {
+ lifecycleAbortSignal: AbortSignal
+ transactionEventQueueSavePromise?: Promise<{ error?: Error }>
+ }
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike)
+ assert.deepStrictEqual(savedSnapshots, [
+ { generation: staleLifecycle.signal, queueLength: 1 },
+ ])
+
+ saveState.lifecycleAbortSignal = shutdownLifecycle.signal
+ queueLength = 2
+ const supersededPersistence =
+ ChargingStation.prototype.persistTransactionEventQueues.call(stationLike)
+ staleSave.resolve(undefined)
+
+ await supersededPersistence
+ assert.deepStrictEqual(savedSnapshots, [
+ { generation: staleLifecycle.signal, queueLength: 1 },
+ { generation: shutdownLifecycle.signal, queueLength: 2 },
+ ])
+ })
+
+ await it('should force a deferred transaction queue checkpoint before stop completes', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const transactionEventQueue: unknown[] = [{}]
+ const savedQueueLengths: number[] = []
+ const stationLike = {
+ bootNotificationResponse: {},
+ closeWSConnection: () => undefined,
+ configurationFileHash: 'test-configuration',
+ emitChargingStationEvent: () => undefined,
+ iterateConnectors: () => [],
+ lifecycleAbortController: new AbortController(),
+ logPrefix: () => '',
+ ocppIncomingRequestService: { stop: () => undefined },
+ ocppRequestService: { cancelPendingRequests: () => undefined },
+ pendingConfigurationSave: Promise.resolve(),
+ saveConfiguration: () => {
+ savedQueueLengths.push(transactionEventQueue.length)
+ },
+ sharedLRUCache: { deleteChargingStationConfiguration: () => undefined },
+ started: true,
+ stationInfo: { enableStatistics: false },
+ stopAlignedMeterValues: () => undefined,
+ stopMessageSequence: () => {
+ assert.deepStrictEqual(savedQueueLengths, [2])
+ return Promise.resolve()
+ },
+ } as unknown as ChargingStation
+ installBufferedMessageCallbackState(stationLike)
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ transactionEventQueue.push({})
+ ChargingStation.prototype.saveTransactionEventQueues.call(stationLike, true)
+ assert.deepStrictEqual(savedQueueLengths, [])
+
+ await (
+ ChargingStation.prototype as unknown as {
+ performStop: (reason?: unknown, stopTransactions?: boolean) => Promise<void>
+ }
+ ).performStop.call(stationLike)
+
+ assert.deepStrictEqual(savedQueueLengths, [2])
+ })
})
await describe('Delete Operations', async () => {
}
})
+ await it('should flush a deferred transaction queue checkpoint before delete(false)', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ let queueLength = 1
+ const savedQueueLengths: number[] = []
+ ;(station as unknown as { saveConfiguration: () => void }).saveConfiguration = () => {
+ savedQueueLengths.push(queueLength)
+ }
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(station, true)
+ queueLength = 2
+ ChargingStation.prototype.saveTransactionEventQueues.call(station, true)
+ assert.deepStrictEqual(savedQueueLengths, [])
+
+ await ChargingStation.prototype.delete.call(station, false)
+
+ assert.deepStrictEqual(savedQueueLengths, [2])
+ })
+
+ await it('should not retry a failed final queue checkpoint after delete(false)', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const failure = new Error('transient configuration save failure')
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ station.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(station)
+ const savedConnectorCounts: number[] = []
+ ;(
+ station as unknown as {
+ saveConfiguration: (onError?: (error: Error) => void) => void
+ }
+ ).saveConfiguration = onError => {
+ savedConnectorCounts.push(station?.getNumberOfConnectors() ?? 0)
+ onError?.(failure)
+ }
+
+ station.saveTransactionEventQueues(true)
+ await assert.rejects(
+ ChargingStation.prototype.delete.call(station, false),
+ error => error === failure
+ )
+ assert.deepStrictEqual(savedConnectorCounts, [1])
+
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(savedConnectorCounts, [1])
+ })
+
+ await it('should persist the final queue snapshot after delete(false) cancels reset', async t => {
+ const directory = mkdtempSync(join(tmpdir(), 'charging-station-delete-persist-'))
+ t.after(() => {
+ rmSync(directory, { force: true, recursive: true })
+ })
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ Object.setPrototypeOf(station, ChargingStation.prototype)
+ ;(
+ station as unknown as {
+ getAutomaticTransactionGeneratorConfiguration: () => undefined
+ getConfigurationFromFile: () => Record<string, never>
+ sharedLRUCache: {
+ deleteChargingStationConfiguration: () => void
+ setChargingStationConfiguration: () => void
+ }
+ }
+ ).getAutomaticTransactionGeneratorConfiguration = () => undefined
+ ;(
+ station as unknown as { getConfigurationFromFile: () => Record<string, never> }
+ ).getConfigurationFromFile = () => ({})
+ ;(
+ station as unknown as {
+ sharedLRUCache: {
+ deleteChargingStationConfiguration: () => void
+ setChargingStationConfiguration: () => void
+ }
+ }
+ ).sharedLRUCache = {
+ deleteChargingStationConfiguration: () => undefined,
+ setChargingStationConfiguration: () => undefined,
+ }
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ const configurationFile = join(directory, 'station.json')
+ writeFileSync(configurationFile, '{}', 'utf8')
+ ;(station as unknown as { configurationFile: string }).configurationFile = configurationFile
+ ;(station as unknown as { configurationFileHash: string }).configurationFileHash = ''
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(station, true)
+ await ChargingStation.prototype.delete.call(station, false)
+
+ assert.strictEqual(existsSync(configurationFile), true)
+ const persisted = JSON.parse(readFileSync(configurationFile, 'utf8')) as {
+ connectorsStatus?: unknown
+ }
+ assert.notStrictEqual(persisted.connectorsStatus, undefined)
+ })
+
+ await it('should discard a deferred transaction queue checkpoint with delete(true)', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(station as unknown as { configurationFile: string }).configurationFile = join(
+ tmpdir(),
+ 'discarded-station-configuration.json'
+ )
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ const saveConfiguration = mock.fn()
+ ;(station as unknown as { saveConfiguration: () => void }).saveConfiguration =
+ saveConfiguration
+
+ ChargingStation.prototype.saveTransactionEventQueues.call(station, true)
+ await ChargingStation.prototype.delete.call(station, true)
+
+ assert.strictEqual(saveConfiguration.mock.callCount(), 0)
+ await assert.rejects(
+ ChargingStation.prototype.persistTransactionEventQueues.call(station),
+ /persistence was discarded/
+ )
+ })
+
+ await it('should complete cleanup and purge when delete(true) discards a joined delete(false) save', async t => {
+ const directory = mkdtempSync(join(tmpdir(), 'charging-station-delete-escalation-'))
+ t.after(() => {
+ rmSync(directory, { force: true, recursive: true })
+ })
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const configurationFile = join(directory, 'station.json')
+ writeFileSync(configurationFile, '{}', 'utf8')
+ const saveStarted = Promise.withResolvers<undefined>()
+ const releaseSave = Promise.withResolvers<undefined>()
+ ;(station as unknown as { configurationFile: string }).configurationFile = configurationFile
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ station.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(station)
+ ;(
+ station as unknown as {
+ saveConfiguration: () => void
+ }
+ ).saveConfiguration = () => {
+ saveStarted.resolve(undefined)
+ ;(
+ station as unknown as {
+ pendingConfigurationSave?: Promise<void>
+ }
+ ).pendingConfigurationSave = releaseSave.promise
+ }
+
+ const persistentDelete = ChargingStation.prototype.delete.call(station, false)
+ await saveStarted.promise
+ const destructiveDelete = ChargingStation.prototype.delete.call(station, true)
+ releaseSave.resolve(undefined)
+ await Promise.all([persistentDelete, destructiveDelete])
+
+ assert.strictEqual(station.getNumberOfConnectors(), 0)
+ assert.strictEqual(station.getNumberOfEvses(), 0)
+ assert.strictEqual(existsSync(configurationFile), false)
+ })
+
+ await it('should surface an unrelated joined persistence failure after cleanup and purge', async t => {
+ const directory = mkdtempSync(join(tmpdir(), 'charging-station-delete-failure-'))
+ t.after(() => {
+ rmSync(directory, { force: true, recursive: true })
+ })
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const configurationFile = join(directory, 'station.json')
+ writeFileSync(configurationFile, '{}', 'utf8')
+ const failure = new Error('durable storage failed')
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ ;(station as unknown as { configurationFile: string }).configurationFile = configurationFile
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ station.persistTransactionEventQueues = async () => {
+ persistenceStarted.resolve(undefined)
+ await releasePersistence.promise
+ throw failure
+ }
+
+ const persistentDelete = ChargingStation.prototype.delete.call(station, false)
+ await persistenceStarted.promise
+ const destructiveDelete = ChargingStation.prototype.delete.call(station, true)
+ releasePersistence.resolve(undefined)
+ const results = await Promise.allSettled([persistentDelete, destructiveDelete])
+
+ assert.ok(results.every(result => result.status === 'rejected' && result.reason === failure))
+ assert.strictEqual(station.getNumberOfConnectors(), 0)
+ assert.strictEqual(station.getNumberOfEvses(), 0)
+ assert.strictEqual(existsSync(configurationFile), false)
+ })
+
await it('should handle delete() on stopped station', async () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 2 })
assert.strictEqual(station.requests.size, 0)
})
+ await it('should honor a later configuration purge after delete cleanup completed', async () => {
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const directory = mkdtempSync(join(tmpdir(), 'charging-station-delete-'))
+ const configurationFile = join(directory, 'station.json')
+ writeFileSync(configurationFile, '{}', 'utf8')
+ ;(station as unknown as { configurationFile: string }).configurationFile = configurationFile
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+
+ try {
+ await ChargingStation.prototype.delete.call(station, false)
+ assert.strictEqual(existsSync(configurationFile), true)
+
+ await ChargingStation.prototype.delete.call(station, true)
+ assert.strictEqual(existsSync(configurationFile), false)
+ await assert.rejects(
+ ChargingStation.prototype.persistTransactionEventQueues.call(station),
+ /persistence was discarded/
+ )
+ } finally {
+ rmSync(directory, { force: true, recursive: true })
+ }
+ })
+
await it('should stop station before delete() if running', async () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 1 })
assert.strictEqual(station.getNumberOfConnectors(), 0)
})
+ await it('should stop before discarding pending requests during delete', async () => {
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ station.started = true
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ const cleanupOrder: string[] = []
+ mock.method(station.ocppRequestService, 'cancelPendingRequests', () => {
+ cleanupOrder.push('cancel')
+ })
+ mock.method(station, 'stop', () => {
+ cleanupOrder.push('stop')
+ ;(result.station as unknown as { stopping: boolean }).stopping = true
+ result.station.started = false
+ return Promise.resolve()
+ })
+
+ await ChargingStation.prototype.delete.call(station, false)
+
+ assert.deepStrictEqual(cleanupOrder, ['stop', 'cancel'])
+ })
+
+ await it('should join an active OCPP 1.6 StopTransaction before delete cleanup', async () => {
+ const statusResponse = Promise.withResolvers<unknown>()
+ const stopResponse = Promise.withResolvers<unknown>()
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ let statusRequestPending = false
+ let stopRequestPending = false
+ let cancellationDuringLifecycleRequest = false
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ const command = args[1]
+ if (command === 'StatusNotification') {
+ statusRequestPending = true
+ statusRequestStarted.resolve(undefined)
+ try {
+ return await statusResponse.promise
+ } finally {
+ statusRequestPending = false
+ }
+ }
+ if (command === 'StopTransaction') {
+ stopRequestPending = true
+ stopRequestStarted.resolve(undefined)
+ try {
+ return await stopResponse.promise
+ } finally {
+ stopRequestPending = false
+ }
+ }
+ return {}
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ station = result.station
+ station.started = true
+ setupConnectorWithTransaction(station, 1, { transactionId: 101 })
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ let cancelCalls = 0
+ mock.method(station.ocppRequestService, 'cancelPendingRequests', () => {
+ cancelCalls++
+ if (statusRequestPending) {
+ cancellationDuringLifecycleRequest = true
+ statusResponse.reject(new Error('StatusNotification cancelled by delete'))
+ }
+ if (stopRequestPending) {
+ cancellationDuringLifecycleRequest = true
+ stopResponse.reject(new Error('StopTransaction cancelled by delete'))
+ }
+ })
+ const activeStop = (async () => {
+ await stopRunningTransactions(result.station)
+ result.station.started = false
+ })()
+ ;(station as unknown as { stopPromise?: Promise<void> }).stopPromise = activeStop
+ const stopMock = mock.method(station, 'stop', async () => {
+ await activeStop
+ })
+
+ await statusRequestStarted.promise
+ assert.strictEqual(statusRequestPending, true)
+ statusResponse.resolve({})
+ await stopRequestStarted.promise
+ const deletePromise = ChargingStation.prototype.delete.call(station, false)
+ await Promise.resolve()
+
+ assert.strictEqual(cancelCalls, 0)
+ assert.strictEqual(stopRequestPending, true)
+ stopResponse.resolve({ idTagInfo: { status: 'Accepted' } })
+ await Promise.all([activeStop, deletePromise])
+
+ assert.strictEqual(cancellationDuringLifecycleRequest, false)
+ assert.strictEqual(cancelCalls, 1)
+ assert.strictEqual(stopMock.mock.callCount(), 1)
+ assert.deepStrictEqual(
+ requestHandler.mock.calls.map(call => call.arguments[1]),
+ ['StatusNotification', 'StopTransaction']
+ )
+ })
+
+ await it('should coalesce concurrent deletes while OCPP 2.0 Ended is pending', async () => {
+ const transactionEventResponse = Promise.withResolvers<unknown>()
+ const transactionEventStarted = Promise.withResolvers<undefined>()
+ let transactionEventPending = false
+ let cancellationDuringTransactionEvent = false
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ const command = args[1]
+ if (command === 'TransactionEvent') {
+ transactionEventPending = true
+ transactionEventStarted.resolve(undefined)
+ try {
+ return await transactionEventResponse.promise
+ } finally {
+ transactionEventPending = false
+ }
+ }
+ return {}
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+ station = result.station
+ station.started = true
+ setupConnectorWithTransaction(station, 1, { transactionId: 'tx-delete-1' })
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ let cancelCalls = 0
+ mock.method(station.ocppRequestService, 'cancelPendingRequests', () => {
+ cancelCalls++
+ if (transactionEventPending) {
+ cancellationDuringTransactionEvent = true
+ transactionEventResponse.reject(new Error('TransactionEvent cancelled by delete'))
+ }
+ })
+ const stopMock = mock.method(station, 'stop', async () => {
+ await stopRunningTransactions(result.station)
+ result.station.started = false
+ })
+
+ const firstDeletePromise = ChargingStation.prototype.delete.call(station, false)
+ await transactionEventStarted.promise
+ const secondDeletePromise = ChargingStation.prototype.delete.call(station, false)
+
+ assert.strictEqual(firstDeletePromise, secondDeletePromise)
+ assert.strictEqual(cancelCalls, 0)
+ assert.strictEqual(transactionEventPending, true)
+ transactionEventResponse.resolve({ idTokenInfo: { status: 'Accepted' } })
+ await Promise.all([firstDeletePromise, secondDeletePromise])
+
+ assert.strictEqual(cancellationDuringTransactionEvent, false)
+ assert.strictEqual(cancelCalls, 1)
+ assert.strictEqual(stopMock.mock.callCount(), 1)
+ const transactionEventCall = requestHandler.mock.calls.find(
+ call => call.arguments[1] === 'TransactionEvent'
+ )
+ assert.strictEqual(
+ (transactionEventCall?.arguments[2] as { eventType?: string }).eventType,
+ 'Ended'
+ )
+ })
+
+ await it('should persist and send graceful Ended before delete(true) fences persistence', async () => {
+ const transactionEventResponse = Promise.withResolvers<unknown>()
+ const transactionEventStarted = Promise.withResolvers<undefined>()
+ const persistedEventTypes: string[][] = []
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === 'TransactionEvent') {
+ transactionEventStarted.resolve(undefined)
+ return await transactionEventResponse.promise
+ }
+ return {}
+ })
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+ station = result.station
+ station.started = true
+ setupConnectorWithTransaction(station, 1, { transactionId: 'tx-delete-durable-ended' })
+ ;(station as unknown as { deleteAbortController: AbortController }).deleteAbortController =
+ new AbortController()
+ ;(
+ station as unknown as {
+ chargingStationWorkerBroadcastChannel: { unref: () => void }
+ }
+ ).chargingStationWorkerBroadcastChannel = { unref: () => undefined }
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ station.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(station)
+ ;(station as unknown as { saveConfiguration: () => void }).saveConfiguration = () => {
+ const connectorStatus = station?.getConnectorStatus(1, 1)
+ persistedEventTypes.push(
+ connectorStatus?.transactionEventQueue?.map(({ request }) => request.eventType) ?? []
+ )
+ ;(
+ station as unknown as {
+ pendingConfigurationSave: Promise<void>
+ }
+ ).pendingConfigurationSave = Promise.resolve()
+ }
+ mock.method(station, 'stop', async () => {
+ await stopRunningTransactions(result.station)
+ result.station.started = false
+ })
+
+ const deletion = ChargingStation.prototype.delete.call(station, true)
+ const firstCompletion = await Promise.race([
+ transactionEventStarted.promise.then(() => 'started'),
+ deletion.then(() => 'deleted'),
+ ])
+ assert.strictEqual(firstCompletion, 'started')
+ assert.ok(persistedEventTypes.some(eventTypes => eventTypes.includes('Ended')))
+
+ transactionEventResponse.resolve({ idTokenInfo: { status: 'Accepted' } })
+ await deletion
+
+ const transactionEventCall = requestHandler.mock.calls.find(
+ call => call.arguments[1] === 'TransactionEvent'
+ )
+ assert.strictEqual(
+ (transactionEventCall?.arguments[2] as { eventType?: string }).eventType,
+ 'Ended'
+ )
+ })
+
+ await it('should ignore persisted EVSEs absent from the current template', () => {
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ })
+ station = result.station
+ const initializeFromFile = (
+ ChargingStation.prototype as unknown as {
+ initializeConnectorsOrEvsesFromFile: (
+ configuration: unknown,
+ stationTemplate: unknown
+ ) => void
+ }
+ ).initializeConnectorsOrEvsesFromFile
+
+ assert.doesNotThrow(() => {
+ initializeFromFile.call(
+ station,
+ {
+ evsesStatus: [
+ [
+ 99,
+ {
+ availability: 'Operative',
+ connectorsStatus: [],
+ },
+ ],
+ ],
+ },
+ { Evses: {} }
+ )
+ })
+ assert.strictEqual(station.getEvseStatus(99), undefined)
+ })
+
+ await it('should preserve live connector transactions during template reload', () => {
+ const result = createMockChargingStation({ connectorsCount: 1 })
+ station = result.station
+ const liveConnector = station.getConnectorStatus(1)
+ assert.ok(liveConnector != null)
+ liveConnector.transactionId = 42
+ liveConnector.transactionStarted = true
+ station.started = true
+ const initializeFromFile = (
+ ChargingStation.prototype as unknown as {
+ initializeConnectorsOrEvsesFromFile: (
+ configuration: unknown,
+ stationTemplate: unknown,
+ persistentConfiguration?: boolean,
+ restorePersistedTransactions?: boolean
+ ) => void
+ }
+ ).initializeConnectorsOrEvsesFromFile
+
+ initializeFromFile.call(
+ station,
+ {
+ connectorsStatus: [
+ [
+ 1,
+ {
+ availability: 'Operative',
+ transactionId: 7,
+ transactionStarted: true,
+ },
+ ],
+ ],
+ },
+ { Connectors: {} },
+ undefined,
+ false
+ )
+
+ assert.strictEqual(station.getConnectorStatus(1), liveConnector)
+ assert.strictEqual(liveConnector.transactionId, 42)
+ assert.strictEqual(liveConnector.transactionRestored, undefined)
+ })
+
+ await it('should remove persisted EVSE meter templates absent from the current template', () => {
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ })
+ station = result.station
+ const initializeFromFile = (
+ ChargingStation.prototype as unknown as {
+ initializeConnectorsOrEvsesFromFile: (
+ configuration: unknown,
+ stationTemplate: unknown
+ ) => void
+ }
+ ).initializeConnectorsOrEvsesFromFile
+
+ initializeFromFile.call(
+ station,
+ {
+ evsesStatus: [
+ [
+ 1,
+ {
+ availability: 'Operative',
+ connectorsStatus: [],
+ MeterValues: [{ measurand: 'Power.Active.Import', unit: 'W', value: '1000' }],
+ },
+ ],
+ ],
+ },
+ { Evses: { 1: {} } }
+ )
+
+ assert.deepStrictEqual(station.getEvseStatus(1)?.MeterValues, [])
+ })
+
await it('should handle delete operation with pending transactions', async () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 2 })
import type { ChargingStation } from '../../src/charging-station/ChargingStation.js'
-import { WebSocketCloseEventStatusCode } from '../../src/types/index.js'
-import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+import {
+ ChargingStationEvents,
+ OCPP20RequestCommand,
+ WebSocketCloseEventStatusCode,
+} from '../../src/types/index.js'
+import { logger } from '../../src/utils/index.js'
+import {
+ flushMicrotasks,
+ standardCleanup,
+ withMockTimers,
+} from '../helpers/TestLifecycleHelpers.js'
import {
cleanupStationTemplates,
copyStationTemplate,
createStationFromTemplate,
} from './helpers/StationHelpers.realStation.js'
-// onClose and reconnect are private; the tests drive onClose directly with a
-// spied reconnect to observe the reconnect decision without opening a socket.
interface StationInternals {
- onClose: (code: WebSocketCloseEventStatusCode, reason: Buffer) => void
+ acknowledgedBufferedMessages: Set<string>
+ getReconnectDelay: () => number
+ initialize: () => void
+ messageQueue: string[]
+ onClose: (wsConnection: WebSocket, code: WebSocketCloseEventStatusCode, reason: Buffer) => void
+ onError: (wsConnection: WebSocket, error: Error) => void
+ openWSConnection: () => void
reconnect: () => Promise<void>
+ start: () => void
started: boolean
- wsConnection: unknown
+ stop: () => Promise<void>
+ stopping: boolean
+ wsConnection: null | WebSocket
+ wsPingSetInterval?: NodeJS.Timeout
+}
+
+const createOpenSocket = (): WebSocket => {
+ const socket: { close: () => void; readyState: number } = {
+ close: () => {
+ socket.readyState = WebSocket.CLOSING
+ },
+ readyState: WebSocket.OPEN,
+ }
+ return socket as unknown as WebSocket
}
-// Build a started station whose reconnect() is replaced by a counter.
-const makeStation = (): { reconnectCount: () => number; station: ChargingStation } => {
+const makeStation = (): {
+ reconnectCount: () => number
+ socket: WebSocket
+ station: ChargingStation
+} => {
const station = createStationFromTemplate(copyStationTemplate())
+ const socket = createOpenSocket()
let reconnects = 0
const internals = station as unknown as StationInternals
internals.reconnect = () => {
return Promise.resolve()
}
internals.started = true
- return { reconnectCount: () => reconnects, station }
+ internals.wsConnection = socket
+ return { reconnectCount: () => reconnects, socket, station }
}
await describe('ChargingStation reconnect decision on WebSocket close', async () => {
})
await it('should reconnect after a server-initiated normal close while started', () => {
- const { reconnectCount, station } = makeStation()
+ const { reconnectCount, socket, station } = makeStation()
;(station as unknown as StationInternals).onClose(
+ socket,
WebSocketCloseEventStatusCode.CLOSE_NORMAL,
Buffer.from('')
)
assert.strictEqual(reconnectCount(), 1)
+ assert.strictEqual(station.wsConnection, null)
+ })
+
+ await it('should restore acknowledged buffered calls when their socket closes', () => {
+ const { socket, station } = makeStation()
+ const internals = station as unknown as StationInternals
+ const message = '[2,"replay-after-close","Heartbeat",{}]'
+ let suspendedTimeouts = 0
+ internals.acknowledgedBufferedMessages.add(message)
+ station.requests.set('replay-after-close', [
+ () => undefined,
+ () => undefined,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ undefined,
+ undefined,
+ () => {
+ suspendedTimeouts++
+ },
+ ])
+
+ internals.onClose(socket, WebSocketCloseEventStatusCode.CLOSE_NORMAL, Buffer.from(''))
+
+ assert.deepStrictEqual(internals.messageQueue, [message])
+ assert.strictEqual(internals.acknowledgedBufferedMessages.size, 0)
+ assert.strictEqual(suspendedTimeouts, 1)
})
await it('should stay disconnected after a requested close', () => {
- const { reconnectCount, station } = makeStation()
+ const { reconnectCount, socket, station } = makeStation()
const internals = station as unknown as StationInternals
- // An open socket lets closeWSConnection record the request before onClose runs.
- internals.wsConnection = { close: () => undefined, readyState: WebSocket.OPEN }
station.closeWSConnection({ byRequest: true })
- internals.onClose(WebSocketCloseEventStatusCode.CLOSE_NORMAL, Buffer.from(''))
+ internals.onClose(socket, WebSocketCloseEventStatusCode.CLOSE_NORMAL, Buffer.from(''))
assert.strictEqual(reconnectCount(), 0)
})
+
+ await it('should not reconnect when the socket closes while stopping', () => {
+ const { reconnectCount, socket, station } = makeStation()
+ const internals = station as unknown as StationInternals
+ internals.stopping = true
+
+ internals.onClose(socket, WebSocketCloseEventStatusCode.CLOSE_NORMAL, Buffer.from(''))
+
+ assert.strictEqual(reconnectCount(), 0)
+ })
+
+ await it('should ignore an error emitted by a replaced socket', t => {
+ const { socket: oldSocket, station } = makeStation()
+ const internals = station as unknown as StationInternals
+ const currentSocket = createOpenSocket()
+ internals.wsConnection = currentSocket
+ const errorSpy = t.mock.method(logger, 'error', () => undefined)
+
+ internals.onError(oldSocket, new Error('stale socket error'))
+ assert.strictEqual(errorSpy.mock.callCount(), 0)
+
+ internals.onError(currentSocket, new Error('current socket error'))
+ assert.strictEqual(errorSpy.mock.callCount(), 1)
+ })
+
+ await it('should ignore a delayed requested close from before a zero-time reset', async t => {
+ await withMockTimers(t, ['setInterval', 'setTimeout'], async () => {
+ const { reconnectCount, socket: oldSocket, station } = makeStation()
+ const internals = station as unknown as StationInternals
+ const replacementSocket = createOpenSocket()
+ let automaticTransactionGeneratorStarted = true
+ station.automaticTransactionGenerator = {
+ get started () {
+ return automaticTransactionGeneratorStarted
+ },
+ stop: () => {
+ automaticTransactionGeneratorStarted = false
+ },
+ } as unknown as NonNullable<ChargingStation['automaticTransactionGenerator']>
+ station.heartbeatSetInterval = setInterval(() => undefined, 1_000)
+ internals.wsPingSetInterval = setInterval(() => undefined, 1_000)
+ const heartbeatSetInterval = station.heartbeatSetInterval
+ const wsPingSetInterval = internals.wsPingSetInterval
+ let disconnectedEvents = 0
+ station.on(ChargingStationEvents.disconnected, () => {
+ disconnectedEvents++
+ })
+ if (station.stationInfo != null) {
+ station.stationInfo.resetTime = 0
+ }
+ t.mock.method(internals, 'stop', () => {
+ station.closeWSConnection({ byRequest: true })
+ station.started = false
+ return Promise.resolve()
+ })
+ t.mock.method(internals, 'initialize', () => undefined)
+ t.mock.method(internals, 'start', () => {
+ internals.wsConnection = replacementSocket
+ station.started = true
+ })
+
+ const resetPromise = station.reset()
+ await flushMicrotasks()
+ t.mock.timers.tick(0)
+ await resetPromise
+
+ // Requesting a close on the replacement before the old close arrives proves
+ // the old event cannot consume the replacement socket's close intent.
+ station.closeWSConnection({ byRequest: true })
+ internals.onClose(
+ oldSocket,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('delayed old close')
+ )
+
+ assert.strictEqual(station.wsConnection, replacementSocket)
+ assert.strictEqual(station.heartbeatSetInterval, heartbeatSetInterval)
+ assert.strictEqual(internals.wsPingSetInterval, wsPingSetInterval)
+ assert.strictEqual(automaticTransactionGeneratorStarted, true)
+ assert.strictEqual(disconnectedEvents, 0)
+ assert.strictEqual(reconnectCount(), 0)
+
+ internals.onClose(
+ replacementSocket,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('requested current close')
+ )
+ assert.strictEqual(reconnectCount(), 0)
+ assert.strictEqual(disconnectedEvents, 1)
+ assert.strictEqual(automaticTransactionGeneratorStarted, false)
+ })
+ })
+
+ await it('should not open a socket when stop begins during the reconnect delay', async t => {
+ const station = createStationFromTemplate(copyStationTemplate())
+ const internals = station as unknown as StationInternals
+ internals.started = true
+ let openCalls = 0
+ internals.getReconnectDelay = () => 1000
+ internals.openWSConnection = () => {
+ openCalls++
+ }
+ if (station.stationInfo != null) {
+ station.stationInfo.autoReconnectMaxRetries = -1
+ }
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const reconnectPromise = internals.reconnect()
+ internals.stopping = true
+ t.mock.timers.tick(1000)
+ await reconnectPromise
+ })
+
+ assert.strictEqual(openCalls, 0)
+ })
+
+ await it('should discard a reconnect sleeper from an earlier lifecycle', async t => {
+ const station = createStationFromTemplate(copyStationTemplate())
+ const internals = station as unknown as StationInternals
+ const oldLifecycle = new AbortController()
+ let lifecycleSignal = oldLifecycle.signal
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleSignal,
+ })
+ internals.started = true
+ internals.getReconnectDelay = () => 1000
+ if (station.stationInfo != null) {
+ station.stationInfo.autoReconnectMaxRetries = -1
+ }
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const reconnectPromise = internals.reconnect()
+
+ internals.stopping = true
+ internals.started = false
+ oldLifecycle.abort()
+ lifecycleSignal = new AbortController().signal
+ const currentSocket = createOpenSocket()
+ internals.wsConnection = currentSocket
+ internals.stopping = false
+ internals.started = true
+ let openCalls = 0
+ internals.openWSConnection = () => {
+ openCalls++
+ currentSocket.close()
+ internals.wsConnection = createOpenSocket()
+ }
+
+ t.mock.timers.tick(1000)
+ await reconnectPromise
+
+ assert.strictEqual(openCalls, 0)
+ assert.strictEqual(internals.wsConnection, currentSocket)
+ assert.strictEqual(currentSocket.readyState, WebSocket.OPEN)
+ })
+ })
})
assert.strictEqual(station.getEnergyActiveImportRegisterByTransactionId(501), 18000)
})
+ await it('should resolve energy for the exact EVSE-local connector owning the transaction', () => {
+ // Two EVSEs share connector id 1; the transaction lives on EVSE 2's connector,
+ // while EVSE 1's connector 1 (iterated first) carries a different register value.
+ const result = createMockChargingStation({
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppVersion: OCPPVersion.VERSION_201,
+ })
+ station = result.station
+ const evse1Connector = station.getEvseStatus(1)?.connectors.get(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1Connector != null)
+ assert.ok(evse2 != null)
+ const evse2Connector = evse2.connectors.get(2)
+ assert.ok(evse2Connector != null)
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+ evse1Connector.transactionEnergyActiveImportRegisterValue = 11000
+ evse2Connector.transactionStarted = true
+ evse2Connector.transactionId = 777
+ evse2Connector.transactionEnergyActiveImportRegisterValue = 33000
+
+ assert.strictEqual(station.getEnergyActiveImportRegisterByTransactionId(777), 33000)
+ })
+
await it('should correctly count transactions only on connectors > 0', () => {
// Arrange
const result = createMockChargingStation({ connectorsCount: 2 })
})
})
+ await it('should preserve collected ended meter values when restarting the interval', async t => {
+ await withMockTimers(t, ['setInterval'], () => {
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+ station = result.station
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 100
+ const collectedMeterValue = { sampledValue: [], timestamp: new Date() }
+ connectorStatus.transactionEndedMeterValues = [collectedMeterValue]
+
+ OCPP20ServiceUtils.startEndedMeterValues(station, 1, 5000)
+ OCPP20ServiceUtils.startEndedMeterValues(station, 1, 5000)
+
+ assert.deepStrictEqual(connectorStatus.transactionEndedMeterValues, [collectedMeterValue])
+ })
+ })
+
await it('should clear transaction ended interval when stopEndedMeterValues() is called', async t => {
await withMockTimers(t, ['setInterval'], () => {
// Arrange
transactionSeqNo: 5,
transactionStart: new Date(),
transactionStarted: true,
+ transactionStartedExhaustedTransactionId: 'tx-123',
}
resetConnectorStatus(connectorStatus)
assert.strictEqual(connectorStatus.transactionStart, undefined)
assert.strictEqual(connectorStatus.transactionBeginMeterValue, undefined)
assert.strictEqual(connectorStatus.transactionSeqNo, undefined)
+ assert.strictEqual(connectorStatus.transactionStartedExhaustedTransactionId, undefined)
assert.strictEqual(connectorStatus.transactionEvseSent, undefined)
assert.strictEqual(connectorStatus.transactionIdTokenSent, undefined)
assert.strictEqual(connectorStatus.transactionDeauthorized, undefined)
await it('should preserve non-transaction fields', () => {
const connectorStatus: ConnectorStatus = {
availability: AvailabilityType.Operative,
+ energyActiveImportIntervalBaselines: { 'station:aligned': 42 },
MeterValues: [{} as unknown as SampledValueTemplate],
status: ConnectorStatusEnum.Available,
+ transactionEnergyActiveImportIntervalBaselines: { transaction: 21 },
transactionStarted: true,
}
resetConnectorStatus(connectorStatus)
assert.strictEqual(connectorStatus.availability, AvailabilityType.Operative)
+ assert.deepStrictEqual(connectorStatus.energyActiveImportIntervalBaselines, {
+ 'station:aligned': 42,
+ })
assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
assert.strictEqual(connectorStatus.MeterValues.length, 1)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, undefined)
})
})
--- /dev/null
+/**
+ * @file Tests for TransactionEventQueueUtils
+ * @description Unit tests for the bounded per-connector OCPP 2.0.1 TransactionEvent queue (FIFO replay, soft/hard byte and length limits, lifecycle-core preservation, hydration and accounting)
+ */
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import type {
+ ConnectorStatus,
+ OCPP20MeterValue,
+ OCPP20SampledValue,
+ OCPP20TransactionEventRequest,
+ QueuedTransactionEvent,
+} from '../../src/types/index.js'
+
+import transactionEventRequestSchema from '../../src/assets/json-schemas/ocpp/2.0/TransactionEventRequest.json' with { type: 'json' }
+import {
+ prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
+} from '../../src/charging-station/HelpersConnectorStatus.js'
+import { createAjv } from '../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ boundTransactionEventQueue,
+ clearTransactionEventQueueInFlight,
+ enqueueBoundedTransactionEvent,
+ getTransactionEventQueueBytes,
+ hasQueuedEndedTransactionEvent,
+ queuedTransactionEventHasPublicKey,
+ removeBoundedTransactionEvent,
+ setTransactionEventQueueInFlight,
+ setTransactionEventQueueStaged,
+ shiftBoundedTransactionEvent,
+} from '../../src/charging-station/TransactionEventQueueUtils.js'
+import {
+ OCPP20IdTokenEnumType,
+ OCPP20LocationEnumType,
+ OCPP20MeasurandEnumType,
+ OCPP20PhaseEnumType,
+ OCPP20ReadingContextEnumType,
+ OCPP20TransactionEventEnumType,
+ OCPP20TriggerReasonEnumType,
+ OCPP20UnitEnumType,
+} from '../../src/types/index.js'
+import { Constants } from '../../src/utils/index.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+const validateTransactionEvent = createAjv().compile(transactionEventRequestSchema)
+
+const prepareTestConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => {
+ for (const queuedEvent of connectorStatus.transactionEventQueue ?? []) {
+ queuedEvent.ownerEvseId ??= 1
+ queuedEvent.ownerConnectorId ??= 1
+ }
+ return preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(connectorStatus),
+ request =>
+ validateTransactionEvent(
+ JSON.parse(JSON.stringify(request)) as OCPP20TransactionEventRequest
+ ),
+ 1,
+ 1
+ )
+}
+
+const assertSchemaValid = (request: OCPP20TransactionEventRequest): void => {
+ const wireRequest = JSON.parse(JSON.stringify(request)) as OCPP20TransactionEventRequest
+ assert.strictEqual(
+ validateTransactionEvent(wireRequest),
+ true,
+ JSON.stringify(validateTransactionEvent.errors)
+ )
+}
+
+const toQueuedEvent = (request: OCPP20TransactionEventRequest): QueuedTransactionEvent => ({
+ request,
+ seqNo: request.seqNo,
+ timestamp: request.timestamp,
+})
+
+const lifecycleMeterValues = (offset: number): OCPP20MeterValue[] =>
+ Array.from({ length: 225 }, (_, index) => ({
+ sampledValue: [
+ {
+ customData: { channel: offset + index, vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: OCPP20PhaseEnumType.L1,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: index === 0 ? 'public-key' : '',
+ signedMeterData: 'x'.repeat(2500),
+ signingMethod: '',
+ },
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: offset + index,
+ },
+ ],
+ timestamp: new Date((offset + index) * 1000),
+ }))
+
+const oversizedLifecycleMeterValues = (timestamp: Date): OCPP20MeterValue[] => [
+ {
+ sampledValue: Array.from({ length: 425 }, (_, sampledValueIndex) => ({
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: sampledValueIndex === 0 ? 'public-key' : '',
+ signedMeterData: 'x'.repeat(2500),
+ signingMethod: '',
+ },
+ unitOfMeasure: {
+ multiplier: sampledValueIndex,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ },
+ value: sampledValueIndex,
+ })),
+ timestamp,
+ },
+]
+
+await describe('TransactionEventQueueUtils', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should preserve additive interval energy while decimating queued updates', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000200'
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) => {
+ const timestamp = new Date(seqNo * 1000)
+ return toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ })
+ const connectorStatus = { transactionEventQueue } as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.ok(result.removedEvents.length > 0)
+ assert.ok(
+ result.removedEvents.some(queuedEvent => queuedEvent.seqNo > eventCount * 0.75),
+ 'eviction should be distributed beyond the oldest chronological quarter'
+ )
+ assert.ok(queue.length <= Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ for (const queuedEvent of queue) {
+ for (const meterValue of queuedEvent.request.meterValue ?? []) {
+ if (
+ meterValue.sampledValue.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ ) {
+ assert.strictEqual(
+ meterValue.timestamp.getTime(),
+ queuedEvent.request.timestamp.getTime()
+ )
+ }
+ }
+ }
+ const retainedEnergy = queue
+ .flatMap(queuedEvent => queuedEvent.request.meterValue ?? [])
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ .reduce((total, sample) => total + sample.value, 0)
+ assert.strictEqual(retainedEnergy, eventCount)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes(queue))
+ })
+
+ await it('should conserve unsigned interval energy while compacting an Ended history', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000206'
+ const sampleCount = 425
+ const meterValue = Array.from({ length: sampleCount }, (_, index) => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ customData: { padding: 'x'.repeat(2500), vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ }))
+ const request: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue,
+ seqNo: 1,
+ timestamp: new Date(sampleCount * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ const connectorStatus = { transactionId } as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, toQueuedEvent(request))
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(queue.length, 1)
+ assert.ok((queue[0].request.meterValue?.length ?? 0) < sampleCount)
+ const retainedEnergy =
+ queue[0].request.meterValue
+ ?.flatMap(value => value.sampledValue)
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .reduce((total, sample) => total + sample.value, 0) ?? 0
+ assert.strictEqual(retainedEnergy, sampleCount)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes(queue))
+ assertSchemaValid(queue[0].request)
+ })
+
+ await it('should preserve signed evidence byte-for-byte while compacting unsigned Ended history', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000223'
+ const baselineKey = 'SampledDataCtrlr.TxEndedMeasurands'
+ const sampleCount = 100
+ const signedIndexes = new Set([0, 49, 99])
+ const meterValue: OCPP20MeterValue[] = Array.from({ length: sampleCount }, (_, index) => ({
+ sampledValue: [
+ signedIndexes.has(index)
+ ? {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: index === 0 ? 'public-key' : '',
+ signedMeterData: `signed-${index.toString()}`,
+ signingMethod: '',
+ },
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ }
+ : {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ customData: { padding: 'x'.repeat(15_000), vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ }))
+ const signedEvidence = meterValue
+ .filter(value => value.sampledValue[0].signedMeterValue != null)
+ .map(value => JSON.stringify(value))
+ const queuedEvent = {
+ ...toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue,
+ seqNo: sampleCount,
+ timestamp: new Date(sampleCount * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }),
+ transactionEnergyActiveImportIntervalConsumption: { [baselineKey]: sampleCount },
+ }
+ const connectorStatus = { transactionId } as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, queuedEvent)
+
+ const retainedEvent = connectorStatus.transactionEventQueue?.[0]
+ assert.ok(retainedEvent != null)
+ const retainedMeterValues = retainedEvent.request.meterValue
+ assert.ok(retainedMeterValues != null)
+ assert.deepStrictEqual(
+ retainedMeterValues
+ .filter(value => value.sampledValue.some(sample => sample.signedMeterValue != null))
+ .map(value => JSON.stringify(value)),
+ signedEvidence
+ )
+ const retainedIntervalSamples = retainedMeterValues.flatMap(value => value.sampledValue)
+ assert.strictEqual(
+ retainedIntervalSamples.reduce((total, sample) => total + sample.value, 0),
+ sampleCount
+ )
+ assert.strictEqual(
+ retainedIntervalSamples
+ .filter(sample => sample.signedMeterValue == null)
+ .reduce((total, sample) => total + sample.value, 0),
+ sampleCount - signedIndexes.size
+ )
+ assert.deepStrictEqual(retainedEvent.transactionEnergyActiveImportIntervalConsumption, {
+ [baselineKey]: sampleCount,
+ })
+ assert.strictEqual(result.overLimit, false)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes([retainedEvent]))
+ assertSchemaValid(retainedEvent.request)
+ })
+
+ await it('should discard malformed signed evidence without mutating the persisted candidate', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000220'
+ const meterValue: OCPP20MeterValue[] = Array.from({ length: 425 }, (_, index) => ({
+ sampledValue: [
+ {
+ customData: { payload: 'x'.repeat(3000), vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {} as OCPP20SampledValue['signedMeterValue'],
+ value: index,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ }))
+ const queuedEvent = toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue,
+ seqNo: 425,
+ timestamp: new Date(425_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ assert.ok(
+ getTransactionEventQueueBytes([queuedEvent]) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ const persistedStatus = JSON.parse(
+ JSON.stringify({ transactionEventQueue: [queuedEvent] })
+ ) as ConnectorStatus
+
+ const persistedEvent = persistedStatus.transactionEventQueue?.[0]
+ assert.ok(persistedEvent != null)
+ const signedEvidence = JSON.stringify(persistedEvent.request.meterValue)
+
+ const restoredStatus = prepareTestConnectorStatus(persistedStatus)
+
+ assert.deepStrictEqual(restoredStatus.transactionEventQueue, [])
+ assert.strictEqual(JSON.stringify(persistedEvent.request.meterValue), signedEvidence)
+ })
+
+ await it('should reject an all-signed oversized Ended event without changing its evidence', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000201'
+ const measurands = [
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_EXPORT_REGISTER,
+ ] as const
+ const phases = [OCPP20PhaseEnumType.L1, OCPP20PhaseEnumType.L2, OCPP20PhaseEnumType.L3] as const
+ const channels = measurands.flatMap(measurand =>
+ phases.flatMap(phase =>
+ [false, true].flatMap(publicKeyRequired =>
+ [0, 1].map(customVariant => ({ customVariant, measurand, phase, publicKeyRequired }))
+ )
+ )
+ )
+ const meterValue: OCPP20MeterValue[] = Array.from({ length: 500 }, (_, index) => {
+ const channel = channels[index % channels.length]
+ return {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ customData: { channel: channel.customVariant, vendorId: 'test' },
+ location:
+ channel.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ ? OCPP20LocationEnumType.Inlet
+ : OCPP20LocationEnumType.Outlet,
+ measurand: channel.measurand,
+ phase: channel.phase,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: channel.publicKeyRequired ? 'public-key' : '',
+ signedMeterData: 'x'.repeat(2500),
+ signingMethod: '',
+ },
+ unitOfMeasure: {
+ multiplier: channel.customVariant,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ },
+ value: index,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ }
+ })
+ const request: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue,
+ seqNo: 500,
+ timestamp: new Date(500_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ assertSchemaValid(request)
+ const connectorStatus = {} as ConnectorStatus
+ const originalBytes = JSON.stringify(request)
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, toQueuedEvent(request))
+
+ assert.strictEqual(result.inserted, false)
+ assert.strictEqual(result.capacityRejected, true)
+ assert.strictEqual(result.overLimit, false)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(JSON.stringify(request), originalBytes)
+ })
+
+ await it('should retain unsigned customData channel endpoints from the middle of an oversized event', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000203'
+ const billingSample = (
+ context: OCPP20ReadingContextEnumType,
+ value: number
+ ): OCPP20SampledValue => ({
+ context,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value,
+ })
+ const frequencySamples = Array.from({ length: 500 }, (_, index): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ customData: {
+ channel: index % 2 === 0 ? 'channel-a' : 'channel-b',
+ payload: 'x'.repeat(2500),
+ vendorId: 'test',
+ },
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.FREQUENCY,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.HERTZ },
+ value: index,
+ },
+ ],
+ timestamp: new Date((index + 1) * 1000),
+ }))
+ const request: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: [
+ {
+ sampledValue: [billingSample(OCPP20ReadingContextEnumType.TRANSACTION_BEGIN, 10)],
+ timestamp: new Date(0),
+ },
+ ...frequencySamples,
+ {
+ sampledValue: [billingSample(OCPP20ReadingContextEnumType.TRANSACTION_END, 20)],
+ timestamp: new Date(501_000),
+ },
+ ],
+ seqNo: 501,
+ timestamp: new Date(501_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ assertSchemaValid(request)
+ assert.ok(
+ getTransactionEventQueueBytes([toQueuedEvent(request)]) >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ const connectorStatus = {} as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, toQueuedEvent(request))
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(queue.length, 1)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes(queue))
+ assertSchemaValid(queue[0].request)
+ const retainedFrequencyValues = new Map<string, number[]>()
+ for (const sample of queue[0].request.meterValue?.flatMap(value => value.sampledValue) ?? []) {
+ if (sample.measurand !== OCPP20MeasurandEnumType.FREQUENCY) continue
+ const channel = sample.customData?.channel
+ if (typeof channel !== 'string') assert.fail('Retained Frequency sample has no channel')
+ const values = retainedFrequencyValues.get(channel) ?? []
+ values.push(sample.value)
+ retainedFrequencyValues.set(channel, values)
+ }
+ assert.deepStrictEqual(retainedFrequencyValues.get('channel-a'), [0, 498])
+ assert.deepStrictEqual(retainedFrequencyValues.get('channel-b'), [1, 499])
+ })
+
+ await it('should reject an oversized lifecycle event without mutating it or exceeding the cap', () => {
+ for (const [index, eventType] of [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Ended,
+ ].entries()) {
+ const transactionId =
+ index === 0
+ ? '00000000-0000-4000-8000-000000000210'
+ : '00000000-0000-4000-8000-000000000211'
+ const timestamp = new Date((6000 + index) * 1000)
+ const queuedEvent = toQueuedEvent({
+ customData: {
+ payload: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ vendorId: 'test',
+ },
+ eventType,
+ seqNo: index,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason:
+ eventType === OCPP20TransactionEventEnumType.Started
+ ? OCPP20TriggerReasonEnumType.RemoteStart
+ : OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ const originalBytes = JSON.stringify(queuedEvent)
+ const connectorStatus = { transactionId } as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, queuedEvent)
+
+ assert.strictEqual(result.inserted, false)
+ assert.strictEqual(result.capacityRejected, true)
+ assert.strictEqual(result.overLimit, false)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(JSON.stringify(queuedEvent), originalBytes)
+ }
+ })
+
+ await it('should preserve the public-key reservation when another queued frame retains the key', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000212'
+ const retainedTimestamp = new Date(8_000_000)
+ const retainedEvent = toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: retainedTimestamp,
+ },
+ ],
+ seqNo: 0,
+ timestamp: retainedTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ const protectedTimestamp = new Date(8_001_000)
+ const protectedEvent = toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: oversizedLifecycleMeterValues(protectedTimestamp),
+ seqNo: 1,
+ timestamp: protectedTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ assertSchemaValid(retainedEvent.request)
+ assertSchemaValid(protectedEvent.request)
+ assert.ok(
+ getTransactionEventQueueBytes([protectedEvent]) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [retainedEvent],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, protectedEvent)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.deepStrictEqual(queue, [retainedEvent])
+ assert.strictEqual(result.inserted, false)
+ assert.strictEqual(result.capacityRejected, true)
+ assert.strictEqual(result.overLimit, false)
+ assert.strictEqual(queuedTransactionEventHasPublicKey(retainedEvent, transactionId), true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes(queue))
+ })
+
+ await it('should retain a non-quantile signed Updated during the first compaction pass', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000221'
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) => {
+ const timestamp = new Date(seqNo * 1000)
+ return toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ ...(seqNo === 1 && {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: seqNo,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ }),
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ })
+ const signedEvent = transactionEventQueue[1]
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue,
+ transactionId,
+ } as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ assert.ok(result.removedEvents.length > 0)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.includes(signedEvent), true)
+ assert.strictEqual(queuedTransactionEventHasPublicKey(signedEvent, transactionId), true)
+ })
+
+ await it('should not transfer a public key into malformed persisted signed metadata', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000222'
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) => {
+ const timestamp = new Date(seqNo * 1000)
+ return toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ ...(seqNo === 1 && {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: seqNo,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ }),
+ ...(seqNo === eventCount - 1 && {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue:
+ 'malformed' as unknown as OCPP20SampledValue['signedMeterValue'],
+ value: seqNo,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ }),
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ })
+ const persistedStatus = JSON.parse(
+ JSON.stringify({
+ publicKeySentInTransaction: true,
+ transactionEventQueue,
+ transactionId,
+ })
+ ) as ConnectorStatus
+ const connectorStatus = prepareTestConnectorStatus(persistedStatus)
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ const keyedEvent = queue.find(({ seqNo }) => seqNo === 1)
+ const malformedEvent = queue.at(-1)
+ assert.ok(keyedEvent != null)
+ assert.ok(malformedEvent != null)
+
+ assert.ok(queue.length < eventCount)
+ assert.doesNotThrow(() => boundTransactionEventQueue(connectorStatus))
+
+ assert.strictEqual(queue.includes(keyedEvent), true)
+ assert.strictEqual(queue.includes(malformedEvent), true)
+ assert.strictEqual(queuedTransactionEventHasPublicKey(keyedEvent, transactionId), true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should retain malformed signed evidence and its active key reservation', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000224'
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1
+ const malformedKeySeqNo = Math.floor(eventCount / 2) + 1
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) => {
+ const timestamp = new Date(seqNo * 1000)
+ return toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ ...(seqNo === malformedKeySeqNo && {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ publicKey: 'stale-public-key',
+ } as unknown as OCPP20SampledValue['signedMeterValue'],
+ value: seqNo,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ }),
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ })
+ const malformedKeyEvent = transactionEventQueue[malformedKeySeqNo]
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue,
+ transactionId,
+ } as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ assert.strictEqual(result.removedEvents.includes(malformedKeyEvent), false)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.includes(malformedKeyEvent), true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should retain every signed Updated event even when they exceed the cap', () => {
+ const firstTransactionId = '00000000-0000-4000-8000-000000000213'
+ const secondTransactionId = '00000000-0000-4000-8000-000000000214'
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 4
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) => {
+ const transactionId = seqNo < 2 ? firstTransactionId : secondTransactionId
+ const timestamp = new Date(seqNo * 1000)
+ return toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: `public-key-${transactionId}`,
+ signedMeterData: `signed-data-${seqNo.toString()}`,
+ signingMethod: '',
+ },
+ value: seqNo,
+ },
+ ],
+ timestamp,
+ },
+ ],
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ })
+ const firstTransactionFinalSignedEvent = transactionEventQueue[1]
+ const secondTransactionFinalSignedEvent = transactionEventQueue[eventCount - 1]
+ const connectorStatus = { transactionEventQueue } as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ assert.deepStrictEqual(result.removedEvents, [])
+ assert.strictEqual(result.overLimit, true)
+ const boundedQueue = connectorStatus.transactionEventQueue
+ assert.ok(boundedQueue != null)
+ assert.strictEqual(boundedQueue.length, eventCount)
+ assert.ok(boundedQueue.length > Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH)
+ assert.strictEqual(result.bytes, getTransactionEventQueueBytes(boundedQueue))
+ assert.strictEqual(boundedQueue.includes(firstTransactionFinalSignedEvent), true)
+ assert.strictEqual(boundedQueue.includes(secondTransactionFinalSignedEvent), true)
+ })
+
+ await it('should retain Started and Ended cores while compacting permitted Ended meter data', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000202'
+ const startedRequest: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Started,
+ evse: { connectorId: 2, id: 1 },
+ idToken: { idToken: 'first-event-token', type: OCPP20IdTokenEnumType.Local },
+ meterValue: lifecycleMeterValues(0),
+ seqNo: 0,
+ timestamp: new Date(0),
+ transactionInfo: { remoteStartId: 202, transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.RemoteStart,
+ }
+ const endedRequest: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: lifecycleMeterValues(1000),
+ seqNo: 1,
+ timestamp: new Date(1_000_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ assertSchemaValid(startedRequest)
+ assertSchemaValid(endedRequest)
+ const startedEvent = toQueuedEvent(startedRequest)
+ const endedEvent = toQueuedEvent(endedRequest)
+ const startedBytes = getTransactionEventQueueBytes([startedEvent])
+ const endedBytes = getTransactionEventQueueBytes([endedEvent])
+ assert.ok(startedBytes > 600_000 && startedBytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.ok(endedBytes > 600_000 && endedBytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.ok(
+ getTransactionEventQueueBytes([startedEvent, endedEvent]) >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [startedEvent, endedEvent],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const endedResult = boundTransactionEventQueue(connectorStatus)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.deepStrictEqual(queue, [startedEvent, endedEvent])
+ assert.deepStrictEqual(endedResult.removedEvents, [])
+ assert.strictEqual(endedRequest.evse, undefined)
+ assert.strictEqual(endedRequest.idToken, undefined)
+ assert.strictEqual(endedRequest.transactionInfo.remoteStartId, undefined)
+ assert.strictEqual(endedResult.overLimit, true)
+ assert.ok(endedResult.bytes > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.strictEqual(endedResult.bytes, getTransactionEventQueueBytes(queue))
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), true)
+ assert.strictEqual(queuedTransactionEventHasPublicKey(startedEvent, transactionId), true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ assertSchemaValid(startedEvent.request)
+ assertSchemaValid(endedEvent.request)
+
+ const duplicateResult = enqueueBoundedTransactionEvent(connectorStatus, {
+ ...endedEvent,
+ request: { ...endedEvent.request },
+ })
+ assert.strictEqual(duplicateResult.inserted, false)
+ assert.strictEqual(queue.length, 2)
+ assert.strictEqual(duplicateResult.bytes, getTransactionEventQueueBytes(queue))
+
+ assert.strictEqual(shiftBoundedTransactionEvent(connectorStatus)?.seqNo, 0)
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), true)
+ assert.strictEqual(shiftBoundedTransactionEvent(connectorStatus)?.seqNo, 1)
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), false)
+ const emptyResult = boundTransactionEventQueue(connectorStatus)
+ assert.strictEqual(emptyResult.bytes, getTransactionEventQueueBytes([]))
+ })
+
+ await it('should update Ended lifecycle accounting after exact removals', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000226'
+ const endedEvent = (seqNo: number): QueuedTransactionEvent =>
+ toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ const connectorStatus = {
+ transactionEventQueue: [],
+ transactionId,
+ } as unknown as ConnectorStatus
+ const firstEndedEvent = endedEvent(0)
+ assert.strictEqual(
+ enqueueBoundedTransactionEvent(connectorStatus, firstEndedEvent).inserted,
+ true
+ )
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), true)
+
+ assert.strictEqual(removeBoundedTransactionEvent(connectorStatus, firstEndedEvent), true)
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), false)
+
+ const stagedEndedEvent = endedEvent(1)
+ assert.strictEqual(
+ enqueueBoundedTransactionEvent(connectorStatus, stagedEndedEvent).inserted,
+ true
+ )
+ setTransactionEventQueueStaged(connectorStatus, stagedEndedEvent, true)
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), false)
+ assert.strictEqual(removeBoundedTransactionEvent(connectorStatus, stagedEndedEvent), true)
+
+ const replacementEndedEvent = endedEvent(2)
+ assert.strictEqual(
+ enqueueBoundedTransactionEvent(connectorStatus, replacementEndedEvent).inserted,
+ true
+ )
+ assert.strictEqual(hasQueuedEndedTransactionEvent(connectorStatus, transactionId), true)
+ })
+
+ await it('should dispose the exact update and carries derivable active-transaction energy', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000206'
+ const intervalEvent = (seqNo: number, value: number): QueuedTransactionEvent =>
+ toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value,
+ },
+ ],
+ timestamp: new Date(seqNo * 1000),
+ },
+ ],
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ const connectorStatus = {
+ transactionEventQueue: [intervalEvent(1, 10), intervalEvent(2, 20)],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ assert.strictEqual(shiftBoundedTransactionEvent(connectorStatus, true)?.seqNo, 1)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(queue.length, 1)
+ assert.strictEqual(queue[0].request.meterValue?.[0].sampledValue[0].value, 30)
+
+ const signedEvent = intervalEvent(5, 40)
+ const signedSample = signedEvent.request.meterValue?.[0].sampledValue[0]
+ assert.ok(signedSample != null)
+ signedSample.signedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ }
+ const signedConnectorStatus = {
+ transactionEventQueue: [signedEvent, intervalEvent(6, 50)],
+ transactionId,
+ } as unknown as ConnectorStatus
+ assert.strictEqual(shiftBoundedTransactionEvent(signedConnectorStatus, true)?.seqNo, 5)
+ const retainedSamples =
+ signedConnectorStatus.transactionEventQueue?.[0].request.meterValue?.flatMap(
+ meterValue => meterValue.sampledValue
+ )
+ if (retainedSamples == null) assert.fail('Expected retained interval samples')
+ assert.deepStrictEqual(
+ retainedSamples.map(sampledValue => sampledValue.value).sort((left, right) => left - right),
+ [40, 50]
+ )
+ assert.ok(retainedSamples.some(sampledValue => sampledValue.signedMeterValue != null))
+
+ assert.strictEqual(
+ boundTransactionEventQueue(connectorStatus).bytes,
+ getTransactionEventQueueBytes(queue)
+ )
+
+ const lastActiveEvent = intervalEvent(3, 30)
+ lastActiveEvent.transactionEnergyActiveImportIntervalConsumption = { periodic: 30 }
+ const activeConnectorStatus = {
+ transactionEventQueue: [lastActiveEvent],
+ transactionId,
+ } as unknown as ConnectorStatus
+ assert.strictEqual(shiftBoundedTransactionEvent(activeConnectorStatus, true)?.seqNo, 3)
+ assert.strictEqual(activeConnectorStatus.transactionEventQueue?.length, 0)
+ assert.strictEqual(
+ activeConnectorStatus.transactionEnergyActiveImportIntervalCarry?.periodic,
+ 30
+ )
+
+ const legacyEvent = intervalEvent(4, 30)
+ const historicalConnectorStatus = {
+ transactionEventQueue: [legacyEvent],
+ } as unknown as ConnectorStatus
+ assert.strictEqual(shiftBoundedTransactionEvent(historicalConnectorStatus, true)?.seqNo, 4)
+ assert.strictEqual(historicalConnectorStatus.transactionEventQueue?.length, 0)
+ assert.strictEqual(
+ historicalConnectorStatus.transactionEnergyActiveImportIntervalCarry,
+ undefined
+ )
+
+ const zeroConnectorStatus = {
+ transactionEventQueue: [intervalEvent(7, 0)],
+ transactionId,
+ } as unknown as ConnectorStatus
+ assert.strictEqual(shiftBoundedTransactionEvent(zeroConnectorStatus, true)?.seqNo, 7)
+ assert.strictEqual(zeroConnectorStatus.transactionEventQueue?.length, 0)
+ })
+
+ await it('should distribute intermediate Updated eviction while retaining transaction endpoints', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000211'
+ const connectorStatus = {
+ transactionEventQueue: Array.from({ length: 6 }, (_, seqNo) =>
+ toQueuedEvent({
+ customData: { payload: 'x'.repeat(300_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ ),
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+ const queue = connectorStatus.transactionEventQueue
+
+ assert.ok(queue != null)
+ assert.strictEqual(result.overLimit, false)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.deepStrictEqual(
+ queue.map(queuedEvent => queuedEvent.seqNo),
+ [0, 5]
+ )
+ assert.ok(
+ result.removedEvents.every(
+ event => event.request.eventType === OCPP20TransactionEventEnumType.Updated
+ )
+ )
+ })
+
+ await it('should distribute intermediate eviction across short transaction histories', () => {
+ const connectorStatus = {
+ transactionEventQueue: Array.from({ length: 9 }, (_, seqNo) =>
+ toQueuedEvent({
+ customData: { payload: 'x'.repeat(150_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: {
+ transactionId: `00000000-0000-4000-8000-${Math.floor(seqNo / 3)
+ .toString()
+ .padStart(12, '0')}`,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ ),
+ } as unknown as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(result.overLimit, false)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ const retainedTransactionIds = new Set(
+ queue.map(queuedEvent => queuedEvent.request.transactionInfo.transactionId)
+ )
+ assert.strictEqual(retainedTransactionIds.size, 3)
+ const removedSeqNos = new Set(result.removedEvents.map(queuedEvent => queuedEvent.seqNo))
+ for (const interiorSeqNo of [1, 4, 7]) {
+ assert.strictEqual(removedSeqNos.has(interiorSeqNo), true)
+ }
+ assert.strictEqual(queue.length + result.removedEvents.length, 9)
+ })
+
+ await it('should distribute singleton eviction across historical transactions', () => {
+ const connectorStatus = {
+ transactionEventQueue: Array.from({ length: 4 }, (_, seqNo) =>
+ toQueuedEvent({
+ customData: { payload: 'x'.repeat(300_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: {
+ transactionId: `00000000-0000-4000-8000-${seqNo.toString().padStart(12, '0')}`,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ ),
+ } as unknown as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(result.overLimit, false)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.deepStrictEqual(
+ queue.map(queuedEvent => queuedEvent.seqNo),
+ [0, 3]
+ )
+ assert.deepStrictEqual(
+ result.removedEvents
+ .map(queuedEvent => queuedEvent.seqNo)
+ .sort((left, right) => left - right),
+ [1, 2]
+ )
+ })
+
+ await it('should retain every lifecycle core when mandatory records exceed the soft budget', () => {
+ const eventCount = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1
+ const transactionEventQueue = Array.from({ length: eventCount }, (_, seqNo) =>
+ toQueuedEvent({
+ eventType:
+ seqNo % 2 === 0
+ ? OCPP20TransactionEventEnumType.Started
+ : OCPP20TransactionEventEnumType.Ended,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: {
+ transactionId: `00000000-0000-4000-8001-${seqNo.toString().padStart(12, '0')}`,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ )
+ const connectorStatus = { transactionEventQueue } as unknown as ConnectorStatus
+
+ const result = boundTransactionEventQueue(connectorStatus)
+
+ assert.strictEqual(result.overLimit, true)
+ assert.deepStrictEqual(result.removedEvents, [])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, transactionEventQueue)
+ })
+
+ await it('should admit lifecycle cores after the Updated soft byte budget is exceeded', () => {
+ const transactionId = '00000000-0000-4000-8002-888888888888'
+ const started = toQueuedEvent({
+ customData: {
+ payload: 'x'.repeat(Math.floor(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * 0.76)),
+ vendorId: 'test',
+ },
+ eventType: OCPP20TransactionEventEnumType.Started,
+ seqNo: 0,
+ timestamp: new Date(0),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ const connectorStatus = { transactionEventQueue: [started] } as unknown as ConnectorStatus
+ const ended = toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 1,
+ timestamp: new Date(1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, ended)
+
+ assert.strictEqual(result.inserted, true)
+ assert.strictEqual(result.capacityRejected, undefined)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [started, ended])
+ })
+
+ await it('should admit mandatory lifecycle cores beyond the soft queue budget', () => {
+ const transactionEventQueue = Array.from(
+ { length: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH },
+ (_, seqNo) =>
+ toQueuedEvent({
+ eventType:
+ seqNo % 2 === 0
+ ? OCPP20TransactionEventEnumType.Started
+ : OCPP20TransactionEventEnumType.Ended,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: {
+ transactionId: `00000000-0000-4000-8002-${seqNo.toString().padStart(12, '0')}`,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ )
+ const connectorStatus = { transactionEventQueue } as unknown as ConnectorStatus
+ const attempted = toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH,
+ timestamp: new Date(Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH * 1000),
+ transactionInfo: { transactionId: '00000000-0000-4000-8002-999999999999' },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, attempted)
+
+ assert.strictEqual(result.inserted, true)
+ assert.strictEqual(result.capacityRejected, undefined)
+ assert.strictEqual(result.overLimit, true)
+ assert.deepStrictEqual(result.removedEvents, [])
+ assert.strictEqual(connectorStatus.transactionEventQueue, transactionEventQueue)
+ assert.strictEqual(connectorStatus.transactionEventQueue.at(-1), attempted)
+ })
+
+ await it('should reject admission without mutating existing queue state', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000224'
+ const attemptedEvent = (seqNo: number): QueuedTransactionEvent => ({
+ ...toQueuedEvent({
+ customData: { payload: 'x'.repeat(400_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ }),
+ deliveryAttempted: true,
+ })
+ const transferableEvent = {
+ ...toQueuedEvent({
+ customData: { payload: 'x'.repeat(50_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: new Date(1_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ }),
+ transactionEnergyActiveImportIntervalConsumption: { periodic: 10 },
+ }
+ const existingEvents = [attemptedEvent(0), transferableEvent, attemptedEvent(2)]
+ const connectorStatus = {
+ transactionEventQueue: existingEvents,
+ transactionId,
+ } as unknown as ConnectorStatus
+ const queueSnapshot = structuredClone(existingEvents)
+ const candidate = toQueuedEvent({
+ customData: { payload: 'x'.repeat(400_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 3,
+ timestamp: new Date(3_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, candidate)
+
+ assert.strictEqual(result.inserted, false)
+ assert.strictEqual(result.capacityRejected, true)
+ assert.strictEqual(result.overLimit, false)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, queueSnapshot)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ assert.ok(result.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ })
+
+ await it('should preserve exact in-flight and attempted events while transferring removed state safely', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000225'
+ const event = (
+ seqNo: number,
+ publicKey: string,
+ intervalEnergy = 0
+ ): QueuedTransactionEvent => {
+ const timestamp = new Date(seqNo * 1000)
+ return {
+ ...toQueuedEvent({
+ customData: { payload: 'x'.repeat(400_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: `signed-${seqNo.toString()}`,
+ signingMethod: '',
+ },
+ value: seqNo,
+ },
+ ...(intervalEnergy > 0
+ ? [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: intervalEnergy,
+ },
+ ]
+ : []),
+ ],
+ timestamp,
+ },
+ ],
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ }),
+ ...(intervalEnergy > 0 && {
+ transactionEnergyActiveImportIntervalConsumption: { periodic: intervalEnergy },
+ }),
+ }
+ }
+ const inFlightEvent = event(0, '')
+ const removedEvent = event(1, '', 10)
+ assert.ok(removedEvent.request.meterValue != null)
+ delete removedEvent.request.meterValue[0].sampledValue[0].signedMeterValue
+ removedEvent.request.customData = {
+ payload: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ vendorId: 'test',
+ }
+ const protectedEvent = event(2, '')
+ protectedEvent.deliveryAttempted = true
+ const inFlightSnapshot = JSON.stringify(inFlightEvent)
+ const protectedSnapshot = JSON.stringify(protectedEvent)
+ const connectorStatus = {
+ publicKeySentInTransaction: false,
+ transactionEventQueue: [inFlightEvent, removedEvent, protectedEvent],
+ transactionId,
+ } as unknown as ConnectorStatus
+ setTransactionEventQueueInFlight(connectorStatus, inFlightEvent)
+
+ const result = boundTransactionEventQueue(connectorStatus)
+ clearTransactionEventQueueInFlight(connectorStatus, inFlightEvent)
+
+ assert.strictEqual(result.removedEvents.includes(removedEvent), true)
+ assert.strictEqual(JSON.stringify(inFlightEvent), inFlightSnapshot)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [inFlightEvent, protectedEvent])
+ assert.strictEqual(JSON.stringify(protectedEvent), protectedSnapshot)
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ periodic: 10,
+ })
+ assert.strictEqual(queuedTransactionEventHasPublicKey(protectedEvent, transactionId), false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ assert.strictEqual(
+ result.bytes,
+ getTransactionEventQueueBytes(connectorStatus.transactionEventQueue)
+ )
+ })
+
+ await it('should not clear a newer in-flight replay with stale ownership', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000210'
+ const event = (seqNo: number, payloadSize: number): QueuedTransactionEvent =>
+ toQueuedEvent({
+ customData: { payload: 'x'.repeat(payloadSize), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ const inFlightEvent = event(1, 900_000)
+ const concurrentEvent = event(2, 300_000)
+ const connectorStatus = {
+ transactionEventQueue: [inFlightEvent, concurrentEvent],
+ } as unknown as ConnectorStatus
+ setTransactionEventQueueInFlight(connectorStatus, inFlightEvent)
+ setTransactionEventQueueInFlight(connectorStatus, concurrentEvent)
+ clearTransactionEventQueueInFlight(connectorStatus, inFlightEvent)
+ const protectedResult = boundTransactionEventQueue(connectorStatus, inFlightEvent)
+
+ assert.strictEqual(protectedResult.overLimit, true)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [inFlightEvent, concurrentEvent])
+
+ clearTransactionEventQueueInFlight(connectorStatus, concurrentEvent)
+ const releasedResult = boundTransactionEventQueue(connectorStatus, inFlightEvent)
+
+ assert.strictEqual(releasedResult.overLimit, false)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [inFlightEvent])
+ assert.strictEqual(
+ releasedResult.bytes,
+ getTransactionEventQueueBytes(connectorStatus.transactionEventQueue)
+ )
+ })
+
+ await it('should admit the 7500th protected Updated and reject the 7501st', () => {
+ const transactionId = ''
+ const compactEvent = (seqNo: number): QueuedTransactionEvent => {
+ const queuedEvent = {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ transactionInfo: { transactionId },
+ },
+ seqNo,
+ } as unknown as QueuedTransactionEvent
+ Object.defineProperty(queuedEvent, 'deliveryAttempted', { value: true })
+ return queuedEvent
+ }
+ const existingEvents = Array.from({ length: 7_499 }, (_, seqNo) => compactEvent(seqNo))
+ const connectorStatus = {
+ transactionEventQueue: existingEvents,
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const admitted = enqueueBoundedTransactionEvent(connectorStatus, compactEvent(7_499))
+ const rejected = enqueueBoundedTransactionEvent(connectorStatus, compactEvent(7_500))
+
+ assert.strictEqual(admitted.inserted, true)
+ assert.strictEqual(admitted.capacityRejected, undefined)
+ assert.strictEqual(rejected.inserted, false)
+ assert.strictEqual(rejected.capacityRejected, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 7_500)
+ assert.ok(rejected.bytes <= Math.floor(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * 0.75))
+ })
+
+ await it('should reserve queue headroom for lifecycle events and reopen admission after drain', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000227'
+ const signedEvent = (seqNo: number, payloadBytes: number): QueuedTransactionEvent =>
+ toQueuedEvent({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: seqNo === 0 ? 'public-key' : '',
+ signedMeterData: 'x'.repeat(payloadBytes),
+ signingMethod: '',
+ },
+ value: seqNo,
+ },
+ ],
+ timestamp: new Date(seqNo * 1000),
+ },
+ ],
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ const existingEvents = Array.from({ length: 3 }, (_, seqNo) => signedEvent(seqNo, 200_000))
+ const [firstEvent, secondEvent, thirdEvent] = existingEvents
+ const connectorStatus = {
+ transactionEventQueue: existingEvents,
+ transactionId,
+ } as unknown as ConnectorStatus
+ const rejectedEvent = signedEvent(3, 190_000)
+
+ const rejected = enqueueBoundedTransactionEvent(connectorStatus, rejectedEvent)
+
+ assert.strictEqual(rejected.inserted, false)
+ assert.strictEqual(rejected.capacityRejected, true)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, existingEvents)
+ assert.ok(rejected.bytes <= Math.floor(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * 0.75))
+
+ assert.strictEqual(shiftBoundedTransactionEvent(connectorStatus), firstEvent)
+ const admitted = enqueueBoundedTransactionEvent(connectorStatus, rejectedEvent)
+
+ assert.strictEqual(admitted.inserted, true)
+ assert.strictEqual(admitted.capacityRejected, undefined)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [
+ secondEvent,
+ thirdEvent,
+ rejectedEvent,
+ ])
+ assert.ok(admitted.bytes <= Math.floor(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES * 0.75))
+ })
+
+ await it('should reject a newest update that cannot fit within the byte cap', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000207'
+ const baselineKey = 'AlignedDataCtrlr.Measurands'
+ const request: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ idToken: {
+ additionalInfo: Array.from({ length: 15_000 }, (_, index) => ({
+ additionalIdToken: index.toString().padStart(36, '0'),
+ type: 'x'.repeat(50),
+ })),
+ idToken: 'active-token',
+ type: OCPP20IdTokenEnumType.Local,
+ },
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(5_000),
+ },
+ ],
+ seqNo: 5,
+ timestamp: new Date(5_000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ }
+ const event = {
+ ...toQueuedEvent(request),
+ transactionEnergyActiveImportIntervalConsumption: { [baselineKey]: 10 },
+ }
+ assert.ok(getTransactionEventQueueBytes([event]) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ const connectorStatus = { transactionId } as ConnectorStatus
+
+ const result = enqueueBoundedTransactionEvent(connectorStatus, event)
+
+ assert.deepStrictEqual(result.removedEvents, [])
+ assert.strictEqual(result.inserted, false)
+ assert.strictEqual(result.capacityRejected, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ assert.strictEqual(result.overLimit, false)
+ })
+})
--- /dev/null
+/**
+ * @file Tests for TransactionIntervalUtils
+ * @description Unit tests for transaction interval energy capture/restore/carry helpers and inlet-to-output efficiency resolution
+ */
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import type { ConnectorStatus } from '../../src/types/index.js'
+
+import {
+ captureTransactionIntervalState,
+ completeTransactionIntervalState,
+ getRepresentedTransactionIntervalEnergyWh,
+ recordTransactionIntervalConsumption,
+ restoreTransactionIntervalState,
+ truncateTransactionIntervalValue,
+} from '../../src/charging-station/meter-values/TransactionIntervalUtils.js'
+import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
+
+await describe('TransactionIntervalUtils', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should preserve every failed concurrent interval consumption', () => {
+ const baselineKey = 'TxCtrlr.Measurands'
+ const connectorStatus = { transactionId: 'transaction-1' } as ConnectorStatus
+ const firstMeterValue = {}
+ const secondMeterValue = {}
+ const firstState = captureTransactionIntervalState(connectorStatus)
+ recordTransactionIntervalConsumption(firstMeterValue, baselineKey, 10)
+ completeTransactionIntervalState(firstState, baselineKey, [firstMeterValue])
+ const secondState = captureTransactionIntervalState(connectorStatus)
+ recordTransactionIntervalConsumption(secondMeterValue, baselineKey, 5)
+ completeTransactionIntervalState(secondState, baselineKey, [secondMeterValue])
+
+ restoreTransactionIntervalState(firstState, connectorStatus, baselineKey)
+ restoreTransactionIntervalState(secondState, connectorStatus, baselineKey)
+
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry?.[baselineKey],
+ 15
+ )
+ })
+
+ await it('should account for only the interval energy represented on the wire', () => {
+ assert.strictEqual(truncateTransactionIntervalValue(0.009), 0)
+ assert.strictEqual(truncateTransactionIntervalValue(0.019), 0.01)
+ assert.strictEqual(
+ getRepresentedTransactionIntervalEnergyWh(
+ {
+ sampledValue: [
+ {
+ measurand: 'Energy.Active.Import.Interval',
+ unit: 'kWh',
+ value: '0.01',
+ },
+ ],
+ },
+ 1
+ ),
+ 10
+ )
+ assert.strictEqual(
+ getRepresentedTransactionIntervalEnergyWh(
+ {
+ sampledValue: [
+ {
+ measurand: 'Energy.Active.Import.Interval',
+ phase: 'L1',
+ unitOfMeasure: { unit: 'kWh' },
+ value: 0.003,
+ },
+ ],
+ },
+ 3
+ ),
+ 9
+ )
+ assert.strictEqual(
+ getRepresentedTransactionIntervalEnergyWh(
+ {
+ sampledValue: [
+ {
+ location: 'Inlet',
+ measurand: 'Energy.Active.Import.Interval',
+ unit: 'Wh',
+ value: 100,
+ },
+ ],
+ },
+ 1,
+ 0.9
+ ),
+ 90
+ )
+ assert.strictEqual(
+ getRepresentedTransactionIntervalEnergyWh(
+ {
+ sampledValue: [
+ {
+ location: 'Inlet',
+ measurand: 'Energy.Active.Import.Interval',
+ phase: 'L1',
+ unit: 'Wh',
+ value: 100 / 3,
+ },
+ ],
+ },
+ 3,
+ 0.9
+ ),
+ 90
+ )
+ })
+
+ await it('should not carry a failed interval into a replacement transaction', () => {
+ const baselineKey = 'TxCtrlr.Measurands'
+ const connectorStatus = { transactionId: 'transaction-1' } as ConnectorStatus
+ const meterValue = {}
+ const state = captureTransactionIntervalState(connectorStatus)
+ recordTransactionIntervalConsumption(meterValue, baselineKey, 10)
+ completeTransactionIntervalState(state, baselineKey, [meterValue])
+ connectorStatus.transactionId = 'transaction-2'
+
+ restoreTransactionIntervalState(state, connectorStatus, baselineKey)
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
+})
import { afterEach, describe, it, mock } from 'node:test'
import { ChargingStationWorkerBroadcastChannel } from '../../../src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.js'
+import { buildConfigKey } from '../../../src/charging-station/index.js'
+import { OCPP16ServiceUtils } from '../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import { OCPP20ServiceUtils } from '../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { stopRunningTransactions } from '../../../src/charging-station/ocpp/OCPPServiceOperations.js'
import { AbstractUIService } from '../../../src/charging-station/ui-server/ui-services/AbstractUIService.js'
-import { BaseError } from '../../../src/exception/index.js'
+import { BaseError, OCPPError } from '../../../src/exception/index.js'
import {
BroadcastChannelProcedureName,
type BroadcastChannelRequestPayload,
ConfigurationStatus,
+ ErrorType,
GenericStatus,
GetCertificateStatusEnumType,
Iso15118EVCertificateStatusEnumType,
MeterValueMeasurand,
+ OCPP16AuthorizationStatus,
+ OCPP16MeterValueFormat,
+ OCPP16MeterValueMeasurand,
+ type OCPP16MeterValuesRequest,
+ OCPP16MeterValueUnit,
+ type OCPP16StartTransactionRequest,
+ OCPP16StopTransactionReason,
+ type OCPP16StopTransactionRequest,
+ OCPP16VendorParametersKey,
OCPP20AuthorizationStatusEnumType,
+ OCPP20ComponentName,
+ OCPP20MeasurandEnumType,
+ OCPP20OptionalVariableName,
+ OCPP20RequiredVariableName,
+ OCPP20TransactionEventEnumType,
+ OCPP20UnitEnumType,
OCPPVersion,
ProcedureName,
+ PublicKeyWithSignedMeterValueEnumType,
RequestCommand,
+ type RequestParams,
ResponseStatus,
+ SigningMethodEnumType,
+ StandardParametersKey,
+ VendorParametersKey,
} from '../../../src/types/index.js'
import { Constants } from '../../../src/utils/index.js'
-import { flushMicrotasks, standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
-import { TEST_TRANSACTION_ID_STRING } from '../ChargingStationTestConstants.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../helpers/TestLifecycleHelpers.js'
+import { TEST_PUBLIC_KEY_HEX, TEST_TRANSACTION_ID_STRING } from '../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../helpers/StationHelpers.js'
+import {
+ createMeterValuesTemplate,
+ setMockRequestHandler,
+ upsertConfigurationKey,
+} from '../ocpp/1.6/OCPP16TestUtils.js'
import { createMockStationWithRequestTracking } from '../ocpp/2.0/OCPP20TestUtils.js'
// ============================================================================
commandResponse: unknown
) => ResponseStatus
requestHandler: (messageEvent: { data: unknown }) => void
+ requestParams: RequestParams
}
/**
commandHandlers: testable.commandHandlers,
commandResponseToResponseStatus: testable.commandResponseToResponseStatus.bind(instance),
requestHandler: testable.requestHandler.bind(instance),
+ requestParams: testable.requestParams,
}
}
}
})
+ await describe('START_TRANSACTION handler', async () => {
+ await it('should let stop await a worker StartTransaction before sending StopTransaction', async () => {
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const startResponseGate = Promise.withResolvers<undefined>()
+ const startRequestStarted = Promise.withResolvers<undefined>()
+ const commands: RequestCommand[] = []
+ setMockRequestHandler(station, async (...args: unknown[]) => {
+ const command = args[1] as RequestCommand
+ commands.push(command)
+ if (command === RequestCommand.START_TRANSACTION) {
+ startRequestStarted.resolve(undefined)
+ await startResponseGate.promise
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 91
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ if (command === RequestCommand.STOP_TRANSACTION) {
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.START_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ const startPromise = handler({ connectorId: 1, idTag: 'WORKER-ID-TAG' })
+ assert.ok(startPromise != null)
+ await startRequestStarted.promise
+ const stopPromise = stopRunningTransactions(station)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(commands, [RequestCommand.START_TRANSACTION])
+ startResponseGate.resolve(undefined)
+ await Promise.all([startPromise, stopPromise])
+ assert.deepStrictEqual(commands, [
+ RequestCommand.START_TRANSACTION,
+ RequestCommand.STATUS_NOTIFICATION,
+ RequestCommand.STOP_TRANSACTION,
+ ])
+ })
+
+ await it('should preserve optional StartTransaction payload fields through tracked routing', async () => {
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const timestamp = new Date('2026-01-02T03:04:05.000Z')
+ const payload: OCPP16StartTransactionRequest = {
+ connectorId: 1,
+ idTag: 'WORKER-ID-TAG',
+ meterStart: 123,
+ reservationId: 7,
+ timestamp,
+ }
+ let sentPayload: Partial<OCPP16StartTransactionRequest> | undefined
+ setMockRequestHandler(station, (...args: unknown[]) => {
+ sentPayload = args[2] as Partial<OCPP16StartTransactionRequest>
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.START_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ await handler(payload)
+
+ assert.deepStrictEqual(sentPayload, payload)
+ })
+ })
+
// CHANGE_CONFIGURATION command handler: payload validation + delegation
await describe('CHANGE_CONFIGURATION handler', async () => {
})
})
+ await describe('STOP_TRANSACTION handler', async () => {
+ await it('should preserve valid UI overrides and transport params while using canonical transaction identity and meterStop', async () => {
+ const response = { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ const requestHandler = mock.fn((...args: unknown[]) =>
+ Promise.resolve(args[1] === RequestCommand.STOP_TRANSACTION ? response : {})
+ )
+ const { station } = createMockChargingStation({
+ connectorsCount: 2,
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 2, { energyImport: 1234, transactionId: 202 })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+ const timestamp = '2026-09-08T10:00:00.000Z'
+ const transactionData = [{ sampledValue: [], timestamp: new Date(timestamp) }]
+
+ const result = await handler({
+ connectorId: 1,
+ idTag: 'UI-ID-TAG',
+ meterStop: 9999,
+ reason: OCPP16StopTransactionReason.EMERGENCY_STOP,
+ timestamp,
+ transactionData,
+ transactionId: 202,
+ })
+
+ assert.strictEqual(result, response)
+ const stopCall = requestHandler.mock.calls.find(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ )
+ assert.ok(stopCall != null)
+ assert.deepStrictEqual(stopCall.arguments[2], {
+ idTag: 'UI-ID-TAG',
+ meterStop: 1234,
+ reason: OCPP16StopTransactionReason.EMERGENCY_STOP,
+ timestamp: new Date(timestamp),
+ transactionData,
+ transactionId: 202,
+ })
+ const {
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ...requestOptions
+ } = stopCall.arguments[3] as RequestParams
+ for (const callback of [
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ]) {
+ assert.strictEqual(typeof callback, 'function')
+ }
+ assert.deepStrictEqual(requestOptions, {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ rawPayload: true,
+ skipBufferingOnError: false,
+ throwError: true,
+ })
+ })
+
+ await it('should normalize a valid UI timestamp before generating and sending signed data', async () => {
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return Promise.resolve({
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId: 606 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+ const timestamp = '2026-09-08T10:00:00.123+02:00'
+
+ await handler({ timestamp, transactionId: 606 })
+
+ assert.ok(stopPayload != null)
+ assert.ok(stopPayload.timestamp instanceof Date)
+ assert.strictEqual(stopPayload.timestamp.getTime(), new Date(timestamp).getTime())
+ const signedSample = stopPayload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(signedSample != null)
+ const signedMeterValue = JSON.parse(signedSample.value) as {
+ publicKey: string
+ signedMeterData: string
+ }
+ assert.notStrictEqual(signedMeterValue.publicKey, '')
+ assert.match(
+ Buffer.from(signedMeterValue.signedMeterData, 'base64').toString(),
+ /2026-09-08T08:00:00.123Z/
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should default an omitted timestamp and preserve valid Date and RFC3339 instants', async () => {
+ const stopTimestamps: Date[] = []
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] === RequestCommand.STOP_TRANSACTION) {
+ stopTimestamps.push((args[2] as OCPP16StopTransactionRequest).timestamp)
+ }
+ return Promise.resolve({
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ })
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 608 })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ const beforeDefault = Date.now()
+ await handler({ transactionId: 608 })
+ const afterDefault = Date.now()
+ assert.ok(stopTimestamps[0] instanceof Date)
+ assert.ok(stopTimestamps[0].getTime() >= beforeDefault)
+ assert.ok(stopTimestamps[0].getTime() <= afterDefault)
+
+ const dateTimestamp = new Date('2026-09-08T10:00:00.321Z')
+ await handler({ timestamp: dateTimestamp, transactionId: 608 })
+ assert.notStrictEqual(stopTimestamps[1], dateTimestamp)
+ assert.strictEqual(stopTimestamps[1].getTime(), dateTimestamp.getTime())
+
+ const stringTimestamps = [
+ ['2026-09-08T10:00:00.123456Z', '2026-09-08T10:00:00.123Z'],
+ ['2026-09-08T10:00:00.987+05:30', '2026-09-08T04:30:00.987Z'],
+ ] as const
+ for (const [timestamp, expectedInstant] of stringTimestamps) {
+ await handler({ timestamp, transactionId: 608 })
+ assert.strictEqual(stopTimestamps.at(-1)?.toISOString(), expectedInstant)
+ }
+ })
+
+ await it('should reject invalid explicit UI timestamps before any transaction mutation', async () => {
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 607 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(
+ () => undefined,
+ 60_000
+ )
+ const activeTimer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+ const invalidTimestamps: unknown[] = [
+ null,
+ undefined,
+ 0,
+ 'not-an-ISO-timestamp',
+ '2026-09-08T10:00:00',
+ '2026-02-30T10:00:00Z',
+ '2026-09-08T24:00:00Z',
+ '2026-09-08T10:00:00+24:00',
+ new Date(Number.NaN),
+ ]
+
+ for (const timestamp of invalidTimestamps) {
+ await assert.rejects(
+ async () =>
+ await handler({
+ timestamp,
+ transactionId: 607,
+ } as unknown as BroadcastChannelRequestPayload),
+ (error: Error) => {
+ assert.ok(error instanceof OCPPError)
+ assert.strictEqual(error.code, ErrorType.FORMAT_VIOLATION)
+ return true
+ }
+ )
+ }
+
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, activeTimer)
+ })
+
+ await it('should reject OCPP 2.x before transaction lookup and preserve active timers', async () => {
+ const { sentRequests, station } = createMockStationWithRequestTracking()
+ setupConnectorWithTransaction(station, 1, { transactionId: 609 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(
+ () => undefined,
+ 60_000
+ )
+ connectorStatus.transactionEndedMeterValuesSetInterval = setInterval(() => undefined, 60_000)
+ const updatedTimer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ const endedTimer = connectorStatus.transactionEndedMeterValuesSetInterval
+ const transactionLookup = mock.method(station, 'getConnectorIdByTransactionId', () => 1)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ await assert.rejects(
+ async () => await handler({ transactionId: 609 }),
+ (error: Error) => error instanceof BaseError && error.message.includes('OCPP 1.6')
+ )
+
+ assert.strictEqual(transactionLookup.mock.callCount(), 0)
+ assert.strictEqual(sentRequests.length, 0)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, updatedTimer)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, endedTimer)
+ })
+
+ await it('should join an overlapping station stop and return the exact same wire response', async () => {
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ const stopResponse = Promise.withResolvers<{
+ idTagInfo: { status: OCPP16AuthorizationStatus }
+ }>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === RequestCommand.STOP_TRANSACTION) {
+ stopRequestStarted.resolve(undefined)
+ return await stopResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ started: true,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 303 })
+ let stationStopResult: unknown
+ station.stop = async () => {
+ stationStopResult = await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ }
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const stopStationHandler = testable.commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_CHARGING_STATION
+ )
+ const stopTransactionHandler = testable.commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(stopStationHandler != null)
+ assert.ok(stopTransactionHandler != null)
+
+ const stationStop = stopStationHandler({})
+ await stopRequestStarted.promise
+ const uiStop = stopTransactionHandler({ transactionId: 303 })
+ const expectedResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ }
+ stopResponse.resolve(expectedResponse)
+ const uiStopResult = await uiStop
+ await stationStop
+
+ assert.strictEqual(uiStopResult, expectedResponse)
+ assert.strictEqual(stationStopResult, expectedResponse)
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+ })
+
+ await it('should reject a UI join when a station-initiated StopTransaction fails', async () => {
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ const stopResponse = Promise.withResolvers<unknown>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === RequestCommand.STOP_TRANSACTION) {
+ stopRequestStarted.resolve(undefined)
+ return await stopResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 404 })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ const stationStop = OCPP16ServiceUtils.stopTransactionOnConnector(
+ station,
+ 1,
+ undefined,
+ {},
+ { responseTimeoutMs: 50, throwError: false }
+ )
+ await stopRequestStarted.promise
+ const uiStop = handler({ transactionId: 404 })
+ const failure = new Error('StopTransaction delivery failed')
+ stopResponse.reject(failure)
+ const results = await Promise.allSettled([stationStop, uiStop])
+
+ assert.deepStrictEqual(
+ results.map(result => result.status),
+ ['rejected', 'rejected']
+ )
+ assert.ok(results.every(result => result.status === 'rejected' && result.reason === failure))
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+ const stopCall = requestHandler.mock.calls.find(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ )
+ const {
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ...requestOptions
+ } = stopCall?.arguments[3] as RequestParams
+ for (const callback of [
+ onError,
+ onMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ]) {
+ assert.strictEqual(typeof callback, 'function')
+ }
+ assert.deepStrictEqual(requestOptions, {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ rawPayload: true,
+ responseTimeoutMs: 50,
+ skipBufferingOnError: false,
+ throwError: true,
+ })
+ })
+
+ await it('should coalesce overlapping remote and UI stops before Finishing completes', async () => {
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const statusResponse = Promise.withResolvers<unknown>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ const stopResponse = Promise.withResolvers<{
+ idTagInfo: { status: OCPP16AuthorizationStatus }
+ }>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === RequestCommand.STOP_TRANSACTION) {
+ stopRequestStarted.resolve(undefined)
+ return await stopResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 505 })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ const remoteStop = OCPP16ServiceUtils.remoteStopTransaction(station, 1)
+ await statusRequestStarted.promise
+ const uiStop = handler({ transactionId: 505 })
+
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === RequestCommand.STATUS_NOTIFICATION
+ ).length,
+ 1
+ )
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ ).length,
+ 0
+ )
+
+ statusResponse.resolve({})
+ await stopRequestStarted.promise
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+ const expectedResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ }
+ stopResponse.resolve(expectedResponse)
+
+ assert.strictEqual(await uiStop, expectedResponse)
+ assert.deepStrictEqual(await remoteStop, { status: GenericStatus.Accepted })
+ })
+
+ await it('should reject a missing transactionId with a typed error before sending', async () => {
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ await assert.rejects(async () => {
+ await handler({})
+ }, BaseError)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ })
+
+ await it('should reject an unknown transactionId with a typed error before sending', async () => {
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const handler = createTestableWorkerBroadcastChannel(instance).commandHandlers.get(
+ BroadcastChannelProcedureName.STOP_TRANSACTION
+ )
+ assert.ok(handler != null)
+
+ await assert.rejects(
+ async () => {
+ await handler({ transactionId: 404 })
+ },
+ (error: Error) => error instanceof BaseError && error.message.includes("'404'")
+ )
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ })
+ })
+
+ await describe('METER_VALUES handler delivery accounting', async () => {
+ const createGeneratedMeterValuesStation = (
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ ) => {
+ const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 })
+ setMockRequestHandler(station, requestHandler)
+ setupConnectorWithTransaction(station, 1, { transactionId: 606 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ upsertConfigurationKey(
+ station,
+ StandardParametersKey.MeterValuesSampledData,
+ `${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 10 }
+ return { connectorStatus, station }
+ }
+
+ const runGeneratedMeterValuesFailure = async (
+ configureDelivery: (params: RequestParams, failure: OCPPError) => void,
+ configureCallerCallbacks?: (params: RequestParams) => void,
+ afterSettled?: (params: RequestParams, failure: OCPPError) => void
+ ): Promise<{ carry: number; keyReserved: boolean; relayedCalls: number }> => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'MeterValues failed')
+ const relayed = mock.fn()
+ let deliveredParams: RequestParams | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ deliveredParams = args[3] as RequestParams
+ configureDelivery(deliveredParams, failure)
+ return Promise.reject(failure)
+ })
+ const { connectorStatus, station } = createGeneratedMeterValuesStation(requestHandler)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ configureCallerCallbacks?.(testable.requestParams)
+ if (testable.requestParams.onMessageSent != null) {
+ const callback = testable.requestParams.onMessageSent
+ testable.requestParams.onMessageSent = () => {
+ relayed()
+ callback()
+ }
+ }
+ if (testable.requestParams.onTransportError != null) {
+ const callback = testable.requestParams.onTransportError
+ testable.requestParams.onTransportError = (error, deliveryAmbiguous) => {
+ relayed()
+ callback(error, deliveryAmbiguous)
+ }
+ }
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, { connectorId: 1 }),
+ error => error === failure
+ )
+ if (deliveredParams == null) throw new Error('MeterValues request was not delivered')
+ afterSettled?.(deliveredParams, failure)
+
+ return {
+ carry: connectorStatus.transactionEnergyActiveImportIntervalCarry?.default ?? 0,
+ keyReserved: connectorStatus.publicKeySentInTransaction === true,
+ relayedCalls: relayed.mock.callCount(),
+ }
+ }
+
+ await it('should reject a malformed meterValue entry with a typed error', async () => {
+ const { station } = createMockChargingStation({ ocppVersion: OCPPVersion.VERSION_16 })
+ setupConnectorWithTransaction(station, 1, { transactionId: 707 })
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ meterValue: [{}],
+ }),
+ (error: unknown) =>
+ error instanceof BaseError &&
+ error.message.includes('meterValue.sampledValue must be an array')
+ )
+ })
+
+ await it('should restore generated interval energy after a certain pre-send failure', async () => {
+ const result = await runGeneratedMeterValuesFailure(
+ (params, failure) => params.onTransportError?.(failure, false),
+ params => {
+ params.onTransportError = () => undefined
+ }
+ )
+
+ assert.deepStrictEqual(result, { carry: 10, keyReserved: false, relayedCalls: 1 })
+ })
+
+ await it('should preserve ambiguous delivery consumption and restore confirmed CALLERROR', async () => {
+ const sent = await runGeneratedMeterValuesFailure(
+ params => params.onMessageSent?.(),
+ params => {
+ params.onMessageSent = () => undefined
+ }
+ )
+ const ambiguous = await runGeneratedMeterValuesFailure(
+ (params, failure) => params.onTransportError?.(failure, true),
+ params => {
+ params.onTransportError = () => undefined
+ }
+ )
+ const callError = await runGeneratedMeterValuesFailure(
+ (params, failure) => {
+ params.onMessageSent?.()
+ params.onError?.(failure, true)
+ },
+ params => {
+ params.onMessageSent = () => undefined
+ }
+ )
+
+ assert.deepStrictEqual(sent, { carry: 0, keyReserved: true, relayedCalls: 1 })
+ assert.deepStrictEqual(ambiguous, { carry: 0, keyReserved: true, relayedCalls: 1 })
+ assert.deepStrictEqual(callError, { carry: 10, keyReserved: false, relayedCalls: 1 })
+ })
+
+ await it('should restore a buffered generated interval after replay returns CALLERROR', async () => {
+ const result = await runGeneratedMeterValuesFailure(
+ params => params.onRequestBuffered?.(),
+ undefined,
+ (params, failure) => params.onError?.(failure, true)
+ )
+
+ assert.deepStrictEqual(result, { carry: 10, keyReserved: false, relayedCalls: 0 })
+ })
+
+ await it('should preserve ambiguous generated consumption after replay returns CALLERROR', async () => {
+ const result = await runGeneratedMeterValuesFailure(
+ (params, failure) => {
+ params.onTransportError?.(failure, true)
+ params.onRequestBuffered?.()
+ },
+ undefined,
+ (params, failure) => params.onError?.(failure, true)
+ )
+
+ assert.deepStrictEqual(result, { carry: 0, keyReserved: true, relayedCalls: 0 })
+ })
+
+ await it('should reject a concurrent generated request without adding barrier backlog', async () => {
+ const firstResponse = Promise.withResolvers<unknown>()
+ const requests: OCPP16MeterValuesRequest[] = []
+ let firstRequestParams: RequestParams | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ requests.push(args[2] as OCPP16MeterValuesRequest)
+ const requestParams = args[3] as RequestParams
+ if (requests.length === 1) {
+ firstRequestParams = requestParams
+ return firstResponse.promise
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createGeneratedMeterValuesStation(requestHandler)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const firstRequest = testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ })
+ void firstRequest.catch(() => undefined)
+ await flushMicrotasks()
+ const secondRequest = testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ })
+ const secondOutcome: unknown = await Promise.race([
+ secondRequest.then(
+ () => 'resolved',
+ (error: unknown) => error
+ ),
+ flushMicrotasks().then(() => 'pending'),
+ ])
+
+ await flushMicrotasks()
+ assert.ok(
+ secondOutcome instanceof BaseError &&
+ secondOutcome.message.includes('MeterValues delivery is already in progress')
+ )
+ await assert.rejects(secondRequest, error => error === secondOutcome)
+ assert.strictEqual(requests.length, 1)
+ assert.ok(firstRequestParams != null)
+ const callError = new OCPPError(ErrorType.GENERIC_ERROR, 'predecessor CALLERROR')
+ firstRequestParams.onError?.(callError, true)
+ firstResponse.reject(callError)
+ await assert.rejects(firstRequest, error => error === callError)
+ await testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ })
+ assert.strictEqual(requests.length, 2)
+ const rebuiltSamples = requests[1].meterValue.flatMap(meterValue => meterValue.sampledValue)
+ assert.strictEqual(
+ rebuiltSamples.find(
+ sampledValue =>
+ sampledValue.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ '10'
+ )
+ const firstSignedSample = requests[0].meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ const rebuiltSignedSample = rebuiltSamples.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(firstSignedSample != null)
+ assert.ok(rebuiltSignedSample != null)
+ const firstSignedPayload = JSON.parse(firstSignedSample.value) as { publicKey?: string }
+ const rebuiltSignedPayload = JSON.parse(rebuiltSignedSample.value) as { publicKey?: string }
+ assert.ok(firstSignedPayload.publicKey != null && firstSignedPayload.publicKey.length > 0)
+ assert.strictEqual(rebuiltSignedPayload.publicKey, firstSignedPayload.publicKey)
+ })
+
+ await it('should make a caller-supplied OCPP 1.6 key own OncePerTransaction delivery', async () => {
+ const requests: OCPP16MeterValuesRequest[] = []
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ requests.push(args[2] as OCPP16MeterValuesRequest)
+ const params = args[3] as RequestParams
+ params.onMessageSent?.()
+ params.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { connectorStatus, station } = createGeneratedMeterValuesStation(requestHandler)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const customMeterValue = {
+ sampledValue: [
+ {
+ format: OCPP16MeterValueFormat.SIGNED_DATA,
+ value: JSON.stringify({
+ encodingMethod: 'OCMF',
+ publicKey: 'caller-public-key',
+ signedMeterData: 'caller-signed-data',
+ signingMethod: '',
+ }),
+ },
+ ],
+ timestamp: new Date(),
+ }
+
+ await testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ meterValue: [customMeterValue],
+ })
+ await testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, { connectorId: 1 })
+
+ assert.strictEqual(requests.length, 2)
+ assert.strictEqual(requests[0].meterValue[0], customMeterValue)
+ const generatedSignedValue = requests[1].meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(generatedSignedValue != null)
+ assert.strictEqual(
+ (JSON.parse(generatedSignedValue.value) as { publicKey?: string }).publicKey,
+ ''
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should release a caller-supplied OCPP 1.6 key after definite pre-send failure', async () => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'custom MeterValues not sent')
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ const params = args[3] as RequestParams
+ params.onTransportError?.(failure, false)
+ return Promise.reject(failure)
+ })
+ const { connectorStatus, station } = createGeneratedMeterValuesStation(requestHandler)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ format: OCPP16MeterValueFormat.SIGNED_DATA,
+ value: JSON.stringify({
+ encodingMethod: 'OCMF',
+ publicKey: 'caller-public-key',
+ signedMeterData: 'caller-signed-data',
+ signingMethod: '',
+ }),
+ },
+ ],
+ timestamp: new Date(),
+ },
+ ],
+ }),
+ error => error === failure
+ )
+
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should account for caller-supplied OCPP 2.0 public-key delivery', async () => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'custom MeterValues not sent')
+ let rejectDelivery = false
+ let deliveredRequest: unknown
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ deliveredRequest = args[2]
+ const params = args[3] as RequestParams
+ if (rejectDelivery) {
+ params.onTransportError?.(failure, false)
+ return Promise.reject(failure)
+ }
+ params.onMessageSent?.()
+ params.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_201,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_ID_STRING })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const customMeterValue = {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'caller-public-key',
+ signedMeterData: 'caller-signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ }
+
+ await testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ meterValue: [customMeterValue],
+ })
+ assert.strictEqual(
+ (deliveredRequest as { meterValue: unknown[] }).meterValue[0],
+ customMeterValue
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ connectorStatus.publicKeySentInTransaction = false
+ rejectDelivery = true
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ meterValue: [customMeterValue],
+ }),
+ error => error === failure
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should resolve OCPP 2.0 OncePerTransaction ownership by delivery certainty', async () => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'MeterValues failed')
+ let keyReservedAtDispatch = false
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ keyReservedAtDispatch =
+ station.getConnectorStatus(1, 1)?.publicKeySentInTransaction === true
+ const params = args[3] as RequestParams
+ params.onTransportError?.(failure, false)
+ return Promise.reject(failure)
+ })
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_201,
+ stationInfo: { meterSerialNumber: 'SIM-001', ocppVersion: OCPPVersion.VERSION_201 },
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_ID_STRING })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ value: 0,
+ },
+ ]
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ VendorParametersKey.SignUpdatedReadings
+ ),
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue
+ ),
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ TEST_PUBLIC_KEY_HEX
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'SigningMethod'),
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ }),
+ error => error === failure
+ )
+
+ assert.strictEqual(keyReservedAtDispatch, true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+
+ setMockRequestHandler(station, (...args: unknown[]) => {
+ const params = args[3] as RequestParams
+ params.onTransportError?.(failure, true)
+ return Promise.reject(failure)
+ })
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ }),
+ error => error === failure
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ connectorStatus.publicKeySentInTransaction = false
+ setMockRequestHandler(station, (...args: unknown[]) => {
+ const params = args[3] as RequestParams
+ params.onRequestBuffered?.()
+ return Promise.reject(failure)
+ })
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ }),
+ error => error === failure
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should settle a signed OCPP 2.0 broadcast before building Ended', async () => {
+ const broadcastStarted = Promise.withResolvers<undefined>()
+ const releaseBroadcast = Promise.withResolvers<undefined>()
+ let broadcastPublicKey: string | undefined
+ let endedPublicKey: string | undefined
+ let endedSent = false
+ const requestHandler = mock.fn(async (...args: unknown[]): Promise<unknown> => {
+ const command = args[1] as RequestCommand
+ const params = args[3] as RequestParams
+ if (command === RequestCommand.METER_VALUES) {
+ const payload = args[2] as {
+ meterValue: { sampledValue: { signedMeterValue?: { publicKey?: string } }[] }[]
+ }
+ broadcastPublicKey = payload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.signedMeterValue != null
+ )?.signedMeterValue?.publicKey
+ params.onMessageSent?.()
+ broadcastStarted.resolve(undefined)
+ await releaseBroadcast.promise
+ params.onResponseReceived?.()
+ return {}
+ }
+ const payload = args[2] as { eventType?: OCPP20TransactionEventEnumType }
+ if (payload.eventType === OCPP20TransactionEventEnumType.Ended) {
+ const endedPayload = args[2] as {
+ meterValue?: { sampledValue: { signedMeterValue?: { publicKey?: string } }[] }[]
+ }
+ endedPublicKey = endedPayload.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.signedMeterValue != null)
+ ?.signedMeterValue?.publicKey
+ endedSent = true
+ params.onMessageSent?.()
+ params.onResponseReceived?.()
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_201,
+ stationInfo: { meterSerialNumber: 'SIM-001', ocppVersion: OCPPVersion.VERSION_201 },
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.inAcceptedState = () => true
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_ID_STRING })
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ value: 0,
+ },
+ ]
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ VendorParametersKey.SignUpdatedReadings
+ ),
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue
+ ),
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ TEST_PUBLIC_KEY_HEX
+ )
+ upsertConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'SigningMethod'),
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ const broadcast = testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 1,
+ })
+ await broadcastStarted.promise
+ const stop = OCPP20ServiceUtils.requestStopTransaction(station, 1, 1)
+ await flushMicrotasks()
+
+ assert.strictEqual(endedSent, false)
+ releaseBroadcast.resolve(undefined)
+ await Promise.all([broadcast, stop])
+ assert.strictEqual(endedSent, true)
+ assert.strictEqual(typeof broadcastPublicKey, 'string')
+ assert.notStrictEqual(broadcastPublicKey, '')
+ assert.strictEqual(endedPublicKey, '')
+ })
+
+ await it('should reject a malformed OCPP 2.0.1 meterValue override before dispatch', async () => {
+ const { sentRequests, station } = createMockStationWithRequestTracking()
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+ const handler = testable.commandHandlers.get(BroadcastChannelProcedureName.METER_VALUES)
+ assert.ok(handler != null)
+
+ await assert.rejects(
+ async () => await handler({ connectorId: 1, meterValue: {} }),
+ /meterValue must be an array/
+ )
+ assert.strictEqual(sentRequests.length, 0)
+ })
+ })
+
// ==========================================================================
// Group 5: requestHandler full pipeline — exercise handler dispatch via message events
// ==========================================================================
},
]
connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING
+ connectorStatus.transactionStarted = true
}
instance = new ChargingStationWorkerBroadcastChannel(station)
assert.strictEqual(sentRequests.length, 1)
assert.strictEqual(sentRequests[0].command, RequestCommand.METER_VALUES)
+ assert.strictEqual('connectorId' in sentRequests[0].payload, false)
assert.ok(
sentRequests[0].payload.evseId != null,
'OCPP 2.0.1 meter values payload should contain evseId'
'OCPP 2.0.1 meter values payload should contain meterValue array'
)
})
+ await it('should reject an ambiguous OCPP 2.0 connector unless its EVSE is supplied', async () => {
+ const { sentRequests, station } = createMockStationWithRequestTracking()
+ const evse1 = station.getEvseStatus(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const connector1 = evse1.connectors.get(1)
+ const connector2 = evse2.connectors.get(2)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evse2.connectors.delete(2)
+ evse2.connectors.set(1, connector2)
+ connector1.transactionId = 'tx-evse-1'
+ connector1.transactionStarted = true
+ connector1.publicKeySentInTransaction = false
+ connector2.transactionId = 'tx-evse-2'
+ connector2.transactionStarted = true
+ connector2.publicKeySentInTransaction = false
+ const customMeterValue = {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'caller-public-key',
+ signedMeterData: 'caller-signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ }
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ meterValue: [customMeterValue],
+ }),
+ (error: Error) => error instanceof BaseError && error.message.includes('evseId')
+ )
+ assert.strictEqual(sentRequests.length, 0)
+ assert.strictEqual(connector1.publicKeySentInTransaction, false)
+ assert.strictEqual(connector2.publicKeySentInTransaction, false)
+
+ await assert.rejects(
+ testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 3,
+ meterValue: [customMeterValue],
+ }),
+ (error: Error) => error instanceof BaseError && error.message.includes('EVSE 3')
+ )
+ assert.strictEqual(sentRequests.length, 0)
+ assert.strictEqual(connector1.publicKeySentInTransaction, false)
+ assert.strictEqual(connector2.publicKeySentInTransaction, false)
+
+ await testable.commandHandler(BroadcastChannelProcedureName.METER_VALUES, {
+ connectorId: 1,
+ evseId: 2,
+ meterValue: [customMeterValue],
+ })
+
+ assert.strictEqual(sentRequests.length, 1)
+ assert.strictEqual(sentRequests[0].payload.evseId, 2)
+ assert.strictEqual(connector1.publicKeySentInTransaction, false)
+ assert.strictEqual(connector2.publicKeySentInTransaction, true)
+ })
+
+ await it('should route an EVSE-only meter request to its active connector', async () => {
+ const { sentRequests, station } = createMockStationWithRequestTracking()
+ const evseStatus = station.getEvseStatus(1)
+ const connector1 = station.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ connector1.transactionId = undefined
+ connector1.transactionStarted = false
+ const connector2 = {
+ ...connector1,
+ energyActiveImportRegisterValue: 222,
+ transactionEnergyActiveImportRegisterValue: 222,
+ transactionId: 'tx-active-connector-2',
+ transactionStarted: true,
+ }
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: 222,
+ },
+ ]
+ evseStatus.connectors.set(2, connector2)
+ upsertConfigurationKey(
+ station,
+ `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ testable.requestHandler({
+ data: [
+ randomUUID(),
+ BroadcastChannelProcedureName.METER_VALUES,
+ { evseId: 1, hashIds: [station.stationInfo?.hashId] },
+ ],
+ })
+ await flushMicrotasks()
+
+ assert.strictEqual(sentRequests.length, 1)
+ assert.ok(Array.isArray(sentRequests[0].payload.meterValue))
+ assert.ok(sentRequests[0].payload.meterValue.length > 0)
+ })
+
+ await it('should preserve a caller-supplied MeterValues payload', async () => {
+ const { sentRequests, station } = createMockStationWithRequestTracking()
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: 10,
+ },
+ ]
+ connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const rawMeterValue = [
+ {
+ sampledValue: [
+ {
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value: 321,
+ },
+ ],
+ timestamp: new Date(123),
+ },
+ ]
+ instance = new ChargingStationWorkerBroadcastChannel(station)
+ const testable = createTestableWorkerBroadcastChannel(instance)
+
+ testable.requestHandler({
+ data: [
+ randomUUID(),
+ BroadcastChannelProcedureName.METER_VALUES,
+ {
+ connectorId: 1,
+ hashIds: [station.stationInfo?.hashId],
+ meterValue: rawMeterValue,
+ },
+ ],
+ })
+ await flushMicrotasks()
+
+ assert.strictEqual(sentRequests.length, 1)
+ assert.deepStrictEqual(sentRequests[0].payload.meterValue, rawMeterValue)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 0)
+ })
})
await describe('mapping completeness', async () => {
* ```
*/
export function cleanupChargingStation (station: ChargingStation): void {
+ if (typeof station.stopAlignedMeterValues === 'function') station.stopAlignedMeterValues()
+
// Stop heartbeat timer
if (station.heartbeatSetInterval != null) {
clearInterval(station.heartbeatSetInterval)
* @file Factory to construct a mock ChargingStation instance for tests.
*/
-import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js'
+import type { ChargingStation } from '../../../src/charging-station/index.js'
import type {
+ CachedRequest,
ConnectorEntry,
ConnectorStatus,
EvseEntry,
} from './StationHelpers.types.js'
import { getConfigurationKey } from '../../../src/charging-station/index.js'
+import { type CoherentSession } from '../../../src/charging-station/meter-values/index.js'
+import { OCPPError } from '../../../src/exception/index.js'
import {
AvailabilityType,
CurrentType,
+ ErrorType,
+ MessageType,
OCPPVersion,
RegistrationStatusEnumType,
StandardParametersKey,
import { MockWebSocket, WebSocketReadyState } from '../mocks/MockWebSocket.js'
import { createConnectorStatus, determineEvseUsage } from './StationHelpers.connector.js'
+interface BufferedMessageCallbackReservation {
+ readonly bytes: number
+ readonly onCancelled?: () => void
+}
+
+interface BufferedMessageCallbacks {
+ readonly onDiscarded?: () => void
+ readonly onSent?: () => void
+ readonly retainedBytes?: number
+}
+
+interface BufferedMessageEntry {
+ callbacks?: BufferedMessageCallbacks
+ reservation?: BufferedMessageCallbackReservation
+}
+
/**
* Creates a minimal mock ChargingStation-like object for testing
*
}
// Create requests map
- const requests = new Map<string, unknown>()
+ const requests = new Map<string, CachedRequest>()
// Create the station object that mimics ChargingStation
const station = {
__injectCoherentSession (transactionId: number | string, session: CoherentSession): void {
this.coherentSessions.set(transactionId, session)
},
+ acknowledgedBufferedMessages: new Set<string>(),
addReservation (reservation: Record<string, unknown>): void {
// Check if reservation with same ID exists and remove it
const existingReservation = this.getReservationBy(
connectorStatus.reservation = reservation as unknown as Reservation
}
},
- automaticTransactionGenerator: undefined,
+ automaticTransactionGenerator: undefined,
bootNotificationRequest: undefined,
bootNotificationResponse: {
interval: number
status: RegistrationStatusEnumType
},
- bufferMessage (message: string): void {
- this.messageQueue.push(message)
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageCallbackReservations: new Set<BufferedMessageCallbackReservation>(),
+ bufferedMessageEntries: [] as BufferedMessageEntry[],
+ bufferedMessageInFlight: undefined as
+ undefined | { isRequest: boolean; message: string; retracted: boolean; stopDrain?: boolean },
+ bufferMessage (
+ message: string,
+ prepend = false,
+ callbacks?: BufferedMessageCallbacks,
+ callbackReservation?: BufferedMessageCallbackReservation
+ ): boolean {
+ const callbackBytes =
+ callbacks == null
+ ? 0
+ : Math.max(
+ Buffer.byteLength(message, 'utf8'),
+ Number.isFinite(callbacks.retainedBytes) ? (callbacks.retainedBytes ?? 0) : 0
+ )
+ const reservation =
+ callbacks == null
+ ? undefined
+ : (callbackReservation ?? this.reserveBufferedMessageCallbacks(callbackBytes))
+ if (
+ callbacks != null &&
+ (reservation == null || !this.bufferedMessageCallbackReservations.has(reservation))
+ ) {
+ callbacks.onDiscarded?.()
+ return false
+ }
+ const entry: BufferedMessageEntry = { callbacks, reservation }
+ if (prepend) {
+ this.messageQueue.unshift(message)
+ this.bufferedMessageEntries.unshift(entry)
+ } else {
+ this.messageQueue.push(message)
+ this.bufferedMessageEntries.push(entry)
+ }
+ return true
+ },
+ clearMessageBuffer (): void {
+ this.acknowledgedBufferedMessages.clear()
+ this.messageQueue.length = 0
+ for (const { callbacks, reservation } of this.bufferedMessageEntries) {
+ callbacks?.onDiscarded?.()
+ this.releaseBufferedMessageCallbacks(reservation)
+ }
+ this.bufferedMessageEntries.length = 0
},
closeWSConnection (): void {
if (this.wsConnection != null) {
},
// Coherent MeterValues session store (real class uses a private Map).
coherentSessions: new Map<number | string, CoherentSession>(),
-
connectors,
+
createCoherentSession (
_transactionId: number | string,
_connectorId: number
if (this.started) {
await this.stop()
}
- this.requests.clear()
+ this.ocppRequestService.cancelPendingRequests(
+ this,
+ 'Charging station deleted while awaiting an OCPP response',
+ true
+ )
this.connectors.clear()
this.evses.clear()
// Note: deleteConfiguration controls file deletion in real implementation
// Mock doesn't have file system access, so parameter is unused
},
-
destroyCoherentSession (transactionId: number | string | undefined): boolean {
if (transactionId == null) {
return false
}
return this.coherentSessions.delete(transactionId)
},
+
// Event emitter methods (minimal implementation)
emit: () => true,
// Empty implementations for interface compatibility
// eslint-disable-next-line @typescript-eslint/no-empty-function
emitChargingStationEvent: () => {},
evses,
+ flushMessageBuffer (): void {
+ /* empty */
+ },
getAuthorizeRemoteTxRequests (): boolean {
return false // Default to false in mock
},
+ getBufferedRequestIds (): Set<string> {
+ if (this.acknowledgedBufferedMessages.size > 0) {
+ this.messageQueue.unshift(...this.acknowledgedBufferedMessages.values())
+ this.acknowledgedBufferedMessages.clear()
+ }
+ const messageIds = new Set<string>()
+ for (const message of [...this.messageQueue, ...this.acknowledgedBufferedMessages.values()]) {
+ try {
+ const parsedMessage = JSON.parse(message) as unknown[]
+ if (
+ parsedMessage[0] === MessageType.CALL_MESSAGE &&
+ typeof parsedMessage[1] === 'string'
+ ) {
+ messageIds.add(parsedMessage[1])
+ }
+ } catch {
+ // Ignore malformed frames: they cannot identify a pending CALL.
+ }
+ }
+ return messageIds
+ },
getCoherentSession (transactionId: number | string): CoherentSession | undefined {
return this.coherentSessions.get(transactionId)
},
({ connectorStatus }) => connectorStatus.transactionId === transactionId
)?.connectorId
},
- getConnectorMaximumAvailablePower (_connectorId: number): number {
+ getConnectorMaximumAvailablePower (_connectorId: number, _evseId?: number): number {
return stationInfoOverrides?.maximumPower ?? 22000
},
- getConnectorStatus (connectorId: number): ConnectorStatus | undefined {
+ getConnectorStatus (connectorId: number, evseId?: number): ConnectorStatus | undefined {
+ if (evseId != null) return this.getEvseStatus(evseId)?.connectors.get(connectorId)
return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId)
?.connectorStatus
},
- getEnergyActiveImportRegisterByConnectorId (connectorId: number, rounded = false): number {
- const connectorStatus = this.getConnectorStatus(connectorId)
+ getEnergyActiveImportRegisterByConnectorId (
+ connectorId: number,
+ rounded = false,
+ evseId?: number
+ ): number {
+ const connectorStatus = this.getConnectorStatus(connectorId, evseId)
if (connectorStatus == null) {
return 0
}
- const value = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ // `!== false` mirrors production's effective default: DEFAULT_STATION_INFO
+ // seeds meteringPerTransaction to true (Constants.ts), so an unset field
+ // reads transaction-scoped like ChargingStation.getEnergyActiveImportRegister.
+ const value =
+ this.stationInfo.meteringPerTransaction !== false
+ ? (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0)
+ : (connectorStatus.energyActiveImportRegisterValue ?? 0)
return rounded ? Math.round(value) : value
},
getEnergyActiveImportRegisterByTransactionId (
transactionId: number | string | undefined,
rounded = false
): number {
- const connectorId = this.getConnectorIdByTransactionId(transactionId)
- if (connectorId == null) {
+ const entry =
+ transactionId != null
+ ? this.iterateConnectors().find(
+ ({ connectorStatus }) => connectorStatus.transactionId === transactionId
+ )
+ : undefined
+ if (entry == null) {
return 0
}
- return this.getEnergyActiveImportRegisterByConnectorId(connectorId, rounded)
+ return this.getEnergyActiveImportRegisterByConnectorId(
+ entry.connectorId,
+ rounded,
+ entry.evseId
+ )
},
getEvseIdByConnectorId (connectorId: number): number | undefined {
return this.iterateConnectors().find(({ connectorId: id }) => id === connectorId)?.evseId
return evses.has(0) ? evses.size - 1 : evses.size
},
getNumberOfPhases (): number {
- return stationInfoOverrides?.numberOfPhases ?? 3
+ return this.stationInfo.numberOfPhases
},
getNumberOfRunningTransactions (): number {
return this.iterateConnectors(true).reduce(
)?.connectorStatus.transactionIdTag
},
getVoltageOut (): number {
- return stationInfoOverrides?.voltageOut ?? 230
+ return this.stationInfo.voltageOut
},
getWebSocketPingInterval (): number {
return websocketPingInterval
},
+ hasBufferedRequest (messageId: string): boolean {
+ return this.getBufferedRequestIds().has(messageId)
+ },
hasConnector (connectorId: number): boolean {
return this.iterateConnectors().some(({ connectorId: id }) => id === connectorId)
return reservation == null
},
+ isStopping (): boolean {
+ return this.stopping
+ },
+
isWebSocketConnectionOpened (): boolean {
return this.wsConnection?.readyState === WebSocketReadyState.OPEN
},
}
},
+ lifecycleAbortSignal: new AbortController().signal,
+
listenerCount: () => 0,
lockConnector (connectorId: number): void {
},
...ocppIncomingRequestService,
},
-
ocppRequestService: {
+ acquireOutgoingCall: () => Promise.resolve(),
+ cancelPendingRequests: (
+ targetStation: Pick<
+ ChargingStation,
+ 'clearMessageBuffer' | 'hasBufferedRequest' | 'requests'
+ >,
+ message = 'Charging station stopped while awaiting an OCPP response',
+ discardBufferedRequests = false
+ ): void => {
+ const cancellationError = new OCPPError(ErrorType.GENERIC_ERROR, message)
+ for (const [messageId, [, errorCallback]] of [...targetStation.requests.entries()]) {
+ if (!discardBufferedRequests && targetStation.hasBufferedRequest(messageId)) continue
+ targetStation.requests.delete(messageId)
+ errorCallback(cancellationError, false)
+ }
+ if (discardBufferedRequests) targetStation.clearMessageBuffer()
+ },
+ releaseOutgoingCall: () => undefined,
requestHandler: async () => {
return await Promise.reject(
new Error(
)
)
},
+ validateRequestPayload: () => true,
...ocppRequestService,
},
performanceStatistics: undefined,
+ persistTransactionEventQueues (): Promise<void> {
+ return Promise.resolve()
+ },
+
powerDivider: 1,
+ releaseAllBufferedMessageCallbacks (): void {
+ const discardedCallbacks: BufferedMessageCallbacks[] = []
+ for (
+ let messageIndex = this.bufferedMessageEntries.length - 1;
+ messageIndex >= 0;
+ messageIndex--
+ ) {
+ const entry = this.bufferedMessageEntries[messageIndex]
+ if (entry.callbacks == null) continue
+ const [message] = this.messageQueue.splice(messageIndex, 1)
+ this.bufferedMessageEntries.splice(messageIndex, 1)
+ this.releaseBufferedMessageCallbacks(entry.reservation)
+ if (this.bufferedMessageInFlight?.message === message) {
+ this.bufferedMessageInFlight.retracted = true
+ }
+ discardedCallbacks.unshift(entry.callbacks)
+ }
+ const reservations = [...this.bufferedMessageCallbackReservations]
+ this.bufferedMessageCallbackReservations.clear()
+ this.bufferedMessageCallbackCount = 0
+ this.bufferedMessageCallbackBytes = 0
+ for (const callbacks of discardedCallbacks) callbacks.onDiscarded?.()
+ for (const reservation of reservations) reservation.onCancelled?.()
+ },
+
+ releaseBufferedMessageCallbacks (
+ reservation: BufferedMessageCallbackReservation | undefined
+ ): void {
+ if (reservation == null || !this.bufferedMessageCallbackReservations.delete(reservation)) {
+ return
+ }
+ --this.bufferedMessageCallbackCount
+ this.bufferedMessageCallbackBytes -= reservation.bytes
+ },
removeAllListeners: () => station,
+ removeBufferedMessage (message: string): boolean {
+ const messageIndex = this.messageQueue.indexOf(message)
+ if (messageIndex === -1) return this.acknowledgedBufferedMessages.delete(message)
+ this.messageQueue.splice(messageIndex, 1)
+ const [entry] = this.bufferedMessageEntries.splice(messageIndex, 1)
+ entry.callbacks?.onDiscarded?.()
+ this.releaseBufferedMessageCallbacks(entry.reservation)
+ this.acknowledgedBufferedMessages.delete(message)
+ if (messageIndex === 0 && this.bufferedMessageInFlight?.message === message) {
+ this.bufferedMessageInFlight.retracted = true
+ }
+ return true
+ },
+
+ removeBufferedRequest (messageId: string): boolean {
+ const messageIndex = this.messageQueue.findIndex(message => {
+ try {
+ const parsedMessage = JSON.parse(message) as unknown[]
+ return parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId
+ } catch {
+ return false
+ }
+ })
+ if (messageIndex >= 0) {
+ const [message] = this.messageQueue.splice(messageIndex, 1)
+ const [entry] = this.bufferedMessageEntries.splice(messageIndex, 1)
+ entry.callbacks?.onDiscarded?.()
+ this.releaseBufferedMessageCallbacks(entry.reservation)
+ this.acknowledgedBufferedMessages.delete(message)
+ if (messageIndex === 0 && this.bufferedMessageInFlight?.message === message) {
+ this.bufferedMessageInFlight.retracted = true
+ }
+ return true
+ }
+ for (const message of this.acknowledgedBufferedMessages) {
+ try {
+ const parsedMessage = JSON.parse(message) as unknown[]
+ if (parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId) {
+ return this.acknowledgedBufferedMessages.delete(message)
+ }
+ } catch {
+ // Malformed frames cannot match a request id.
+ }
+ }
+ return false
+ },
+
removeListener: () => station,
removeReservation (reservation: Record<string, unknown>, _reason?: string): void {
delete connectorStatus.reservation
}
},
+
requests,
+ reserveBufferedMessageCallbacks (
+ retainedBytes: number,
+ onCancelled?: () => void
+ ): BufferedMessageCallbackReservation | undefined {
+ const bytes = Math.max(0, Number.isFinite(retainedBytes) ? retainedBytes : 0)
+ if (
+ this.bufferedMessageCallbackCount >= 1024 ||
+ this.bufferedMessageCallbackBytes + bytes > 1024 * 1024
+ ) {
+ return undefined
+ }
+ const reservation = { bytes, onCancelled }
+ this.bufferedMessageCallbackReservations.add(reservation)
+ ++this.bufferedMessageCallbackCount
+ this.bufferedMessageCallbackBytes += bytes
+ return reservation
+ },
+
restartHeartbeat (): void {
this.stopHeartbeat()
this.startHeartbeat()
/* empty */
},
+ retainBufferedMessage (message: string): boolean {
+ const messageIndex = this.messageQueue.indexOf(message)
+ if (messageIndex !== -1) {
+ if (messageIndex === 0 && this.bufferedMessageInFlight?.message === message) {
+ this.bufferedMessageInFlight.retracted = true
+ this.bufferedMessageInFlight.stopDrain = true
+ }
+ return true
+ }
+ if (!this.acknowledgedBufferedMessages.has(message)) return false
+ this.messageQueue.unshift(...this.acknowledgedBufferedMessages.values())
+ this.acknowledgedBufferedMessages.clear()
+ return true
+ },
+
+ retainBufferedRequest (messageId: string): boolean {
+ for (const message of [...this.messageQueue, ...this.acknowledgedBufferedMessages.values()]) {
+ try {
+ const parsedMessage = JSON.parse(message) as unknown[]
+ if (parsedMessage[0] === MessageType.CALL_MESSAGE && parsedMessage[1] === messageId) {
+ return this.retainBufferedMessage(message)
+ }
+ } catch {
+ // Malformed frames cannot match a request id.
+ }
+ }
+ return false
+ },
saveOcppConfiguration (): void {
/* empty */
},
+ saveTransactionEventQueues (): void {
+ /* empty */
+ },
+ settleTransactionEnergyMeterValues (): void {
+ /* empty */
+ },
start (): void {
this.started = true
this.starting = false
}
},
+ stopAlignedMeterValues (): void {
+ // Mock no-op: the autonomous clock-aligned timer is not armed in unit tests.
+ },
+
stopHeartbeat (): void {
if (this.heartbeatSetInterval != null) {
clearInterval(this.heartbeatSetInterval)
* Provides typed access to mock handlers without eslint-disable comments
*/
export interface MockOCPPRequestService {
+ acquireOutgoingCall: (chargingStation: ChargingStation, messageId: string) => Promise<void>
+ releaseOutgoingCall: (chargingStation: ChargingStation, messageId: string) => void
requestHandler: (...args: unknown[]) => Promise<unknown>
sendError: (...args: unknown[]) => Promise<unknown>
sendResponse: (...args: unknown[]) => Promise<unknown>
+ validateRequestPayload: (...args: unknown[]) => boolean
}
import type {
ChargingStationInfo,
ConnectorStatus,
+ OCPP20SampledValue,
SampledValue,
SampledValueTemplate,
} from '../../../src/types/index.js'
-import { buildCoherentMeterValue } from '../../../src/charging-station/meter-values/CoherentMeterValueBuilder.js'
+import {
+ buildCoherentMeterValue,
+ buildCoherentMeterValueSnapshot,
+} from '../../../src/charging-station/meter-values/CoherentMeterValueBuilder.js'
import {
computeCoherentSample,
disposeCoherentSessionRuntime,
resolveRootSeed,
} from '../../../src/charging-station/meter-values/CoherentSession.js'
import { hashLabel } from '../../../src/charging-station/meter-values/PRNG.js'
+import { buildOCPP20SampledValue } from '../../../src/charging-station/ocpp/2.0/OCPP20RequestBuilders.js'
import {
AvailabilityType,
CurrentType,
+ MeterValueContext,
MeterValueLocation,
MeterValueMeasurand,
MeterValuePhase,
/**
* Builds an in-memory ICoherentContext + connector status for unit tests.
* @param overrides - Optional overrides bag for context knobs.
+ * @param overrides.conversionEfficiency - DC outlet-to-inlet conversion efficiency.
* @param overrides.currentType - `CurrentType.AC` or `CurrentType.DC`.
- * @param overrides.evseMaxPowerW - EVSE cap returned by `getConnectorMaximumAvailablePower`.
+ * @param overrides.evseMaxPowerW - Template input power; the mock applies DC efficiency like production.
* @param overrides.evseMeterValues - When defined and `groupUnderEvse !== false`, exposed via
* `getEvseStatus(evseId).MeterValues`. When omitted with `groupUnderEvse === true`,
* `getEvseStatus(evseId).MeterValues` is `undefined` (EVSE grouping present with no
*/
const buildContext = (
overrides: {
+ conversionEfficiency?: number
currentType?: CurrentType
evseMaxPowerW?: number
evseMeterValues?: SampledValueTemplate[]
): {
connectorStatus: ConnectorStatus
context: ICoherentContext
+ mainConnectorStatus: ConnectorStatus
sessions: Map<number | string, CoherentSession>
stationInfo: ChargingStationInfo
} => {
chargePointModel: 'model',
chargePointVendor: 'vendor',
coherentMeterValues: true,
+ ...(overrides.conversionEfficiency != null && {
+ conversionEfficiency: overrides.conversionEfficiency,
+ }),
currentOutType: overrides.currentType ?? CurrentType.AC,
hashId: 'hash-1',
numberOfPhases,
transactionId: 1,
}
+ const mainConnectorStatus: ConnectorStatus = {
+ availability: AvailabilityType.Operative,
+ energyActiveImportRegisterValue: 0,
+ MeterValues: [],
+ transactionEnergyActiveImportRegisterValue: 0,
+ }
+
const sessions = new Map<number | string, CoherentSession>()
const context: ICoherentContext = {
- getConnectorMaximumAvailablePower: () => evseMax,
+ getConnectorMaximumAvailablePower: () =>
+ (overrides.currentType ?? CurrentType.AC) === CurrentType.DC
+ ? evseMax * (overrides.conversionEfficiency ?? 1)
+ : evseMax,
getConnectorStatus: () => connectorStatus,
getEvseIdByConnectorId: () => {
// groupUnderEvse takes precedence over evseMeterValues heuristic.
return overrides.evseMeterValues != null ? 1 : undefined
},
getEvseStatus: (evseId: number) => {
+ if (evseId === 0) {
+ return {
+ availability: AvailabilityType.Operative,
+ connectors: new Map<number, ConnectorStatus>([[0, mainConnectorStatus]]),
+ }
+ }
const grouped =
overrides.groupUnderEvse === true ||
(overrides.groupUnderEvse !== false && overrides.evseMeterValues != null)
logPrefix: () => '[test]',
stationInfo,
}
- return { connectorStatus, context, sessions, stationInfo }
+ return { connectorStatus, context, mainConnectorStatus, sessions, stationInfo }
}
const templatesFor = (
return session
}
+const advanceDcEnergyAtLocations = (
+ locations: readonly (MeterValueLocation | undefined)[]
+): {
+ connectorEnergyWh: number
+ inletEnergyWh: number | undefined
+ outletEnergyWh: number | undefined
+ stationEnergyWh: number
+} => {
+ const { connectorStatus, context, mainConnectorStatus, sessions } = buildContext({
+ conversionEfficiency: 0.8,
+ currentType: CurrentType.DC,
+ evseMaxPowerW: 1000,
+ groupUnderEvse: true,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = locations.map(location => ({
+ ...(location != null && { location }),
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: MeterValueUnit.WATT_HOUR,
+ value: 0,
+ })) as SampledValueTemplate[]
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ (template, value, sampleContext, phase) =>
+ buildOCPP20SampledValue(template, value, sampleContext, phase).sampledValue,
+ {
+ intervalMs: 3_600_000,
+ nowMs: 3_600_000,
+ rootSeed: 42,
+ voltageNoise: false,
+ }
+ )
+ const energyAt = (location: MeterValueLocation): number | undefined => {
+ const sampledValue = meterValue.sampledValue.find(sample => sample.location === location)
+ return sampledValue == null ? undefined : Number(sampledValue.value)
+ }
+
+ return {
+ connectorEnergyWh: connectorStatus.energyActiveImportRegisterValue ?? 0,
+ inletEnergyWh: energyAt(MeterValueLocation.INLET),
+ outletEnergyWh: energyAt(MeterValueLocation.OUTLET),
+ stationEnergyWh: mainConnectorStatus.energyActiveImportRegisterValue ?? 0,
+ }
+}
+
await describe('CoherentMeterValues', async () => {
afterEach(() => {
standardCleanup()
})
await describe('AC 1-phase invariants', async () => {
- await it('should satisfy P = V·I·phases within ±1 W after rounding', () => {
+ await it('should satisfy P = V·I·phases within ±0.01 W after rounding', () => {
const { connectorStatus, context, sessions } = buildContext({
currentType: CurrentType.AC,
evseMaxPowerW: 7400,
})
await describe('AC 3-phase invariants', async () => {
- await it('should satisfy P = V·I·3 within ±3 W after rounding', () => {
+ await it('should satisfy P = V·I·3 within ±0.01 W after rounding', () => {
const { connectorStatus, context, sessions } = buildContext({
currentType: CurrentType.AC,
evseMaxPowerW: 22000,
})
await describe('DC invariants', async () => {
- await it('should satisfy P = V·I within ±1 W after rounding', () => {
+ await it('should satisfy P = V·I within ±0.01 W after rounding', () => {
const { connectorStatus, context, sessions } = buildContext({
currentType: CurrentType.DC,
evseMaxPowerW: 50000,
const after = connectorStatus.energyActiveImportRegisterValue ?? 0
assert.ok(after > before, 'energy register must advance regardless of template presence')
})
+
+ await it('should keep DC connector energy output-side and project Inlet energy input-side', () => {
+ assert.deepStrictEqual(advanceDcEnergyAtLocations([MeterValueLocation.INLET]), {
+ connectorEnergyWh: 800,
+ inletEnergyWh: 1000,
+ outletEnergyWh: undefined,
+ stationEnergyWh: 1000,
+ })
+ assert.deepStrictEqual(advanceDcEnergyAtLocations([MeterValueLocation.OUTLET]), {
+ connectorEnergyWh: 800,
+ inletEnergyWh: undefined,
+ outletEnergyWh: 800,
+ stationEnergyWh: 1000,
+ })
+ assert.deepStrictEqual(advanceDcEnergyAtLocations([undefined]), {
+ connectorEnergyWh: 800,
+ inletEnergyWh: undefined,
+ outletEnergyWh: 800,
+ stationEnergyWh: 1000,
+ })
+ })
+
+ await it('should project coherent DC power and energy to each configured meter side exactly once', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ conversionEfficiency: 0.8,
+ currentType: CurrentType.DC,
+ evseMaxPowerW: 1000,
+ groupUnderEvse: true,
+ voltageOut: 400,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = [MeterValueLocation.INLET, MeterValueLocation.OUTLET].flatMap(
+ location => [
+ {
+ location,
+ measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ unit: MeterValueUnit.WATT,
+ },
+ {
+ location,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ ]
+ ) as unknown as SampledValueTemplate[]
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ (template, value, sampleContext, phase) =>
+ buildOCPP20SampledValue(template, value, sampleContext, phase).sampledValue,
+ {
+ intervalMs: 3_600_000,
+ nowMs: 3_600_000,
+ rootSeed: 42,
+ voltageNoise: false,
+ },
+ undefined,
+ undefined,
+ undefined,
+ new Date(3_600_000),
+ connectorStatus,
+ 1
+ )
+ const values = meterValue.sampledValue.map(sample => [
+ sample.measurand,
+ sample.location,
+ Number(sample.value),
+ ])
+
+ assert.deepStrictEqual(values, [
+ [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueLocation.INLET, 1000],
+ [MeterValueMeasurand.POWER_ACTIVE_IMPORT, MeterValueLocation.OUTLET, 800],
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueLocation.INLET, 1000],
+ [MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, MeterValueLocation.OUTLET, 800],
+ ])
+ })
+
+ await it('should omit unsupported explicit DC inlet current and voltage templates', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ conversionEfficiency: 0.8,
+ currentType: CurrentType.DC,
+ evseMaxPowerW: 1000,
+ groupUnderEvse: true,
+ voltageOut: 400,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = [
+ MeterValueMeasurand.VOLTAGE,
+ MeterValueMeasurand.CURRENT_IMPORT,
+ ].flatMap(measurand =>
+ [MeterValueLocation.INLET, MeterValueLocation.OUTLET, undefined].map(location => ({
+ ...(location != null && { location }),
+ measurand,
+ unit:
+ measurand === MeterValueMeasurand.VOLTAGE ? MeterValueUnit.VOLT : MeterValueUnit.AMP,
+ }))
+ ) as unknown as SampledValueTemplate[]
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ (template, value, sampleContext, phase) =>
+ buildOCPP20SampledValue(template, value, sampleContext, phase).sampledValue,
+ {
+ intervalMs: 1000,
+ nowMs: 1000,
+ rootSeed: 42,
+ voltageNoise: false,
+ },
+ undefined,
+ undefined,
+ undefined,
+ new Date(1000),
+ connectorStatus,
+ 1
+ )
+
+ for (const measurand of [MeterValueMeasurand.VOLTAGE, MeterValueMeasurand.CURRENT_IMPORT]) {
+ const samples = meterValue.sampledValue.filter(sample => sample.measurand === measurand)
+ assert.strictEqual(samples.length, 2)
+ assert.ok(samples.every(sample => sample.location === MeterValueLocation.OUTLET))
+ }
+ })
+
+ await it('should keep mixed DC energy projections and main input energy order-independent', () => {
+ const outletThenInlet = advanceDcEnergyAtLocations([
+ MeterValueLocation.OUTLET,
+ MeterValueLocation.INLET,
+ ])
+ const inletThenOutlet = advanceDcEnergyAtLocations([
+ MeterValueLocation.INLET,
+ MeterValueLocation.OUTLET,
+ ])
+
+ assert.deepStrictEqual(outletThenInlet, {
+ connectorEnergyWh: 800,
+ inletEnergyWh: 1000,
+ outletEnergyWh: 800,
+ stationEnergyWh: 1000,
+ })
+ assert.deepStrictEqual(inletThenOutlet, outletThenInlet)
+ })
+
+ await it('should serialize a coherent snapshot without advancing registers or SoC', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor([
+ MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ MeterValueMeasurand.CURRENT_IMPORT,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ ])
+ buildCoherentMeterValue(context, session, passThroughBuilder, {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ })
+ const registerBefore = connectorStatus.energyActiveImportRegisterValue
+ const transactionRegisterBefore = connectorStatus.transactionEnergyActiveImportRegisterValue
+ const socBefore = session.socPercent
+
+ const snapshot = buildCoherentMeterValueSnapshot(context, session, passThroughBuilder)
+
+ assert.ok(snapshot.sampledValue.length > 0)
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, registerBefore)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterValue,
+ transactionRegisterBefore
+ )
+ assert.strictEqual(session.socPercent, socBefore)
+ })
+
+ await it('should preserve the zero-flow snapshot for Transaction.Begin', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor([
+ MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ MeterValueMeasurand.CURRENT_IMPORT,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ ])
+ const registerBefore = connectorStatus.energyActiveImportRegisterValue
+ const transactionRegisterBefore = connectorStatus.transactionEnergyActiveImportRegisterValue
+ const socBefore = session.socPercent
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ passThroughBuilder,
+ { intervalMs: 0, nowMs: TEST_METER_VALUES_INTERVAL_MS, rootSeed: 42 },
+ MeterValueContext.TRANSACTION_BEGIN
+ )
+
+ assert.strictEqual(
+ Number(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ )?.value
+ ),
+ 0
+ )
+ assert.strictEqual(
+ Number(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === MeterValueMeasurand.CURRENT_IMPORT
+ )?.value
+ ),
+ 0
+ )
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, registerBefore)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterValue,
+ transactionRegisterBefore
+ )
+ assert.strictEqual(session.socPercent, socBefore)
+ })
+
+ await it('should advance elapsed energy for Transaction.End even with interval zero', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor([
+ MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ ])
+ const registerBefore = connectorStatus.energyActiveImportRegisterValue ?? 0
+ const socBefore = session.socPercent
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ passThroughBuilder,
+ { intervalMs: 0, nowMs: TEST_METER_VALUES_INTERVAL_MS, rootSeed: 42 },
+ MeterValueContext.TRANSACTION_END
+ )
+
+ assert.ok(
+ Number(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ )?.value
+ ) > 0
+ )
+ assert.ok((connectorStatus.energyActiveImportRegisterValue ?? 0) > registerBefore)
+ assert.ok(session.socPercent > socBefore)
+ })
+
+ await it('should use the EVSE-qualified connector power limit', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = templatesFor([MeterValueMeasurand.POWER_ACTIVE_IMPORT])
+ let requestedIdentity: [number, number | undefined] | undefined
+ context.getConnectorMaximumAvailablePower = (connectorId, evseId) => {
+ requestedIdentity = [connectorId, evseId]
+ return 22000
+ }
+
+ buildCoherentMeterValue(
+ context,
+ session,
+ passThroughBuilder,
+ {
+ intervalMs: TEST_METER_VALUES_INTERVAL_MS,
+ nowMs: TEST_METER_VALUES_INTERVAL_MS,
+ rootSeed: 42,
+ voltageNoise: false,
+ },
+ undefined,
+ undefined,
+ undefined,
+ new Date(0),
+ connectorStatus,
+ 2
+ )
+
+ assert.deepStrictEqual(requestedIdentity, [1, 2])
+ })
+
+ await it('should preserve enabled fixed non-physical measurands in coherent snapshots', () => {
+ const { connectorStatus, context, sessions } = buildContext()
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: MeterValueMeasurand.FREQUENCY,
+ unit: MeterValueUnit.HERTZ,
+ value: '50',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ unit: MeterValueUnit.WATT,
+ value: '9999',
+ },
+ {
+ measurand: MeterValueMeasurand.POWER_FACTOR,
+ value: '50Hz',
+ },
+ { measurand: MeterValueMeasurand.CURRENT_EXPORT },
+ ] as unknown as SampledValueTemplate[]
+ const enabledMeasurands = new Set([
+ MeterValueMeasurand.CURRENT_EXPORT,
+ MeterValueMeasurand.FREQUENCY,
+ MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ MeterValueMeasurand.POWER_FACTOR,
+ ])
+
+ const snapshot = buildCoherentMeterValueSnapshot(
+ context,
+ session,
+ passThroughBuilder,
+ undefined,
+ enabledMeasurands
+ )
+
+ assert.strictEqual(
+ snapshot.sampledValue.find(value => value.measurand === MeterValueMeasurand.FREQUENCY)
+ ?.value,
+ '50'
+ )
+ assert.strictEqual(
+ snapshot.sampledValue.find(
+ value => value.measurand === MeterValueMeasurand.POWER_ACTIVE_IMPORT
+ )?.value,
+ '0'
+ )
+ assert.strictEqual(
+ snapshot.sampledValue.some(value => value.measurand === MeterValueMeasurand.CURRENT_EXPORT),
+ false
+ )
+ assert.strictEqual(
+ snapshot.sampledValue.some(value => value.measurand === MeterValueMeasurand.POWER_FACTOR),
+ false
+ )
+ })
})
await describe('Wh / kWh unit conversion', async () => {
)
}
})
+
+ await it('should canonicalize recursively reordered customData into one no-phase register family', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 22000,
+ numberOfPhases: 3,
+ voltageOut: 230,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.MeterValues = [
+ {
+ customData: { details: { a: 1, b: 2 }, vendorId: 'acme' },
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L1_N,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ {
+ customData: (() => {
+ const details: Record<string, number> = {}
+ details.b = 2
+ details.a = 1
+ return { details, vendorId: 'acme' }
+ })(),
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L2_N,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ ] as unknown as SampledValueTemplate[]
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ passThroughBuilder,
+ { intervalMs: 3_600_000, nowMs: 3_600_000, rootSeed: 42, voltageNoise: false },
+ undefined,
+ undefined,
+ true
+ )
+ const energySamples = meterValue.sampledValue.filter(
+ sample => sample.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+
+ assert.strictEqual(energySamples.length, 1)
+ assert.strictEqual(energySamples[0].phase, undefined)
+ })
+
+ await it('should suppress register phases by effective emitted OCPP 2.0 identity', () => {
+ const { connectorStatus, context, sessions } = buildContext({
+ currentType: CurrentType.AC,
+ evseMaxPowerW: 22000,
+ numberOfPhases: 3,
+ })
+ const session = createSessionOrFail(context, {
+ connectorId: 1,
+ now: 0,
+ profiles: [baseProfile],
+ rampUpDurationMs: 0,
+ rootSeed: 42,
+ transactionId: 1,
+ })
+ sessions.set(1, session)
+ connectorStatus.energyActiveImportRegisterValue = 6000
+ connectorStatus.MeterValues = [
+ { phase: MeterValuePhase.L1_N },
+ { context: MeterValueContext.TRANSACTION_BEGIN, phase: MeterValuePhase.L2_N },
+ { format: 'SignedData', phase: MeterValuePhase.L3_N },
+ {
+ location: MeterValueLocation.OUTLET,
+ phase: MeterValuePhase.L1_N,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ {
+ customData: { vendorId: 'sensor-b' },
+ phase: MeterValuePhase.L2_N,
+ },
+ ] as unknown as SampledValueTemplate[]
+
+ const meterValue = buildCoherentMeterValue(
+ context,
+ session,
+ (template, value, sampleContext, phase) =>
+ buildOCPP20SampledValue(template, value, sampleContext, phase).sampledValue,
+ { intervalMs: 3_600_000, nowMs: 3_600_000, rootSeed: 42, voltageNoise: false },
+ MeterValueContext.SAMPLE_CLOCK,
+ undefined,
+ true
+ )
+ const energySamples = meterValue.sampledValue.filter(
+ sample => sample.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue[]
+
+ assert.deepStrictEqual(
+ energySamples.map(sample => [
+ sample.context,
+ sample.location,
+ sample.phase,
+ sample.unitOfMeasure?.unit,
+ sample.customData?.vendorId,
+ ]),
+ [
+ [
+ MeterValueContext.SAMPLE_CLOCK,
+ MeterValueLocation.OUTLET,
+ undefined,
+ MeterValueUnit.WATT_HOUR,
+ undefined,
+ ],
+ [
+ MeterValueContext.SAMPLE_CLOCK,
+ MeterValueLocation.OUTLET,
+ undefined,
+ MeterValueUnit.WATT_HOUR,
+ 'sensor-b',
+ ],
+ ]
+ )
+ })
})
await describe('EVSE-level template fallback', async () => {
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it } from 'node:test'
-import type { ChargingStation, CoherentSession } from '../../../src/charging-station/index.js'
+import type { ChargingStation } from '../../../src/charging-station/index.js'
import type { SampledValueTemplate } from '../../../src/types/index.js'
import { addConfigurationKey, buildConfigKey } from '../../../src/charging-station/index.js'
+import { type CoherentSession } from '../../../src/charging-station/meter-values/index.js'
import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
import {
CurrentType,
'true'
)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const templates = connectorStatus.MeterValues
+ connectorStatus.MeterValues = ['sensor-a', 'sensor-b'].flatMap(vendorId =>
+ templates.map(template => ({
+ ...template,
+ customData: { vendorId },
+ }))
+ )
const meterValue = buildMeterValue(
station,
TEST_TRANSACTION_ID,
)
assert.strictEqual(
energySamples.length,
- 1,
- 'the OCPP 2.0.1 strategy gate must resolve RegisterValuesWithoutPhases and thread it into the coherent builder so only one aggregate sample emits (synthesized when only per-phase L-N templates are configured)'
+ 2,
+ 'phase suppression must preserve distinct customData register families'
)
- assert.strictEqual(
- (energySamples[0] as { phase?: string }).phase,
- undefined,
- 'the surviving sample must be the aggregate (no phase qualifier)'
+ assert.deepStrictEqual(
+ energySamples.map(sample => [
+ (sample as { customData?: { vendorId?: string } }).customData?.vendorId,
+ sample.phase,
+ ]),
+ [
+ ['sensor-a', undefined],
+ ['sensor-b', undefined],
+ ]
)
})
import type { RemoteStartTransactionRequest } from '../../../../src/types/index.js'
import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
+import { stopRunningTransactions } from '../../../../src/charging-station/ocpp/index.js'
import {
AvailabilityType,
GenericStatus,
+ OCPP16AuthorizationStatus,
OCPP16ChargePointStatus,
+ OCPP16ChargingProfileKindType,
+ OCPP16ChargingProfilePurposeType,
+ OCPP16ChargingRateUnitType,
OCPP16IncomingRequestCommand,
OCPP16RequestCommand,
} from '../../../../src/types/index.js'
createOCPP16IncomingRequestTestContext,
createOCPP16ListenerStation,
createOCPP16NonContiguousConnectorsContext,
+ createOCPP16RequestTestContext,
type OCPP16IncomingRequestTestContext,
+ setMockRequestHandler,
} from './OCPP16TestUtils.js'
await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async () => {
assert.strictEqual(response.status, GenericStatus.Rejected)
})
+ await it('should not install a TxProfile before the accepted response is delivered', async () => {
+ const { incomingRequestService, station, testableService } = testContext
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const request: RemoteStartTransactionRequest = {
+ chargingProfile: {
+ chargingProfileId: 91,
+ chargingProfileKind: OCPP16ChargingProfileKindType.RELATIVE,
+ chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_PROFILE,
+ chargingSchedule: {
+ chargingRateUnit: OCPP16ChargingRateUnitType.WATT,
+ chargingSchedulePeriod: [{ limit: 7000, startPeriod: 0 }],
+ },
+ stackLevel: 0,
+ },
+ connectorId: 1,
+ idTag: TEST_ID_TAG,
+ }
+
+ const response = await testableService.handleRequestRemoteStartTransaction(station, request)
+ assert.strictEqual(response.status, GenericStatus.Accepted)
+ assert.deepStrictEqual(connectorStatus.chargingProfiles, [])
+
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(connectorStatus.chargingProfiles, [request.chargingProfile])
+ })
+
+ await it('should leave a TxProfile uninstalled after response callback budget rejection', async () => {
+ const { requestService, station } = createOCPP16RequestTestContext({
+ baseName: 'remote-start-response-budget',
+ })
+ const service = new OCPP16IncomingRequestService()
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ const reservation = station.reserveBufferedMessageCallbacks(1024 * 1024)
+ assert.ok(reservation != null)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const request: RemoteStartTransactionRequest = {
+ chargingProfile: {
+ chargingProfileId: 92,
+ chargingProfileKind: OCPP16ChargingProfileKindType.RELATIVE,
+ chargingProfilePurpose: OCPP16ChargingProfilePurposeType.TX_PROFILE,
+ chargingSchedule: {
+ chargingRateUnit: OCPP16ChargingRateUnitType.WATT,
+ chargingSchedulePeriod: [{ limit: 7000, startPeriod: 0 }],
+ },
+ stackLevel: 0,
+ },
+ connectorId: 1,
+ idTag: TEST_ID_TAG,
+ }
+
+ await assert.rejects(
+ service.incomingRequestHandler(
+ station,
+ 'remote-start-over-callback-budget',
+ OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+ request
+ ),
+ /Response callback capacity exceeded/
+ )
+
+ assert.deepStrictEqual(connectorStatus.chargingProfiles, [])
+ assert.strictEqual(connectorStatus.transactionRemoteStarted, false)
+ station.releaseBufferedMessageCallbacks(reservation)
+ })
+
// --- Event listeners ---
await describe('REMOTE_START_TRANSACTION event listener', async () => {
assert.strictEqual(args[1], OCPP16RequestCommand.START_TRANSACTION)
})
+ await it('should let stop await a RemoteStart StartTransaction before sending StopTransaction', async () => {
+ const connectorStatus = listenerStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const startResponseGate = Promise.withResolvers<undefined>()
+ const startRequestStarted = Promise.withResolvers<undefined>()
+ const commands: OCPP16RequestCommand[] = []
+ setMockRequestHandler(listenerStation, async (...args: unknown[]) => {
+ const command = args[1] as OCPP16RequestCommand
+ commands.push(command)
+ if (command === OCPP16RequestCommand.START_TRANSACTION) {
+ startRequestStarted.resolve(undefined)
+ await startResponseGate.promise
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 73
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ if (command === OCPP16RequestCommand.STOP_TRANSACTION) {
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.REMOTE_START_TRANSACTION,
+ listenerStation,
+ { connectorId: 1, idTag: TEST_ID_TAG },
+ { status: GenericStatus.Accepted }
+ )
+ await startRequestStarted.promise
+ const stopPromise = stopRunningTransactions(listenerStation)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(commands, [OCPP16RequestCommand.START_TRANSACTION])
+ startResponseGate.resolve(undefined)
+ await stopPromise
+ assert.deepStrictEqual(commands, [
+ OCPP16RequestCommand.START_TRANSACTION,
+ OCPP16RequestCommand.STATUS_NOTIFICATION,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ ])
+ })
+
await it('should NOT call StartTransaction when response is Rejected', () => {
// Arrange
const request: RemoteStartTransactionRequest = {
assert.strictEqual(response.status, OCPP16UnlockStatus.UNLOCK_FAILED)
})
+ await it('should return UnlockFailed when active transaction delivery fails', async () => {
+ setupConnectorWithTransaction(station, 1, { transactionId: 201 })
+ mock.method(OCPP16ServiceUtils, 'stopTransactionOnConnector', async () =>
+ Promise.reject(new Error('StopTransaction delivery failed'))
+ )
+
+ const response = await testableService.handleRequestUnlockConnector(station, {
+ connectorId: 1,
+ })
+
+ assert.strictEqual(response.status, OCPP16UnlockStatus.UNLOCK_FAILED)
+ })
+
await it('should return a response with exactly one status property', async () => {
// Act
const response = await testableService.handleRequestUnlockConnector(station, {
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type {
OCPP16CancelReservationRequest,
OCPP16ReserveNowRequest,
} from '../../../../src/types/index.js'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
import {
GenericStatus,
OCPP16AuthorizationStatus,
OCPP16ChargePointStatus,
+ OCPP16IncomingRequestCommand,
OCPP16ReservationStatus,
OCPP16StandardParametersKey,
} from '../../../../src/types/index.js'
import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_ID_TAG, TEST_RESERVATION_EXPIRY_MS } from '../../ChargingStationTestConstants.js'
import {
+ createCommandsSupport,
createOCPP16IncomingRequestTestContext,
type OCPP16IncomingRequestTestContext,
ReservationFixtures,
let context: OCPP16IncomingRequestTestContext
beforeEach(() => {
- context = createOCPP16IncomingRequestTestContext()
+ context = createOCPP16IncomingRequestTestContext({
+ stationInfo: {
+ commandsSupport: createCommandsSupport({
+ incomingCommands: { [OCPP16IncomingRequestCommand.CANCEL_RESERVATION]: true },
+ }),
+ },
+ })
})
afterEach(() => {
assert.strictEqual(response.status, GenericStatus.Accepted)
})
+ await it('should commit a cancellation when its request source reconnects in the same lifecycle', async () => {
+ const { incomingRequestService, station } = context
+ enableReservationProfile(context)
+ const reservation = ReservationFixtures.createReservation(1, 43, TEST_ID_TAG)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.reservation = reservation
+ connectorStatus.status = OCPP16ChargePointStatus.Reserved
+ station.removeReservation = ChargingStation.prototype.removeReservation.bind(station)
+ const statusNotificationStarted = Promise.withResolvers<undefined>()
+ const releaseStatusNotification = Promise.withResolvers<undefined>()
+ mock.method(station.ocppRequestService, 'requestHandler', async () => {
+ statusNotificationStarted.resolve(undefined)
+ await releaseStatusNotification.promise
+ return {}
+ })
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ Object.assign(station.ocppRequestService, { sendResponse })
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ let sourceCurrent = true
+
+ const handling = incomingRequestService.incomingRequestHandler(
+ station,
+ 'cancel-reconnected-source',
+ OCPP16IncomingRequestCommand.CANCEL_RESERVATION,
+ { reservationId: reservation.reservationId },
+ station.lifecycleAbortSignal,
+ () => true,
+ () => sourceCurrent
+ )
+ const firstSettlement = await Promise.race([
+ statusNotificationStarted.promise.then(() => 'status-notification'),
+ handling.then(() => 'handling'),
+ ])
+ assert.strictEqual(firstSettlement, 'status-notification')
+ sourceCurrent = false
+ releaseStatusNotification.resolve(undefined)
+ await handling
+
+ assert.strictEqual(connectorStatus.reservation, undefined)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ })
+
+ await it('should preserve a reservation when cancellation resumes in a replacement lifecycle', async () => {
+ const { incomingRequestService, station } = context
+ enableReservationProfile(context)
+ const reservation = ReservationFixtures.createReservation(1, 44, TEST_ID_TAG)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.reservation = reservation
+ connectorStatus.status = OCPP16ChargePointStatus.Reserved
+ station.removeReservation = ChargingStation.prototype.removeReservation.bind(station)
+ const statusNotificationStarted = Promise.withResolvers<undefined>()
+ const releaseStatusNotification = Promise.withResolvers<undefined>()
+ mock.method(station.ocppRequestService, 'requestHandler', async () => {
+ statusNotificationStarted.resolve(undefined)
+ await releaseStatusNotification.promise
+ return {}
+ })
+ const sendResponse = mock.fn((): Promise<void> => Promise.resolve())
+ Object.assign(station.ocppRequestService, { sendResponse })
+ const lifecycle = station as unknown as { lifecycleAbortController: AbortController }
+ lifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycle.lifecycleAbortController.signal,
+ })
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ let lifecycleCurrent = true
+
+ const handling = incomingRequestService.incomingRequestHandler(
+ station,
+ 'cancel-replacement-lifecycle',
+ OCPP16IncomingRequestCommand.CANCEL_RESERVATION,
+ { reservationId: reservation.reservationId },
+ station.lifecycleAbortSignal,
+ () => lifecycleCurrent,
+ () => true
+ )
+ const firstSettlement = await Promise.race([
+ statusNotificationStarted.promise.then(() => 'status-notification'),
+ handling.then(() => 'handling'),
+ ])
+ assert.strictEqual(firstSettlement, 'status-notification')
+ lifecycleCurrent = false
+ lifecycle.lifecycleAbortController.abort()
+ lifecycle.lifecycleAbortController = new AbortController()
+ incomingRequestService.activate(station, station.lifecycleAbortSignal)
+ releaseStatusNotification.resolve(undefined)
+ await handling
+
+ assert.strictEqual(connectorStatus.reservation, reservation)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ })
+
await it('should reject cancellation for non-existent reservation', async () => {
// Arrange
const { station, testableService } = context
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ResetRequest } from '../../../../src/types/index.js'
-import { GenericStatus, ResetType } from '../../../../src/types/index.js'
+import {
+ GenericStatus,
+ OCPP16IncomingRequestCommand,
+ ResetType,
+} from '../../../../src/types/index.js'
import {
setupConnectorWithTransaction,
standardCleanup,
assert.strictEqual(response.status, GenericStatus.Accepted)
})
+ await it('should send the Reset response before starting the station reset', async () => {
+ const { incomingRequestService, station } = testContext
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ const callOrder: string[] = []
+ const reset = mock.fn((): Promise<void> => {
+ callOrder.push('reset')
+ return Promise.resolve()
+ })
+ Object.assign(station, { reset })
+ const sendResponse = mock.fn((...args: unknown[]): Promise<void> => {
+ callOrder.push('response')
+ const requestParams = args[4] as undefined | { onMessageSent?: () => void }
+ requestParams?.onMessageSent?.()
+ return Promise.resolve()
+ })
+ Object.assign(station.ocppRequestService, { sendResponse })
+
+ await incomingRequestService.incomingRequestHandler(
+ station,
+ 'reset-response-before-stop',
+ OCPP16IncomingRequestCommand.RESET,
+ { type: ResetType.HARD }
+ )
+
+ assert.strictEqual(sendResponse.mock.callCount(), 1)
+ assert.strictEqual(reset.mock.callCount(), 1)
+ assert.deepStrictEqual(callOrder, ['response', 'reset'])
+ })
+
+ await it('should discard a Reset action when its response settles in a newer lifecycle', async () => {
+ const { incomingRequestService, station } = testContext
+ const responseStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ const stationLifecycle = station as unknown as { lifecycleAbortController: AbortController }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ const reset = mock.fn((): Promise<void> => Promise.resolve())
+ Object.assign(station, { reset })
+ const sendResponse = mock.fn((): Promise<void> => {
+ responseStarted.resolve(undefined)
+ return releaseResponse.promise
+ })
+ Object.assign(station.ocppRequestService, { sendResponse })
+
+ const handling = incomingRequestService.incomingRequestHandler(
+ station,
+ 'stale-reset-response',
+ OCPP16IncomingRequestCommand.RESET,
+ { type: ResetType.HARD }
+ )
+ await responseStarted.promise
+ stationLifecycle.lifecycleAbortController.abort()
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ releaseResponse.resolve(undefined)
+ await handling
+
+ assert.strictEqual(reset.mock.callCount(), 0)
+ })
+
// Additional test: Verify response structure
await it('should return proper response structure for reset', () => {
// Arrange
OCPP16FirmwareStatusNotificationRequest,
OCPP16TriggerMessageRequest,
OCPP16TriggerMessageResponse,
+ RequestParams,
} from '../../../../src/types/index.js'
+import { TransactionMeterValueDeliveryBarrier } from '../../../../src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.js'
+import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/__testable__/index.js'
import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import { OCPPError } from '../../../../src/exception/index.js'
import {
+ CurrentType,
+ ErrorType,
+ OCPP16AuthorizationStatus,
OCPP16DiagnosticsStatus,
OCPP16FirmwareStatus,
OCPP16IncomingRequestCommand,
OCPP16MessageTrigger,
+ OCPP16MeterValueFormat,
+ OCPP16MeterValueLocation,
+ OCPP16MeterValueMeasurand,
+ type OCPP16MeterValuesRequest,
OCPP16MeterValueUnit,
OCPP16RequestCommand,
OCPP16StandardParametersKey,
OCPP16TriggerMessageStatus,
+ OCPP16VendorParametersKey,
OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
} from '../../../../src/types/index.js'
import { Constants, logger } from '../../../../src/utils/index.js'
import {
setupConnectorWithTransaction,
standardCleanup,
} from '../../../helpers/TestLifecycleHelpers.js'
-import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_PUBLIC_KEY_HEX,
+} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
import {
+ createMeterValuesTemplate,
createOCPP16EvseBackedContext,
createOCPP16IncomingRequestTestContext,
createOCPP16ListenerStation,
station: ChargingStation,
request: OCPP16TriggerMessageRequest
): void => {
- const response: OCPP16TriggerMessageResponse = {
- status: OCPP16TriggerMessageStatus.ACCEPTED,
+ if (request.requestedMessage === OCPP16MessageTrigger.MeterValues) {
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
}
+ const response: OCPP16TriggerMessageResponse =
+ request.requestedMessage === OCPP16MessageTrigger.MeterValues
+ ? createTestableIncomingRequestService(service).handleRequestTriggerMessage(station, request)
+ : { status: OCPP16TriggerMessageStatus.ACCEPTED }
service.emit(OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, station, request, response)
}
await it('should return Accepted for MeterValues trigger', () => {
// Arrange
const { station, testableService } = context
+ enableConnectorMeterValues(station, 1)
// Act
const response = testableService.handleRequestTriggerMessage(station, {
})
})
+ await it('should keep active transaction interval state unchanged during MeterValues admission', () => {
+ const { station, testableService } = createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(station, 1, { energyImport: 150, transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ const lastUpdatedAt = new Date('2026-09-12T12:00:00.000Z')
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = lastUpdatedAt
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { default: 120 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 7 }
+
+ const response = testableService.handleRequestTriggerMessage(station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 150)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt,
+ lastUpdatedAt
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, {
+ default: 120,
+ })
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ default: 7,
+ })
+ })
+
+ await it('should bound pending triggered MeterValues per connector transaction', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ const firstStarted = Promise.withResolvers<undefined>()
+ const releaseFirst = Promise.withResolvers<undefined>()
+ let meterValuesCount = 0
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.METER_VALUES) return {}
+ meterValuesCount++
+ const params = args[3] as RequestParams
+ params.onMessageSent?.()
+ if (meterValuesCount === 1) {
+ firstStarted.resolve(undefined)
+ await releaseFirst.promise
+ }
+ params.onResponseReceived?.()
+ return {}
+ })
+ const firstRequest: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const firstResponse = testableService.handleRequestTriggerMessage(station, firstRequest)
+ assert.strictEqual(firstResponse.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ firstRequest,
+ firstResponse
+ )
+ await firstStarted.promise
+
+ const secondResponse = testableService.handleRequestTriggerMessage(station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+ assert.strictEqual(secondResponse.status, OCPP16TriggerMessageStatus.REJECTED)
+ assert.strictEqual(meterValuesCount, 1)
+
+ releaseFirst.resolve(undefined)
+ await flushMicrotasks()
+ const thirdRequest: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const thirdResponse = testableService.handleRequestTriggerMessage(station, thirdRequest)
+ assert.strictEqual(thirdResponse.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ thirdRequest,
+ thirdResponse
+ )
+ await flushMicrotasks()
+ assert.strictEqual(meterValuesCount, 2)
+ })
+
+ await it('should release a pending trigger when its response cannot be sent', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ ;(
+ station.ocppRequestService as unknown as {
+ sendResponse: (...args: unknown[]) => Promise<unknown>
+ }
+ ).sendResponse = mock.fn(async () => Promise.reject(new Error('response send failed')))
+
+ await assert.rejects(
+ incomingRequestService.incomingRequestHandler<
+ OCPP16TriggerMessageRequest,
+ OCPP16TriggerMessageResponse
+ >(station, 'trigger-response-failure', OCPP16IncomingRequestCommand.TRIGGER_MESSAGE, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }),
+ /response send failed/u
+ )
+
+ const nextResponse = testableService.handleRequestTriggerMessage(station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+ assert.strictEqual(nextResponse.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.stop(station)
+ })
+
+ await it('should cancel a reserved trigger when the station lifecycle stops', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const requestHandler = setRecordingRequestHandler(station)
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+
+ incomingRequestService.stop(station)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandler.mock.calls.length, 0)
+ assert.deepStrictEqual(
+ await TransactionMeterValueDeliveryBarrier.wait(connectorStatus, 100),
+ []
+ )
+ })
+
+ await it('should roll back a station-wide reservation when one connector is unavailable', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ setupConnectorWithTransaction(station, 2, { transactionId: 200 })
+ enableConnectorMeterValues(station, 1)
+ enableConnectorMeterValues(station, 2)
+
+ const secondConnectorRequest: OCPP16TriggerMessageRequest = {
+ connectorId: 2,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const secondConnectorResponse = testableService.handleRequestTriggerMessage(
+ station,
+ secondConnectorRequest
+ )
+ assert.strictEqual(secondConnectorResponse.status, OCPP16TriggerMessageStatus.ACCEPTED)
+
+ const stationWideResponse = testableService.handleRequestTriggerMessage(station, {
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+ assert.strictEqual(stationWideResponse.status, OCPP16TriggerMessageStatus.REJECTED)
+
+ const firstConnectorRequest: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const firstConnectorResponse = testableService.handleRequestTriggerMessage(
+ station,
+ firstConnectorRequest
+ )
+ assert.strictEqual(firstConnectorResponse.status, OCPP16TriggerMessageStatus.ACCEPTED)
+
+ setRecordingRequestHandler(station)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ firstConnectorRequest,
+ firstConnectorResponse
+ )
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ secondConnectorRequest,
+ secondConnectorResponse
+ )
+ await flushMicrotasks()
+ })
+
+ await it('should not commit a trigger public key when a later connector rejects admission', () => {
+ const { station, testableService } = createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ setupConnectorWithTransaction(station, 2, { transactionId: 200 })
+ enableConnectorMeterValues(station, 1)
+ const firstConnector = station.getConnectorStatus(1)
+ const secondConnector = station.getConnectorStatus(2)
+ assert.ok(firstConnector != null)
+ assert.ok(secondConnector != null)
+ firstConnector.publicKeySentInTransaction = false
+ secondConnector.MeterValues = []
+
+ const response = testableService.handleRequestTriggerMessage(station, {
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.REJECTED)
+ assert.strictEqual(firstConnector.publicKeySentInTransaction, false)
+ })
+
+ await it('should commit a trigger public key only when MeterValues owns delivery', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.publicKeySentInTransaction = false
+ setRecordingRequestHandler(station)
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should coalesce periodic MeterValues behind an accepted trigger snapshot', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ const triggeredStarted = Promise.withResolvers<undefined>()
+ const releaseTriggered = Promise.withResolvers<undefined>()
+ const periodicStarted = Promise.withResolvers<undefined>()
+ const deliveryOrder: string[] = []
+ let meterValuesCount = 0
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = async (...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.METER_VALUES) return {}
+ meterValuesCount++
+ const requestParams = args[3] as RequestParams
+ deliveryOrder.push(meterValuesCount === 1 ? 'triggered' : 'periodic')
+ requestParams.onMessageSent?.()
+ if (meterValuesCount === 1) {
+ triggeredStarted.resolve(undefined)
+ await releaseTriggered.promise
+ } else {
+ periodicStarted.resolve(undefined)
+ }
+ requestParams.onResponseReceived?.()
+ return {}
+ }
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await triggeredStarted.promise
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['triggered'])
+
+ releaseTriggered.resolve(undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['triggered'])
+ t.mock.timers.tick(1000)
+ await periodicStarted.promise
+ assert.deepStrictEqual(deliveryOrder, ['triggered', 'periodic'])
+ OCPP16ServiceUtils.stopUpdatedMeterValues(station, 1)
+ })
+
+ await it('should emit one accepted trigger behind a pending periodic MeterValues request', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ enableConnectorMeterValues(station, 1)
+ const releasePeriodic = Promise.withResolvers<undefined>()
+ const triggeredStarted = Promise.withResolvers<undefined>()
+ const deliveryOrder: string[] = []
+ let meterValuesCount = 0
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = async (...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.METER_VALUES) return {}
+ const requestParams = args[3] as RequestParams
+ meterValuesCount++
+ if (requestParams.triggerMessage === true) {
+ deliveryOrder.push('triggered')
+ triggeredStarted.resolve(undefined)
+ } else {
+ deliveryOrder.push('periodic')
+ await releasePeriodic.promise
+ }
+ requestParams.onResponseReceived?.()
+ return {}
+ }
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['periodic'])
+
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['periodic'])
+
+ releasePeriodic.resolve(undefined)
+ await triggeredStarted.promise
+ assert.deepStrictEqual(deliveryOrder, ['periodic', 'triggered'])
+ assert.strictEqual(meterValuesCount, 2)
+ OCPP16ServiceUtils.stopUpdatedMeterValues(station, 1)
+ })
+
+ await it('should preserve restored and newly accrued interval energy behind a rejected predecessor', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { default: 0 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 50 }
+ const predecessorStarted = Promise.withResolvers<undefined>()
+ const releasePredecessor = Promise.withResolvers<undefined>()
+ const triggerStarted = Promise.withResolvers<undefined>()
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'predecessor rejected')
+ let predecessorInterval: string | undefined
+ let triggeredInterval: string | undefined
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = async (...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.METER_VALUES) return {}
+ const payload = args[2] as OCPP16MeterValuesRequest
+ const requestParams = args[3] as RequestParams
+ const interval = payload.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ if (requestParams.triggerMessage === true) {
+ triggeredInterval = interval
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ triggerStarted.resolve(undefined)
+ return {}
+ }
+ predecessorInterval = interval
+ predecessorStarted.resolve(undefined)
+ await releasePredecessor.promise
+ requestParams.onTransportError?.(failure, false)
+ throw failure
+ }
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ await predecessorStarted.promise
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 20
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 30
+ await flushMicrotasks()
+ assert.strictEqual(triggeredInterval, undefined)
+
+ releasePredecessor.resolve(undefined)
+ await triggerStarted.promise
+ OCPP16ServiceUtils.stopUpdatedMeterValues(station, 1)
+
+ assert.strictEqual(predecessorInterval, '50')
+ assert.strictEqual(triggeredInterval, '20')
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines.default, 30)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry.default, 60)
+ })
+
+ await it('should emit the admitted MeterValues snapshot when its transaction starts ending', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.energyActiveImportRegisterValue = 150
+ const requestHandler = setRecordingRequestHandler(station)
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+
+ const response = testableService.handleRequestTriggerMessage(station, request)
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ connectorStatus.transactionEnding = true
+ connectorStatus.energyActiveImportRegisterValue = 999
+ connectorStatus.MeterValues = []
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const meterValuesCall = requestHandler.mock.calls.find(
+ call => call.arguments[1] === OCPP16RequestCommand.METER_VALUES
+ )
+ assert.ok(meterValuesCall != null)
+ const payload = meterValuesCall.arguments[2] as OCPP16MeterValuesRequest
+ assert.strictEqual(payload.transactionId, 100)
+ const energyRegisterSample = payload.meterValue[0].sampledValue.find(
+ sampledValue =>
+ sampledValue.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(energyRegisterSample?.value, '150')
+ })
+
+ await describe('MeterValues idle snapshots', async () => {
+ await it('should emit the configured current connector snapshot without a transactionId', async () => {
+ const { incomingRequestService, station, testableService } =
+ createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '37',
+ },
+ ])
+ connectorStatus.energyActiveImportRegisterValue = 37
+ const handler = setRecordingRequestHandler(station)
+ const request: OCPP16TriggerMessageRequest = {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ }
+ const response = testableService.handleRequestTriggerMessage(station, request)
+
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.ACCEPTED)
+ incomingRequestService.emit(
+ OCPP16IncomingRequestCommand.TRIGGER_MESSAGE,
+ station,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const payload = handler.mock.calls[0].arguments[2] as {
+ meterValue: { sampledValue: { context?: string; measurand?: string; value: string }[] }[]
+ transactionId?: number
+ }
+ assert.strictEqual(payload.transactionId, undefined)
+ assert.deepStrictEqual(payload.meterValue[0].sampledValue, [
+ {
+ context: 'Trigger',
+ location: 'Outlet',
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '37',
+ },
+ ])
+ })
+
+ await it('should reject before Accepted when an idle connector has no configured sample', () => {
+ const { station, testableService } = createOCPP16IncomingRequestTestContext()
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.SupportedFeatureProfiles,
+ 'Core,RemoteTrigger'
+ )
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = []
+
+ const response = testableService.handleRequestTriggerMessage(station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+
+ assert.strictEqual(response.status, OCPP16TriggerMessageStatus.REJECTED)
+ })
+ })
+
await describe('MeterValues broadcast (no connectorId, station-wide scan)', async () => {
+ await it('should restore triggered interval energy only when delivery was certainly not sent', async () => {
+ const runFailure = async (
+ configureDelivery: (params: RequestParams, failure: OCPPError) => void
+ ): Promise<{ carry: number; represented: string }> => {
+ const { incomingRequestService, station } = createOCPP16IncomingRequestTestContext()
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.currentOutType = CurrentType.DC
+ station.stationInfo.conversionEfficiency = 0.9
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 90 }
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'triggered MeterValues failed')
+ let represented: string | undefined
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = mock.fn((...args: unknown[]) => {
+ const payload = args[2] as OCPP16MeterValuesRequest
+ represented = payload.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ configureDelivery(args[3] as RequestParams, failure)
+ return Promise.reject(failure)
+ })
+
+ emitAcceptedTrigger(incomingRequestService, station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+ await flushMicrotasks()
+ assert.ok(represented != null)
+ return {
+ carry: connectorStatus.transactionEnergyActiveImportIntervalCarry.default,
+ represented,
+ }
+ }
+
+ const preSendFailure = await runFailure((params, failure) =>
+ params.onTransportError?.(failure, false)
+ )
+ assert.deepStrictEqual(preSendFailure, { carry: 90, represented: '100' })
+ const callError = await runFailure((params, failure) => params.onError?.(failure, true))
+ assert.deepStrictEqual(callError, { carry: 90, represented: '100' })
+ const sentFailure = await runFailure(params => params.onMessageSent?.())
+ assert.deepStrictEqual(sentFailure, { carry: 0, represented: '100' })
+ const ambiguousFailure = await runFailure((params, failure) =>
+ params.onTransportError?.(failure, true)
+ )
+ assert.deepStrictEqual(ambiguousFailure, { carry: 0, represented: '100' })
+ })
+
+ await it('should settle signed triggered MeterValues before StopTransaction is built', async () => {
+ const { incomingRequestService, station } = createOCPP16IncomingRequestTestContext()
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ if (station.stationInfo != null) {
+ station.stationInfo.meterSerialNumber = 'SIM-001'
+ station.stationInfo.transactionDataMeterValues = true
+ }
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ const triggeredStarted = Promise.withResolvers<undefined>()
+ const releaseTriggered = Promise.withResolvers<undefined>()
+ let stopPublicKey: string | undefined
+ let stopSent = false
+ let triggerPublicKey: string | undefined
+ ;(
+ station.ocppRequestService as unknown as {
+ requestHandler: (...args: unknown[]) => Promise<unknown>
+ }
+ ).requestHandler = mock.fn(async (...args: unknown[]) => {
+ const command = args[1] as OCPP16RequestCommand
+ const params = args[3] as RequestParams
+ if (command === OCPP16RequestCommand.METER_VALUES) {
+ const payload = args[2] as {
+ meterValue: { sampledValue: { context?: string; format?: string; value: string }[] }[]
+ }
+ const signedSample = payload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ const rawSample = payload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(rawSample?.context, 'Trigger')
+ if (signedSample != null) {
+ triggerPublicKey = (JSON.parse(signedSample.value) as { publicKey?: string }).publicKey
+ }
+ params.onMessageSent?.()
+ triggeredStarted.resolve(undefined)
+ await releaseTriggered.promise
+ params.onResponseReceived?.()
+ return {}
+ }
+ if (command === OCPP16RequestCommand.STOP_TRANSACTION) {
+ const payload = args[2] as {
+ transactionData?: (undefined | { sampledValue: { format?: string; value: string }[] })[]
+ }
+ const signedSample = payload.transactionData
+ ?.flatMap(meterValue => meterValue?.sampledValue ?? [])
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ if (signedSample != null) {
+ stopPublicKey = (JSON.parse(signedSample.value) as { publicKey?: string }).publicKey
+ }
+ stopSent = true
+ params.onMessageSent?.()
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+
+ emitAcceptedTrigger(incomingRequestService, station, {
+ connectorId: 1,
+ requestedMessage: OCPP16MessageTrigger.MeterValues,
+ })
+ await triggeredStarted.promise
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await flushMicrotasks()
+
+ assert.strictEqual(typeof triggerPublicKey, 'string')
+ assert.notStrictEqual(triggerPublicKey, '')
+ assert.strictEqual(stopSent, false)
+ releaseTriggered.resolve(undefined)
+ await stop
+ assert.strictEqual(stopSent, true)
+ assert.strictEqual(stopPublicKey, '')
+ })
+
await it('should broadcast MeterValues for both contiguous transacting connectors', async () => {
const { incomingRequestService, station } = createOCPP16IncomingRequestTestContext({
connectorsCount: 2,
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js'
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
import type {
MeterValue,
} from '../../../../src/types/index.js'
import { addConfigurationKey } from '../../../../src/charging-station/index.js'
+import { type CoherentSession } from '../../../../src/charging-station/meter-values/index.js'
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
import {
beforeEach(() => {
const ctx = createOCPP16ResponseTestContext()
station = ctx.station
+ station.isStopping = () => false
responseService = ctx.responseService
setMockRequestHandler(station, async () => Promise.resolve({}))
mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => {
// Fresh station for second run — same seed + transactionId must reproduce.
const ctx2 = createOCPP16ResponseTestContext()
const station2 = ctx2.station
+ station2.isStopping = () => false
const responseService2 = ctx2.responseService
setMockRequestHandler(station2, async () => Promise.resolve({}))
for (const { connectorId } of station2.iterateConnectors(true)) {
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
+ station.isStopping = () => false
// IncomingRequest service (handles RemoteStart/Stop from CSMS)
const incomingRequestService = new OCPP16IncomingRequestService()
assert.notStrictEqual(payload.transactionData, undefined)
assert.strictEqual(Array.isArray(payload.transactionData), true)
})
+
+ await it('should preserve an explicit stop snapshot without reading replacement transaction state', () => {
+ if (station.stationInfo != null) {
+ station.stationInfo.transactionDataMeterValues = true
+ }
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = 200
+ connectorStatus.transactionIdTag = 'REPLACEMENT-TAG'
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 9000
+ Reflect.deleteProperty(connectorStatus, 'MeterValues')
+
+ const timestamp = new Date('2026-09-08T10:00:00.000Z')
+ const transactionData = [
+ { sampledValue: [{ value: '10' }], timestamp },
+ { sampledValue: [{ value: '1234' }], timestamp },
+ ]
+ const payload = testableRequestService.buildRequestPayload(
+ station,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ {
+ idTag: 'ORIGINAL-TAG',
+ meterStop: 1234,
+ timestamp,
+ transactionData,
+ transactionId: 100,
+ }
+ ) as OCPP16StopTransactionRequest
+
+ assert.strictEqual(payload.idTag, 'ORIGINAL-TAG')
+ assert.strictEqual(payload.meterStop, 1234)
+ assert.strictEqual(payload.timestamp, timestamp)
+ assert.strictEqual(payload.transactionData, transactionData)
+ assert.strictEqual(payload.transactionId, 100)
+ })
})
})
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import {
AvailabilityType,
+ ChargingStationEvents,
OCPP16AuthorizationStatus,
OCPP16ChargePointStatus,
OCPP16RequestCommand,
station = ctx.station
responseService = ctx.responseService
station.started = true
+ const testableStation = station as unknown as {
+ isStopping: () => boolean
+ stopping: boolean
+ }
+ testableStation.stopping = false
+ testableStation.isStopping = () => testableStation.stopping
requestCalls = []
setMockRequestHandler(station, (...args: unknown[]) => {
)
})
- await it('should skip cleanup when station stops during delay', async t => {
+ await it('should abort the delay when stop lifecycle begins without post-delay mutation', async t => {
// Arrange
+ const lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: lifecycleAbortController.signal,
+ })
setupConnectorWithTransaction(station, 1, { transactionId: 400 })
const requestPayload: OCPP16StopTransactionRequest = {
meterStop: 4000,
// Act
await withMockTimers(t, ['setTimeout'], async () => {
- const promise = responseService.responseHandler(
- station,
- OCPP16RequestCommand.STOP_TRANSACTION,
- responsePayload,
- requestPayload
- )
- for (let i = 0; i < 10; i++) {
+ let handlerSettled = false
+ const promise = responseService
+ .responseHandler(
+ station,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ responsePayload,
+ requestPayload
+ )
+ .then(() => {
+ handlerSettled = true
+ return undefined
+ })
+ for (let index = 0; index < 10; index++) {
await flushMicrotasks()
}
- station.started = false
- t.mock.timers.tick(5000)
- for (let i = 0; i < 10; i++) {
+
+ ;(station as unknown as { stopping: boolean }).stopping = true
+ lifecycleAbortController.abort()
+ for (let index = 0; index < 10; index++) {
await flushMicrotasks()
}
+
+ assert.strictEqual(handlerSettled, true, 'Stop lifecycle abort should release the handler')
await promise
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus == null) {
+ assert.fail('Expected connector 1 to exist')
+ }
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionIdTag, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, OCPP16ChargePointStatus.Available)
+ const requestCountAfterAbort = requestCalls.length
+ t.mock.timers.tick(5000)
+ for (let index = 0; index < 10; index++) {
+ await flushMicrotasks()
+ }
+ assert.strictEqual(
+ requestCalls.length,
+ requestCountAfterAbort,
+ 'No delayed request should be sent after lifecycle abort'
+ )
+ assert.strictEqual(
+ connectorStatus.status,
+ OCPP16ChargePointStatus.Available,
+ 'Restart-safe local status should remain stable after the abandoned delay expires'
+ )
})
// Assert
if (connectorStatus == null) {
assert.fail('Expected connector 1 to exist')
}
- assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionIdTag, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, OCPP16ChargePointStatus.Available)
+ })
+
+ await it('should skip the delay and status emission when shutdown is already in progress', async t => {
+ // Arrange
+ const lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: lifecycleAbortController.signal,
+ })
+ setupConnectorWithTransaction(station, 1, {
+ idTag: 'SHUTDOWN-TAG',
+ transactionId: 500,
+ })
+ const emitSpy = mock.method(station, 'emitChargingStationEvent')
+ const updateAuthMock = mock.method(
+ OCPP16ServiceUtils,
+ 'updateAuthorizationCache',
+ () => undefined
+ )
+ ;(station as unknown as { stopping: boolean }).stopping = true
+ const requestPayload: OCPP16StopTransactionRequest = {
+ meterStop: 5000,
+ timestamp: new Date(),
+ transactionId: 500,
+ }
+ const responsePayload: OCPP16StopTransactionResponse = {
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ }
+ let handlerSettledBeforeDelay = false
+
+ // Act
+ await withMockTimers(t, ['setTimeout'], async () => {
+ let handlerSettled = false
+ const promise = responseService
+ .responseHandler(
+ station,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ responsePayload,
+ requestPayload
+ )
+ .then(() => {
+ handlerSettled = true
+ return undefined
+ })
+ for (let index = 0; index < 10; index++) {
+ await flushMicrotasks()
+ }
+ handlerSettledBeforeDelay = handlerSettled
+
+ // Release a buggy implementation's pending timer so the test can finish promptly.
+ t.mock.timers.tick(5000)
+ for (let index = 0; index < 10; index++) {
+ await flushMicrotasks()
+ }
+ await promise
+ })
+
+ // Assert
+ assert.strictEqual(lifecycleAbortController.signal.aborted, false)
+ assert.strictEqual(
+ handlerSettledBeforeDelay,
+ true,
+ 'Shutdown cleanup should not wait for postTransactionDelay'
+ )
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus == null) {
+ assert.fail('Expected connector 1 to exist')
+ }
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionIdTag, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, OCPP16ChargePointStatus.Available)
+ assert.strictEqual(updateAuthMock.mock.callCount(), 1)
+ assert.strictEqual(updateAuthMock.mock.calls[0].arguments[1], 'SHUTDOWN-TAG')
+ assert.deepStrictEqual(updateAuthMock.mock.calls[0].arguments[2], responsePayload.idTagInfo)
+ const statusCalls = requestCalls.filter(
+ call => call[1] === OCPP16RequestCommand.STATUS_NOTIFICATION
+ )
+ assert.strictEqual(statusCalls.length, 0, 'Shutdown cleanup should send no StatusNotification')
+ const connectorStatusEvents = emitSpy.mock.calls.filter(
+ call => call.arguments[0] === ChargingStationEvents.connectorStatusChanged
+ )
+ assert.strictEqual(
+ connectorStatusEvents.length,
+ 0,
+ 'Shutdown cleanup should emit no connector status event'
+ )
})
})
beforeEach(() => {
const ctx = createOCPP16ResponseTestContext()
station = ctx.station
+ station.isStopping = () => false
responseService = ctx.responseService
// Mock requestHandler so OCPP requests (StatusNotification, MeterValues) resolve
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import {
+ CurrentType,
type OCPP16MeterValue,
OCPP16MeterValueContext,
+ OCPP16MeterValueLocation,
OCPP16MeterValueMeasurand,
OCPP16MeterValueUnit,
OCPPVersion,
assert.strictEqual(meterValue.sampledValue[0].value, '5')
})
+ await it('should project a DC output meter start to an Inlet transaction begin sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.DC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '1000')
+ })
+
+ await it('should preserve a DC output meter start for an Outlet transaction begin sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.DC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '800')
+ })
+
+ await it('should preserve an AC meter start for an Inlet transaction begin sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.AC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '800')
+ })
+
await it('should use meterStart 0 when undefined', () => {
// Arrange
const { station } = createMockChargingStation({
// Assert — kWh divider: 3000 / 1000 = 3
assert.strictEqual(meterValue.sampledValue[0].value, '3')
})
+
+ await it('should project a DC output meter stop to an Inlet transaction end sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.DC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '1000')
+ })
+
+ await it('should preserve a DC output meter stop for an Outlet transaction end sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.DC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '800')
+ })
+
+ await it('should preserve an AC meter stop for an Inlet transaction end sample', () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.8,
+ currentOutType: CurrentType.AC,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 800)
+
+ assert.strictEqual(meterValue.sampledValue[0].value, '800')
+ })
})
})
import { minutesToMilliseconds } from 'date-fns'
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import { buildSignedOCPP16SampledValue } from '../../../../src/charging-station/ocpp/1.6/OCPP16RequestBuilders.js'
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import {
+ CurrentType,
EncodingMethodEnumType,
+ OCPP16AuthorizationStatus,
type OCPP16MeterValue,
OCPP16MeterValueContext,
OCPP16MeterValueFormat,
OCPP16MeterValueLocation,
OCPP16MeterValueMeasurand,
OCPP16MeterValueUnit,
+ OCPP16RequestCommand,
type OCPP16SampledValue,
type OCPP16SignedMeterValue,
OCPP16VendorParametersKey,
assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_BEGIN)
})
+ await it('should project only the ordinary DC Inlet begin sample while signing the raw Outlet reading', () => {
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.conversionEfficiency = 0.8
+ station.stationInfo.currentOutType = CurrentType.DC
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = 42
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignStartedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 800)
+
+ const ordinarySample = meterValue.sampledValue.find(
+ sampledValue => sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ const signedSample = meterValue.sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(ordinarySample != null)
+ assert.ok(signedSample != null)
+ assert.strictEqual(ordinarySample.location, OCPP16MeterValueLocation.INLET)
+ assert.strictEqual(ordinarySample.value, '1000')
+ assert.strictEqual(signedSample.location, OCPP16MeterValueLocation.OUTLET)
+ const signedMeterValue = JSON.parse(signedSample.value) as OCPP16SignedMeterValue
+ const signedPayload = Buffer.from(signedMeterValue.signedMeterData, 'base64').toString('utf8')
+ const ocmfPayloadJson = signedPayload.split('|').at(1)
+ assert.ok(ocmfPayloadJson != null)
+ const ocmfPayload = JSON.parse(ocmfPayloadJson) as { RD: { RV: number }[] }
+ assert.strictEqual(ocmfPayload.RD[0]?.RV, 0.8)
+ })
+
await it('should not include signed SampledValue when SampledDataSignReadings=true but SampledDataSignStartedReadings=false', () => {
upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_END)
})
+ await it('should not commit a public key while merely constructing an end reading', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = 42
+ connectorStatus.publicKeySentInTransaction = false
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 50000)
+
+ const signedSample = meterValue.sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(signedSample != null)
+ assert.notStrictEqual(
+ (JSON.parse(signedSample.value) as OCPP16SignedMeterValue).publicKey,
+ ''
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
await it('should produce signed SampledValue with valid JSON containing all 4 fields', () => {
const connectorStatus = station.getConnectorStatus(1)
if (connectorStatus != null) {
assert.strictEqual(signedSamples.length, 0)
})
})
+
+ await it('should suppress periodic readings as soon as a signed stop begins', async t => {
+ await withMockTimers(t, ['setInterval'], async () => {
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const statusResponse = Promise.withResolvers<unknown>()
+ let meterValueRequests = 0
+ mock.method(station.ocppRequestService, 'requestHandler', async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) meterValueRequests++
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
+ const meterValuesTimer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ assert.ok(meterValuesTimer != null)
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await statusRequestStarted.promise
+ t.mock.timers.tick(minutesToMilliseconds(1))
+
+ assert.strictEqual(meterValueRequests, 0)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ statusResponse.resolve({})
+ await stop
+ })
+ })
})
// ─── isSigningEnabled ───────────────────────────────────────────────────
*/
import assert from 'node:assert/strict'
-import { afterEach, describe, it } from 'node:test'
+import { afterEach, describe, it, mock } from 'node:test'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
import {
AuthResultStatus,
isIncomingRequestCommandSupported,
isRequestCommandSupported,
} from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import { OCPPError } from '../../../../src/exception/index.js'
import {
ChargePointErrorCode,
+ CurrentType,
+ ErrorType,
OCPP16AuthorizationStatus,
OCPP16ChargePointStatus,
type OCPP16ChargingProfile,
type OCPP16ClearChargingProfileRequest,
type OCPP16IdTagInfo,
OCPP16IncomingRequestCommand,
+ type OCPP16MeterValue,
+ OCPP16MeterValueContext,
+ OCPP16MeterValueFormat,
+ OCPP16MeterValueLocation,
+ OCPP16MeterValueMeasurand,
+ OCPP16MeterValueUnit,
OCPP16RequestCommand,
OCPP16StandardParametersKey,
type OCPP16StatusNotificationRequest,
+ type OCPP16StopTransactionRequest,
+ type OCPP16StopTransactionResponse,
OCPP16SupportedFeatureProfiles,
+ OCPP16VendorParametersKey,
OCPPVersion,
+ type RequestParams,
} from '../../../../src/types/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
-import { TEST_CHARGING_STATION_BASE_NAME, TEST_ID_TAG } from '../../ChargingStationTestConstants.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_ID_TAG,
+ TEST_PUBLIC_KEY_HEX,
+} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
import { getTestAuthCache } from '../auth/helpers/MockFactories.js'
-import { createCommandsSupport } from './OCPP16TestUtils.js'
+import {
+ createCommandsSupport,
+ createMeterValuesTemplate,
+ createOCPP16RequestTestContext,
+ upsertConfigurationKey,
+} from './OCPP16TestUtils.js'
await describe('OCPP16ServiceUtils — pure functions', async () => {
afterEach(() => {
})
})
+ // ─── stopTransactionOnConnector ────────────────────────────────────────
+
+ await describe('stopTransactionOnConnector', async () => {
+ const configureSignedStop = (station: ChargingStation, transactionId: number): void => {
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ }
+
+ await it('should suppress periodic MeterValues while a transaction is ending', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ connectorStatus.transactionEnding = true
+ t.mock.timers.tick(1000)
+
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.ok(connectorStatus.transactionUpdatedMeterValuesSetInterval != null)
+ })
+
+ await it('should coalesce periodic ticks while one MeterValues request is pending', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const firstResponse = Promise.withResolvers<unknown>()
+ let requestCount = 0
+ const requestHandler = mock.fn(() => {
+ requestCount++
+ return requestCount === 1 ? firstResponse.promise : Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(10_000)
+ await flushMicrotasks()
+ assert.strictEqual(requestCount, 1)
+
+ firstResponse.resolve({})
+ await flushMicrotasks()
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.strictEqual(requestCount, 2)
+ })
+
+ const runPeriodicMeterValuesFailure = async (
+ tickInterval: () => void,
+ configureDelivery: (params: RequestParams, failure: OCPPError) => void
+ ): Promise<{ carry: number; keyCounts: number[]; keyReserved: boolean }> => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'MeterValues failed')
+ const payloads: { meterValue: OCPP16MeterValue[] }[] = []
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ payloads.push(args[2] as { meterValue: OCPP16MeterValue[] })
+ configureDelivery(args[3] as RequestParams, failure)
+ return Promise.reject(failure)
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ `${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 10 }
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ tickInterval()
+ await flushMicrotasks()
+ tickInterval()
+ await flushMicrotasks()
+ const timer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ assert.ok(timer != null)
+ clearInterval(timer)
+
+ const keyCounts = payloads.map(
+ payload =>
+ payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sampledValue => {
+ if (sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA) return false
+ const publicKey = (JSON.parse(sampledValue.value) as { publicKey?: string }).publicKey
+ return typeof publicKey === 'string' && publicKey.length > 0
+ }).length
+ )
+ return {
+ carry: connectorStatus.transactionEnergyActiveImportIntervalCarry.default,
+ keyCounts,
+ keyReserved: connectorStatus.publicKeySentInTransaction === true,
+ }
+ }
+
+ await it('should restore energy and retry the key after certain periodic failures', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+
+ for (const configureDelivery of [
+ (params: RequestParams, failure: OCPPError) => params.onTransportError?.(failure, false),
+ (params: RequestParams, failure: OCPPError) => {
+ params.onMessageSent?.()
+ params.onError?.(failure, true)
+ },
+ ]) {
+ assert.deepStrictEqual(
+ await runPeriodicMeterValuesFailure(() => {
+ t.mock.timers.tick(1000)
+ }, configureDelivery),
+ { carry: 10, keyCounts: [1, 1], keyReserved: false }
+ )
+ }
+ })
+
+ await it('should preserve an ambiguous periodic attempt after replay CALLERROR', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+
+ assert.deepStrictEqual(
+ await runPeriodicMeterValuesFailure(
+ () => {
+ t.mock.timers.tick(1000)
+ },
+ (params, failure) => {
+ params.onTransportError?.(failure, true)
+ params.onRequestBuffered?.()
+ params.onError?.(failure, true)
+ }
+ ),
+ { carry: 0, keyCounts: [1, 0], keyReserved: true }
+ )
+ })
+
+ await it('should retain energy consumption and the key after possible periodic delivery', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const scenarios: {
+ configureDelivery: (params: RequestParams, failure: OCPPError) => void
+ expectedKeyCounts: number[]
+ name: string
+ }[] = [
+ {
+ configureDelivery: params => params.onMessageSent?.(),
+ expectedKeyCounts: [1, 0],
+ name: 'post-send response timeout',
+ },
+ {
+ configureDelivery: (params, failure) => params.onTransportError?.(failure, true),
+ expectedKeyCounts: [1, 0],
+ name: 'ambiguous transport failure',
+ },
+ {
+ configureDelivery: params => params.onRequestBuffered?.(),
+ expectedKeyCounts: [1],
+ name: 'buffered request',
+ },
+ ]
+ for (const { configureDelivery, expectedKeyCounts, name } of scenarios) {
+ assert.deepStrictEqual(
+ await runPeriodicMeterValuesFailure(() => {
+ t.mock.timers.tick(1000)
+ }, configureDelivery),
+ { carry: 0, keyCounts: expectedKeyCounts, keyReserved: true },
+ name
+ )
+ }
+ })
+
+ await it('should settle periodic interval rollback before building StopTransaction', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const periodicResponse = Promise.withResolvers<unknown>()
+ let periodicRequestParams: RequestParams | undefined
+ let carryAtStopDispatch: number | undefined
+ let stopRequestCount = 0
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ periodicRequestParams = args[3] as RequestParams
+ periodicRequestParams.onMessageSent?.()
+ return periodicResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ carryAtStopDispatch =
+ station.getConnectorStatus(1)?.transactionEnergyActiveImportIntervalCarry?.default
+ stopRequestCount++
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 10 }
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ `${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.ok(periodicRequestParams != null)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await flushMicrotasks()
+ assert.strictEqual(stopRequestCount, 0)
+
+ const callError = new OCPPError(ErrorType.GENERIC_ERROR, 'periodic CALLERROR')
+ periodicRequestParams.onError?.(callError, true)
+ periodicResponse.reject(callError)
+ await stop
+
+ assert.strictEqual(stopRequestCount, 1)
+ assert.strictEqual(carryAtStopDispatch, 1244)
+ })
+
+ await it('should stop the periodic producer before draining an in-flight sample', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const periodicResponse = Promise.withResolvers<unknown>()
+ let meterValuesRequests = 0
+ let stopRequests = 0
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ meterValuesRequests++
+ return periodicResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) stopRequests++
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ assert.strictEqual(meterValuesRequests, 1)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ t.mock.timers.tick(1000)
+ assert.strictEqual(meterValuesRequests, 1)
+ assert.strictEqual(stopRequests, 0)
+
+ periodicResponse.resolve({})
+ await stop
+ assert.strictEqual(stopRequests, 1)
+ })
+
+ await it('should queue StopTransaction behind a buffered periodic sample and reconcile a rejected predecessor', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const bufferedReplay = Promise.withResolvers<unknown>()
+ const bufferedStop = Promise.withResolvers<unknown>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ let periodicRequestParams: RequestParams | undefined
+ let stopRequestParams: RequestParams | undefined
+ let stopRequests = 0
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ periodicRequestParams = args[3] as RequestParams
+ periodicRequestParams.onRequestBuffered?.()
+ return bufferedReplay.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequests++
+ stopRequestStarted.resolve(undefined)
+ stopRequestParams = args[3] as RequestParams
+ stopRequestParams.onRequestBuffered?.()
+ return bufferedStop.promise
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 10 }
+ connectorStatus.transactionStart = new Date(Date.now() + 60_000)
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.MeterValuesSampledData,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.StopTxnSampledData,
+ `${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ connectorStatus.publicKeySentInTransaction = false
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(
+ station,
+ `${OCPP16VendorParametersKey.MeterPublicKey}1`,
+ TEST_PUBLIC_KEY_HEX
+ )
+ const replacePayload = mock.fn(() => true)
+ ;(
+ station as unknown as {
+ replaceBufferedRequestPayload: typeof replacePayload
+ }
+ ).replaceBufferedRequestPayload = replacePayload
+
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 1000)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.ok(periodicRequestParams != null)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ void stop.catch(() => undefined)
+ await stopRequestStarted.promise
+
+ assert.strictEqual(stopRequests, 1)
+ assert.strictEqual(stopRequestParams?.bufferWithoutSending, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+
+ const callError = new OCPPError(ErrorType.GENERIC_ERROR, 'periodic CALLERROR')
+ periodicRequestParams.onError?.(callError, true)
+ bufferedReplay.reject(callError)
+ await flushMicrotasks()
+
+ assert.strictEqual(replacePayload.mock.callCount(), 1)
+ const [commandName, previousPayload, replacementPayload] = replacePayload.mock.calls[0]
+ .arguments as unknown as [
+ OCPP16RequestCommand,
+ OCPP16StopTransactionRequest,
+ OCPP16StopTransactionRequest
+ ]
+ assert.strictEqual(commandName, OCPP16RequestCommand.STOP_TRANSACTION)
+ const countPublicKeys = (payload: OCPP16StopTransactionRequest): number =>
+ payload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(sampledValue => {
+ if (sampledValue.format !== OCPP16MeterValueFormat.SIGNED_DATA) return false
+ const signedValue = JSON.parse(sampledValue.value) as { publicKey?: string }
+ return typeof signedValue.publicKey === 'string' && signedValue.publicKey.length > 0
+ }).length ?? 0
+ assert.strictEqual(countPublicKeys(previousPayload), 0)
+ assert.strictEqual(countPublicKeys(replacementPayload), 1)
+ const replacementIntervalSample = replacementPayload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.strictEqual(replacementIntervalSample?.value, '1244')
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry.default, 0)
+
+ stopRequestParams.onMessageSent?.()
+ bufferedStop.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ await stop
+ assert.strictEqual(stopRequests, 1)
+ })
+
+ await it('should restore terminal interval energy after a definite pre-send failure', async () => {
+ const failure = new OCPPError(ErrorType.GENERIC_ERROR, 'StopTransaction pre-send failure')
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onTransportError?.(failure, false)
+ return Promise.reject(failure)
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ conversionEfficiency: 0.9,
+ currentOutType: CurrentType.DC,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ {
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '0' }],
+ timestamp: new Date('2026-09-08T09:00:00.000Z'),
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { default: 90 }
+ upsertConfigurationKey(
+ station,
+ OCPP16StandardParametersKey.StopTxnSampledData,
+ `${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+
+ await assert.rejects(OCPP16ServiceUtils.stopTransactionOnConnector(station, 1), failure)
+
+ const intervalSample = stopPayload?.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL &&
+ sampledValue.location === OCPP16MeterValueLocation.INLET
+ )
+ assert.strictEqual(intervalSample?.location, OCPP16MeterValueLocation.INLET)
+ assert.strictEqual(intervalSample.value, '100')
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry.default, 90)
+ })
+
+ await it('should return one in-flight promise per connector and allow a later retry', async () => {
+ const stopResponse = Promise.withResolvers<OCPP16StopTransactionResponse>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequestStarted.resolve(undefined)
+ return await stopResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ const firstStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await stopRequestStarted.promise
+ const joinedStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.strictEqual(joinedStop, firstStop)
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+
+ stopResponse.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ await firstStop
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 2
+ )
+ })
+
+ await it('should reject timestamps outside the exact RFC3339 grammar and value ranges', async () => {
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const invalidTimestamps: unknown[] = [
+ '2026-09-08 12:34:56Z',
+ '2026-09-08\t12:34:56Z',
+ '2026-09-08T12:34:56+0130',
+ '2026-09-08',
+ 1_788_867_296_000,
+ null,
+ undefined,
+ new Date(Number.NaN),
+ '2023-02-29T12:34:56Z',
+ '2024-02-30T12:34:56Z',
+ '2026-04-31T12:34:56Z',
+ '2026-13-01T12:34:56Z',
+ '2026-09-08T24:00:00Z',
+ '2026-09-08T12:60:00Z',
+ '2026-09-08T12:34:61Z',
+ '2026-09-08T12:34:56+24:00',
+ '2026-09-08T12:34:56-01:60',
+ '2026-09-08T12:34:60Z',
+ '2016-12-31T23:59:60-00:30',
+ '2017-01-01T00:59:60+00:30',
+ '0000-01-01T00:00:00+00:01',
+ '9999-12-31T23:59:59-00:01',
+ new Date(-62_167_219_200_001),
+ new Date(253_402_300_800_000),
+ ]
+
+ await Promise.all(
+ invalidTimestamps.map(async timestamp => {
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ timestamp,
+ } as unknown as Partial<OCPP16StopTransactionRequest>),
+ (error: Error) => {
+ assert.ok(error instanceof OCPPError)
+ assert.strictEqual(error.code, ErrorType.FORMAT_VIOLATION)
+ return true
+ }
+ )
+ })
+ )
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ })
+
+ await it('should preserve fractional milliseconds and timezone instants', async () => {
+ const timestamps = [
+ ['2024-02-29T23:59:59Z', '2024-02-29T23:59:59.000Z'],
+ ['2026-09-08t12:34:56z', '2026-09-08T12:34:56.000Z'],
+ ['2026-09-08T12:34:56.1z', '2026-09-08T12:34:56.100Z'],
+ ['2026-09-08T12:34:56.123456789Z', '2026-09-08T12:34:56.123Z'],
+ ['2026-09-08T12:34:56.789+02:30', '2026-09-08T10:04:56.789Z'],
+ ['2026-09-08T12:34:56.042-03:15', '2026-09-08T15:49:56.042Z'],
+ ] as const
+
+ await Promise.all(
+ timestamps.map(async ([timestamp, expected]) => {
+ let stopRequest: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequest = args[2] as OCPP16StopTransactionRequest
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ timestamp,
+ } as unknown as Partial<OCPP16StopTransactionRequest>)
+
+ assert.strictEqual(stopRequest?.timestamp.toISOString(), expected)
+ })
+ )
+ })
+
+ await it('should normalize leap seconds with Z and offset timestamps', async () => {
+ const timestamps = [
+ ['2016-12-31T23:59:60Z', '2017-01-01T00:00:00.000Z'],
+ ['2017-01-01t00:59:60.125+01:00', '2017-01-01T00:00:00.125Z'],
+ ['2017-06-30T22:59:60.250-01:00', '2017-07-01T00:00:00.250Z'],
+ ] as const
+
+ await Promise.all(
+ timestamps.map(async ([timestamp, expected]) => {
+ let stopRequest: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequest = args[2] as OCPP16StopTransactionRequest
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ timestamp,
+ } as unknown as Partial<OCPP16StopTransactionRequest>)
+
+ assert.strictEqual(stopRequest?.timestamp.toISOString(), expected)
+ })
+ )
+ })
+
+ await it('should accept and snapshot a valid Date timestamp', async () => {
+ const timestamp = new Date('2026-09-08T12:34:56.789Z')
+ let stopRequest: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequest = args[2] as OCPP16StopTransactionRequest
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, { timestamp })
+
+ assert.notStrictEqual(stopRequest?.timestamp, timestamp)
+ assert.strictEqual(stopRequest?.timestamp.toISOString(), timestamp.toISOString())
+ })
+
+ await it('should reject malformed nested transactionData before stop state mutates', async () => {
+ const { requestService, station } = createOCPP16RequestTestContext({
+ stationInfo: { ocppStrictCompliance: true },
+ })
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ station.ocppRequestService = {
+ requestHandler,
+ validateRequestPayload: requestService.validateRequestPayload.bind(requestService),
+ } as unknown as ChargingStation['ocppRequestService']
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Charging
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60_000)
+ const meterValuesTimer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ assert.ok(meterValuesTimer != null)
+
+ try {
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ transactionData: [
+ {
+ sampledValue: [null],
+ timestamp: new Date('2026-09-08T12:34:56.000Z'),
+ },
+ ],
+ } as unknown as Partial<OCPP16StopTransactionRequest>),
+ error => {
+ assert.ok(error instanceof OCPPError)
+ return true
+ }
+ )
+
+ assert.strictEqual(connectorStatus.status, OCPP16ChargePointStatus.Charging)
+ assert.strictEqual(
+ Object.hasOwn(connectorStatus, 'transactionUpdatedMeterValuesSetInterval'),
+ true
+ )
+ assert.notStrictEqual(
+ connectorStatus.transactionUpdatedMeterValuesSetInterval,
+ meterValuesTimer
+ )
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ } finally {
+ clearInterval(meterValuesTimer)
+ OCPP16ServiceUtils.stopUpdatedMeterValues(station, 1)
+ }
+ })
+
+ await it('should preserve malformed nested transactionData when strict compliance is disabled', async () => {
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const malformedTransactionData = { unexpected: true }
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ transactionData: malformedTransactionData,
+ } as unknown as Partial<OCPP16StopTransactionRequest>)
+
+ assert.deepStrictEqual(stopPayload?.transactionData, malformedTransactionData)
+ })
+
+ await it('should validate and dispatch one isolated snapshot with valid nested transactionData', async () => {
+ const { requestService, station } = createOCPP16RequestTestContext({
+ stationInfo: { ocppStrictCompliance: true },
+ })
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const statusResponse = Promise.withResolvers<unknown>()
+ let stopPayload: Readonly<OCPP16StopTransactionRequest> | undefined
+ let stopParams: undefined | { rawPayload?: boolean }
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as Readonly<OCPP16StopTransactionRequest>
+ stopParams = args[3] as { rawPayload?: boolean }
+ }
+ return {}
+ })
+ station.ocppRequestService = {
+ requestHandler,
+ validateRequestPayload: requestService.validateRequestPayload.bind(requestService),
+ } as unknown as ChargingStation['ocppRequestService']
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Charging
+ const transactionData: OCPP16MeterValue[] = [
+ {
+ sampledValue: [
+ {
+ context: OCPP16MeterValueContext.TRANSACTION_END,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '1234',
+ },
+ ],
+ timestamp: new Date('2026-09-08T12:34:56.000Z'),
+ },
+ ]
+ const expectedTransactionData = structuredClone(transactionData)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ transactionData,
+ })
+ await statusRequestStarted.promise
+ transactionData[0].sampledValue[0].value = 'mutated-after-validation'
+ transactionData.push({ sampledValue: [{ value: 'late' }], timestamp: new Date() })
+ statusResponse.resolve({})
+ await stop
+
+ assert.ok(stopPayload != null)
+ assert.deepStrictEqual(stopPayload.transactionData, expectedTransactionData)
+ assert.notStrictEqual(stopPayload.transactionData, transactionData)
+ assert.strictEqual(Object.isFrozen(stopPayload), true)
+ assert.strictEqual(stopParams?.rawPayload, true)
+ })
+
+ await it('should validate an explicit timestamp before joining but allow omission to join', async () => {
+ const stopResponse = Promise.withResolvers<OCPP16StopTransactionResponse>()
+ const stopRequestStarted = Promise.withResolvers<undefined>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequestStarted.resolve(undefined)
+ return await stopResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ const firstStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await stopRequestStarted.promise
+ const invalidJoin = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ timestamp: 'not-a-timestamp',
+ } as unknown as Partial<OCPP16StopTransactionRequest>)
+ const omittedJoin = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ await assert.rejects(invalidJoin, (error: Error) => {
+ assert.ok(error instanceof OCPPError)
+ assert.strictEqual(error.code, ErrorType.FORMAT_VIOLATION)
+ return true
+ })
+ assert.strictEqual(omittedJoin, firstStop)
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+
+ stopResponse.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ await firstStop
+ })
+
+ await it('should force strict StopTransaction errors for every joiner and clear the failed operation', async () => {
+ const failure = new Error('StopTransaction CALLERROR')
+ let stopAttempts = 0
+ const onMessageSent = mock.fn()
+ const requestHandler = mock.fn((...args: unknown[]) => {
+ if (args[1] !== OCPP16RequestCommand.STOP_TRANSACTION) {
+ return Promise.resolve({})
+ }
+ stopAttempts++
+ const {
+ onError,
+ onMessageSent: wrappedOnMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ...requestOptions
+ } = args[3] as RequestParams
+ for (const callback of [
+ onError,
+ wrappedOnMessageSent,
+ onRequestBuffered,
+ onResponseReceived,
+ onTransportError,
+ ]) {
+ assert.strictEqual(typeof callback, 'function')
+ }
+ assert.deepStrictEqual(requestOptions, {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ rawPayload: true,
+ responseTimeoutMs: 25,
+ skipBufferingOnError: false,
+ throwError: true,
+ })
+ if (stopAttempts === 1) {
+ return Promise.reject(failure)
+ }
+ return Promise.resolve({
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ })
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ const firstStop = OCPP16ServiceUtils.stopTransactionOnConnector(
+ station,
+ 1,
+ undefined,
+ {},
+ {
+ onMessageSent,
+ responseTimeoutMs: 25,
+ skipBufferingOnError: true,
+ throwError: false,
+ }
+ )
+ const joinedStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.strictEqual(joinedStop, firstStop)
+ const failedStops = await Promise.allSettled([firstStop, joinedStop])
+ assert.deepStrictEqual(
+ failedStops.map(result => result.status),
+ ['rejected', 'rejected']
+ )
+ assert.ok(
+ failedStops.every(result => result.status === 'rejected' && result.reason === failure)
+ )
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(
+ station,
+ 1,
+ undefined,
+ {},
+ {
+ onMessageSent,
+ responseTimeoutMs: 25,
+ skipBufferingOnError: true,
+ throwError: false,
+ }
+ )
+ assert.strictEqual(stopAttempts, 2)
+ })
+
+ await it('should buffer sent terminal MeterValues before StopTransaction when lifecycle aborts without a response', async () => {
+ const meterValuesSent = Promise.withResolvers<undefined>()
+ const lifecycleAbortController = new AbortController()
+ const wireMessages: string[] = []
+ const { requestService, station } = createOCPP16RequestTestContext({
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: lifecycleAbortController.signal,
+ })
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ wireMessages.push(String(data))
+ callback?.()
+ meterValuesSent.resolve(undefined)
+ }
+ )
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ const rejectedStop = assert.rejects(stop, /Buffered message id .* without sending/)
+ await meterValuesSent.promise
+ lifecycleAbortController.abort()
+ await rejectedStop
+
+ const { messageQueue } = station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [OCPP16RequestCommand.METER_VALUES]
+ )
+ assert.deepStrictEqual(
+ messageQueue.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [OCPP16RequestCommand.METER_VALUES, OCPP16RequestCommand.STOP_TRANSACTION]
+ )
+ assert.strictEqual(station.requests.size, 2)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should send StopTransaction when replay response precedes the send callback', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const transportFailure = new Error('terminal MeterValues transport failure')
+ let replaySendCallback: ((error?: Error) => void) | undefined
+ const timestamp = new Date('2026-09-08T12:34:56.000Z')
+ const firstSendFailed = Promise.withResolvers<undefined>()
+ const stopSendStarted = Promise.withResolvers<undefined>()
+ const wireMessages: string[] = []
+ const { requestService, station } = createOCPP16RequestTestContext({
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ const responseService = (
+ requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => Promise.resolve(undefined))
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ const message = String(data)
+ wireMessages.push(message)
+ const [, , command] = JSON.parse(message) as [number, string, OCPP16RequestCommand]
+ if (wireMessages.length === 1) {
+ callback?.(transportFailure)
+ firstSendFailed.resolve(undefined)
+ return
+ }
+ if (wireMessages.length === 2) {
+ replaySendCallback = callback
+ return
+ }
+ callback?.()
+ if (command === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopSendStarted.resolve(undefined)
+ }
+ }
+ )
+
+ assert.strictEqual(station.isStopping(), false)
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1, undefined, {
+ timestamp,
+ })
+ await firstSendFailed.promise
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+
+ const { messageQueue } = station as unknown as { messageQueue: string[] }
+ assert.strictEqual(messageQueue.length, 1)
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [OCPP16RequestCommand.METER_VALUES]
+ )
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ const cachedMeterValues = [...station.requests.values()][0]
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }).sendMessageBuffer =
+ sendMessageBuffer
+ sendMessageBuffer.call(station, () => undefined)
+ await Promise.resolve()
+
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [OCPP16RequestCommand.METER_VALUES, OCPP16RequestCommand.METER_VALUES]
+ )
+ cachedMeterValues[0]({}, cachedMeterValues[3])
+ await stopSendStarted.promise
+ replaySendCallback?.()
+
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ ]
+ )
+ const meterValuesPayload = (
+ JSON.parse(wireMessages[0]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ { meterValue: { sampledValue: { format?: OCPP16MeterValueFormat; value: string }[] }[] }
+ ]
+ )[3]
+ const meterValuesSignedSample = meterValuesPayload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(meterValuesSignedSample != null)
+ assert.notStrictEqual(
+ (JSON.parse(meterValuesSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ const stopPayload = (
+ JSON.parse(wireMessages[2]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ OCPP16StopTransactionRequest
+ ]
+ )[3]
+ const stopSignedSample = stopPayload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(stopSignedSample != null)
+ assert.strictEqual(
+ (JSON.parse(stopSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+
+ const cachedStop = [...station.requests.values()].find(
+ ([, , command]) => command === OCPP16RequestCommand.STOP_TRANSACTION
+ )
+ assert.ok(cachedStop != null)
+ cachedStop[0]({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }, cachedStop[3])
+ await stop
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ })
+
+ await it('should preserve an ambiguous strict end frame when replay CALLERROR precedes the send callback', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const transportFailure = new Error('terminal MeterValues transport failure')
+ const firstSendFailed = Promise.withResolvers<undefined>()
+ const stopSendStarted = Promise.withResolvers<undefined>()
+ let firstSend = true
+ let replaySendCallback: ((error?: Error) => void) | undefined
+ const wireMessages: string[] = []
+ const { requestService, station } = createOCPP16RequestTestContext({
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ const responseService = (
+ requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => Promise.resolve(undefined))
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ const message = String(data)
+ wireMessages.push(message)
+ const [, , command] = JSON.parse(message) as [number, string, OCPP16RequestCommand]
+ if (firstSend) {
+ firstSend = false
+ callback?.(transportFailure)
+ firstSendFailed.resolve(undefined)
+ return
+ }
+ if (wireMessages.length === 2) {
+ replaySendCallback = callback
+ return
+ }
+ callback?.()
+ if (command === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopSendStarted.resolve(undefined)
+ }
+ }
+ )
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await firstSendFailed.promise
+ for (let index = 0; index < 10; index++) await Promise.resolve()
+ const cachedMeterValues = [...station.requests.values()][0]
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }).sendMessageBuffer =
+ sendMessageBuffer
+ sendMessageBuffer.call(station, () => undefined)
+ await Promise.resolve()
+ cachedMeterValues[1](
+ new OCPPError(ErrorType.GENERIC_ERROR, 'MeterValues replay CALLERROR'),
+ true
+ )
+ await stopSendStarted.promise
+ replaySendCallback?.()
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number, string, string])[2]),
+ [
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.METER_VALUES,
+ OCPP16RequestCommand.STOP_TRANSACTION,
+ ]
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ const stopPayload = (
+ JSON.parse(wireMessages[2]) as [
+ number,
+ string,
+ OCPP16RequestCommand,
+ OCPP16StopTransactionRequest
+ ]
+ )[3]
+ const stopSignedSample = stopPayload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(stopSignedSample != null)
+ assert.strictEqual(
+ (JSON.parse(stopSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ const cachedStop = [...station.requests.values()].find(
+ ([, , command]) => command === OCPP16RequestCommand.STOP_TRANSACTION
+ )
+ assert.ok(cachedStop != null)
+ cachedStop[0]({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }, cachedStop[3])
+ await stop
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ })
+
+ await it('should keep replayed StopTransaction ownership through asynchronous response reset', async () => {
+ const transportFailure = new Error('StopTransaction transport failure')
+ const statusSendStarted = Promise.withResolvers<undefined>()
+ const stopResponseHandled = Promise.withResolvers<undefined>()
+ let firstSend = true
+ const { requestService, station } = createOCPP16RequestTestContext()
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ setupConnectorWithTransaction(station, 1, { energyImport: 1234, transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (data: unknown, callback?: (error?: Error) => void): void => {
+ const [, , command] = JSON.parse(String(data)) as [number, string, OCPP16RequestCommand]
+ if (firstSend) {
+ firstSend = false
+ callback?.(transportFailure)
+ return
+ }
+ callback?.()
+ if (command === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusSendStarted.resolve(undefined)
+ }
+ }
+ )
+ const responseService = (
+ requestService as unknown as {
+ ocppResponseService: { responseHandler: (...args: unknown[]) => Promise<void> }
+ }
+ ).ocppResponseService
+ const responseHandler = responseService.responseHandler.bind(responseService)
+ mock.method(responseService, 'responseHandler', async (...args: unknown[]): Promise<void> => {
+ await responseHandler(...args)
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopResponseHandled.resolve(undefined)
+ }
+ })
+
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(station, 1),
+ /WebSocket errored/
+ )
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ const cachedStop = [...station.requests.values()][0]
+ const { messageQueue } = station as unknown as { messageQueue: string[] }
+ const bufferedStop = messageQueue[0]
+ wsConnection.send(bufferedStop, error => {
+ assert.strictEqual(error, undefined)
+ })
+ station.removeBufferedMessage(bufferedStop)
+ cachedStop[0]({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }, cachedStop[3])
+ await statusSendStarted.promise
+
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(station, 1),
+ /pending terminal response processing/
+ )
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ const cachedStatus = [...station.requests.values()].find(
+ ([, , command]) => command === OCPP16RequestCommand.STATUS_NOTIFICATION
+ )
+ assert.ok(cachedStatus != null)
+ cachedStatus[0]({}, cachedStatus[3])
+ await stopResponseHandled.promise
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ })
+
+ await it('should release StopTransaction ownership before a synchronous CALLERROR retry', async () => {
+ const transportFailure = new Error('StopTransaction transport failure')
+ let requestCountAtRetry: number | undefined
+ let synchronousRetry: Promise<OCPP16StopTransactionResponse> | undefined
+ const { requestService, station } = createOCPP16RequestTestContext()
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ callback?.(transportFailure)
+ }
+ )
+
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(
+ station,
+ 1,
+ undefined,
+ {},
+ {
+ onError: () => {
+ requestCountAtRetry = station.requests.size
+ synchronousRetry = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ },
+ }
+ ),
+ /WebSocket errored/
+ )
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ const cachedStop = [...station.requests.values()][0]
+ cachedStop[1](new OCPPError(ErrorType.GENERIC_ERROR, 'Terminal CALLERROR'), true)
+
+ assert.strictEqual(requestCountAtRetry, 0)
+ assert.ok(synchronousRetry != null)
+ await assert.rejects(synchronousRetry, /WebSocket errored/)
+ const { messageQueue } = station as unknown as { messageQueue: string[] }
+ assert.strictEqual(messageQueue.length, 1)
+ assert.strictEqual(station.requests.size, 1)
+ })
+
+ await it('should restore the periodic producer after a retryable stop failure', async () => {
+ const sendFailure = new Error('StopTransaction send failure')
+ let stopAttempts = 0
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopAttempts++
+ if (stopAttempts === 1) {
+ ;(args[3] as RequestParams).onError?.(sendFailure as OCPPError, false)
+ return Promise.reject(sendFailure)
+ }
+ return Promise.resolve({
+ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED },
+ })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.status = OCPP16ChargePointStatus.Charging
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60_000)
+ const meterValuesTimer = connectorStatus.transactionUpdatedMeterValuesSetInterval
+ assert.ok(meterValuesTimer != null)
+ const startUpdatedMeterValues =
+ OCPP16ServiceUtils.startUpdatedMeterValues.bind(OCPP16ServiceUtils)
+ const restorePeriodicMeterValues = mock.method(
+ OCPP16ServiceUtils,
+ 'startUpdatedMeterValues',
+ (targetStation: ChargingStation, targetConnectorId: number, interval: number) => {
+ startUpdatedMeterValues(targetStation, targetConnectorId, interval)
+ }
+ )
+
+ try {
+ const firstStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ const joinedStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.strictEqual(joinedStop, firstStop)
+ const failedStops = await Promise.allSettled([firstStop, joinedStop])
+ assert.ok(
+ failedStops.every(result => result.status === 'rejected' && result.reason === sendFailure)
+ )
+ assert.strictEqual(stopAttempts, 1)
+ assert.strictEqual(restorePeriodicMeterValues.mock.callCount(), 1)
+ assert.strictEqual(
+ Object.hasOwn(connectorStatus, 'transactionUpdatedMeterValuesSetInterval'),
+ true
+ )
+ assert.notStrictEqual(
+ connectorStatus.transactionUpdatedMeterValuesSetInterval,
+ meterValuesTimer
+ )
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionId, 100)
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.strictEqual(stopAttempts, 2)
+ assert.strictEqual(restorePeriodicMeterValues.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ } finally {
+ clearInterval(meterValuesTimer)
+ OCPP16ServiceUtils.stopUpdatedMeterValues(station, 1)
+ }
+ })
+
+ await it('should retry a key-bearing stop after Finishing fails before dispatch', async () => {
+ const finishingFailure = new Error('Finishing failed before StopTransaction dispatch')
+ let finishingAttempts = 0
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ finishingAttempts++
+ if (finishingAttempts === 1) return Promise.reject(finishingFailure)
+ return Promise.resolve({})
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ await assert.rejects(OCPP16ServiceUtils.stopTransactionOnConnector(station, 1), error => {
+ assert.strictEqual(error, finishingFailure)
+ return true
+ })
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ const signedSample = stopPayload?.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(signedSample != null)
+ const signedMeterValue = JSON.parse(signedSample.value) as { publicKey: string }
+ assert.notStrictEqual(signedMeterValue.publicKey, '')
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should retry a key-bearing stop after a buffered pre-send failure', async () => {
+ const sendFailure = new Error('StopTransaction pre-send failure')
+ const stopPayloads: OCPP16StopTransactionRequest[] = []
+ let stopAttempts = 0
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopAttempts++
+ stopPayloads.push(args[2] as OCPP16StopTransactionRequest)
+ const params = args[3] as {
+ onMessageSent?: () => void
+ skipBufferingOnError?: boolean
+ }
+ assert.strictEqual(params.skipBufferingOnError, false)
+ if (stopAttempts === 1) return Promise.reject(sendFailure)
+ params.onMessageSent?.()
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ await assert.rejects(OCPP16ServiceUtils.stopTransactionOnConnector(station, 1), error => {
+ assert.strictEqual(error, sendFailure)
+ return true
+ })
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.strictEqual(stopPayloads.length, 2)
+ for (const payload of stopPayloads) {
+ const signedSample = payload.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(signedSample != null)
+ assert.notStrictEqual(
+ (JSON.parse(signedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ }
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should not roll a failed stop reservation into a replacement transaction', async () => {
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const statusResponse = Promise.withResolvers<unknown>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await statusRequestStarted.promise
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ connectorStatus.transactionId = 200
+ connectorStatus.publicKeySentInTransaction = false
+ statusResponse.reject(new Error('Finishing failed'))
+
+ await assert.rejects(stop, /Finishing failed/)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should not commit an old transaction key into replacement state after dispatch', async () => {
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const statusResponse = Promise.withResolvers<unknown>()
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await statusRequestStarted.promise
+ connectorStatus.transactionId = 200
+ connectorStatus.publicKeySentInTransaction = false
+ statusResponse.resolve({})
+ await stop
+
+ const signedSample = stopPayload?.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(signedSample != null)
+ assert.notStrictEqual((JSON.parse(signedSample.value) as { publicKey: string }).publicKey, '')
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should retain the public-key commit after a dispatched stop later fails', async () => {
+ const deliveryFailure = new Error('StopTransaction response failed')
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return Promise.reject(deliveryFailure)
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ await assert.rejects(OCPP16ServiceUtils.stopTransactionOnConnector(station, 1), error => {
+ assert.strictEqual(error, deliveryFailure)
+ return true
+ })
+
+ const signedSample = stopPayload?.transactionData
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(signedSample != null)
+ assert.notStrictEqual((JSON.parse(signedSample.value) as { publicKey: string }).publicKey, '')
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should send a OncePerTransaction end key only in the first strict frame', async () => {
+ let meterValuesPayload: undefined | { meterValue: OCPP16MeterValue[] }
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ meterValuesPayload = args[2] as { meterValue: OCPP16MeterValue[] }
+ const params = args[3] as {
+ onMessageSent?: () => void
+ skipBufferingOnError?: boolean
+ }
+ assert.strictEqual(params.skipBufferingOnError, false)
+ params.onMessageSent?.()
+ return Promise.resolve({})
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler, validateRequestPayload: () => true },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.ok(meterValuesPayload != null)
+ assert.ok(stopPayload?.transactionData != null)
+ const meterValuesSignedSample = meterValuesPayload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ const stopSignedSample = stopPayload.transactionData
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(meterValuesSignedSample != null)
+ assert.ok(stopSignedSample != null)
+ assert.notStrictEqual(
+ (JSON.parse(meterValuesSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ assert.strictEqual(
+ (JSON.parse(stopSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ })
+
+ await it('should transfer a rejected strict end key without mutating the attempted payload', async () => {
+ const callError = new OCPPError(ErrorType.GENERIC_ERROR, 'MeterValues CALLERROR')
+ let meterValuesPayload: undefined | { meterValue: OCPP16MeterValue[] }
+ let attemptedMeterValuesBytes: string | undefined
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ meterValuesPayload = args[2] as { meterValue: OCPP16MeterValue[] }
+ attemptedMeterValuesBytes = JSON.stringify(meterValuesPayload)
+ const params = args[3] as RequestParams
+ params.onMessageSent?.()
+ params.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler, validateRequestPayload: () => true },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.ok(meterValuesPayload != null)
+ assert.strictEqual(JSON.stringify(meterValuesPayload), attemptedMeterValuesBytes)
+ assert.ok(stopPayload?.transactionData != null)
+ const meterValuesSignedData = meterValuesPayload.meterValue[0].sampledValue
+ .filter(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ .map(
+ sampledValue =>
+ JSON.parse(sampledValue.value) as {
+ publicKey: string
+ signedMeterData: string
+ }
+ )
+ .find(signedData => signedData.publicKey.length > 0)
+ const stopSignedData = stopPayload.transactionData
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ .map(
+ sampledValue =>
+ JSON.parse(sampledValue.value) as {
+ publicKey: string
+ signedMeterData: string
+ }
+ )
+ .find(signedData => signedData.publicKey.length > 0)
+ assert.ok(meterValuesSignedData != null)
+ assert.ok(stopSignedData != null)
+ assert.strictEqual(stopSignedData.publicKey, meterValuesSignedData.publicKey)
+ assert.strictEqual(stopSignedData.signedMeterData, meterValuesSignedData.signedMeterData)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should retain end keys in both strict frames for EveryMeterValue', async () => {
+ let meterValuesPayload: undefined | { meterValue: OCPP16MeterValue[] }
+ let stopPayload: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ meterValuesPayload = args[2] as { meterValue: OCPP16MeterValue[] }
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return Promise.resolve({})
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopPayload = args[2] as OCPP16StopTransactionRequest
+ ;(args[3] as { onMessageSent?: () => void }).onMessageSent?.()
+ return Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler, validateRequestPayload: () => true },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ beginEndMeterValues: true,
+ meterSerialNumber: 'SIM-001',
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ configureSignedStop(station, 100)
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'EveryMeterValue'
+ )
+
+ await OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.ok(meterValuesPayload != null)
+ assert.ok(stopPayload?.transactionData != null)
+ const meterValuesSignedSample = meterValuesPayload.meterValue[0].sampledValue.find(
+ sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ const stopSignedSample = stopPayload.transactionData
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sampledValue => sampledValue.format === OCPP16MeterValueFormat.SIGNED_DATA)
+ assert.ok(meterValuesSignedSample != null)
+ assert.ok(stopSignedSample != null)
+ assert.notStrictEqual(
+ (JSON.parse(meterValuesSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ assert.notStrictEqual(
+ (JSON.parse(stopSignedSample.value) as { publicKey: string }).publicKey,
+ ''
+ )
+ })
+
+ await it('should reject a connector without a complete active transaction identity', async () => {
+ const requestHandler = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = true
+ delete connectorStatus.transactionId
+
+ await assert.rejects(
+ OCPP16ServiceUtils.stopTransactionOnConnector(station, 1),
+ /No active transaction on connector 1/
+ )
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ })
+
+ await it('should retain one initiating payload snapshot while Finishing is delayed and the connector is replaced', async () => {
+ const statusResponse = Promise.withResolvers<unknown>()
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ let endMeterValue: undefined | { meterValue: { sampledValue: { value: string }[] }[] }
+ let stopRequest: OCPP16StopTransactionRequest | undefined
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.METER_VALUES) {
+ endMeterValue = args[2] as typeof endMeterValue
+ return {}
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ stopRequest = args[2] as OCPP16StopTransactionRequest
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler, validateRequestPayload: () => true },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ beginEndMeterValues: true,
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ transactionDataMeterValues: true,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, {
+ energyImport: 1234.4,
+ idTag: 'ORIGINAL-TAG',
+ transactionId: 100,
+ })
+ const emitConnectorStatusChanged = mock.method(station, 'emitChargingStationEvent')
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ const beginTimestamp = new Date('2026-09-08T10:00:00.000Z')
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '10' }],
+ timestamp: beginTimestamp,
+ }
+
+ const stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await statusRequestStarted.promise
+ connectorStatus.transactionId = 200
+ connectorStatus.transactionIdTag = 'REPLACEMENT-TAG'
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [{ value: '9000' }],
+ timestamp: new Date('2026-09-08T11:00:00.000Z'),
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 9999
+ connectorStatus.energyActiveImportRegisterValue = 9999
+ connectorStatus.status = OCPP16ChargePointStatus.Charging
+ statusResponse.resolve({})
+ await stop
+
+ assert.ok(stopRequest != null)
+ assert.strictEqual(stopRequest.transactionId, 100)
+ assert.strictEqual(stopRequest.idTag, 'ORIGINAL-TAG')
+ assert.strictEqual(stopRequest.meterStop, 1234)
+ assert.ok(stopRequest.transactionData != null)
+ const [transactionBeginMeterValue, transactionEndMeterValue] = stopRequest.transactionData
+ assert.strictEqual(transactionBeginMeterValue.sampledValue[0].value, '10')
+ assert.strictEqual(transactionEndMeterValue.sampledValue[0].value, '1234')
+ assert.strictEqual(
+ stopRequest.meterStop,
+ Number(transactionEndMeterValue.sampledValue[0].value)
+ )
+ assert.strictEqual(transactionEndMeterValue.timestamp, stopRequest.timestamp)
+ assert.ok(endMeterValue != null)
+ assert.strictEqual(endMeterValue.meterValue[0], transactionEndMeterValue)
+ assert.strictEqual(connectorStatus.status, OCPP16ChargePointStatus.Charging)
+ assert.strictEqual(emitConnectorStatusChanged.mock.callCount(), 0)
+ })
+
+ await it('should return a rejection instead of throwing synchronously for malformed strict end-meter setup', async () => {
+ const { station } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ beginEndMeterValues: true,
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ },
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ let stop: Promise<OCPP16StopTransactionResponse> | undefined
+ assert.doesNotThrow(() => {
+ stop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ })
+ assert.ok(stop != null)
+ await assert.rejects(stop, /Missing MeterValues/)
+ })
+
+ await it('should reject a replacement transaction while the previous stop remains in flight', async () => {
+ const statusResponse = Promise.withResolvers<unknown>()
+ const statusRequestStarted = Promise.withResolvers<undefined>()
+ const requestHandler = mock.fn(async (...args: unknown[]) => {
+ if (args[1] === OCPP16RequestCommand.STATUS_NOTIFICATION) {
+ statusRequestStarted.resolve(undefined)
+ return await statusResponse.promise
+ }
+ if (args[1] === OCPP16RequestCommand.STOP_TRANSACTION) {
+ return { idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } }
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ ocppRequestService: { requestHandler },
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+
+ const originalStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+ await statusRequestStarted.promise
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = 200
+ connectorStatus.status = OCPP16ChargePointStatus.Charging
+ const replacementStop = OCPP16ServiceUtils.stopTransactionOnConnector(station, 1)
+
+ assert.notStrictEqual(replacementStop, originalStop)
+ await assert.rejects(replacementStop, (error: Error) => {
+ assert.ok(error instanceof OCPPError)
+ assert.strictEqual(error.code, ErrorType.GENERIC_ERROR)
+ assert.match(error.message, /cannot stop replacement transaction 200/)
+ return true
+ })
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STATUS_NOTIFICATION
+ ).length,
+ 1
+ )
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 0
+ )
+
+ statusResponse.resolve({})
+ await originalStop
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call => call.arguments[1] === OCPP16RequestCommand.STOP_TRANSACTION
+ ).length,
+ 1
+ )
+ })
+ })
+
// ─── updateAuthorizationCache ──────────────────────────────────────────
await describe('updateAuthorizationCache', async () => {
type OCPP20CertificateSignedRequest,
type OCPP20CertificateSignedResponse,
OCPP20ComponentName,
+ OCPP20IncomingRequestCommand,
OCPP20OptionalVariableName,
OCPP20RequestCommand,
OCPPVersion,
assert.strictEqual(closeWSConnectionMock.mock.callCount(), 1)
})
+ await it('should retain a durable store result without mutating a replacement lifecycle', async () => {
+ const storeStarted = Promise.withResolvers<undefined>()
+ const releaseStore = Promise.withResolvers<undefined>()
+ let storeCompleted = false
+ const certificateManager = createMockCertificateManager({ storeCertificateResult: true })
+ mock.method(certificateManager, 'storeCertificate', async () => {
+ storeStarted.resolve(undefined)
+ await releaseStore.promise
+ storeCompleted = true
+ return true
+ })
+ stationWithCertManager.certificateManager = certificateManager
+ const service = new OCPP20IncomingRequestService()
+ const lifecycle = station as unknown as { lifecycleAbortController: AbortController }
+ lifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycle.lifecycleAbortController.signal,
+ })
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ service.activate(station, station.lifecycleAbortSignal)
+ const sendResponse = mock.method(
+ station.ocppRequestService,
+ 'sendResponse',
+ (): Promise<void> => Promise.resolve()
+ )
+ const handlers = service as unknown as {
+ incomingRequestHandlers: Map<
+ OCPP20IncomingRequestCommand,
+ (
+ chargingStation: ChargingStation,
+ request: OCPP20CertificateSignedRequest
+ ) => Promise<OCPP20CertificateSignedResponse>
+ >
+ stationsState: WeakMap<
+ ChargingStation,
+ { certSigningRetryManager?: { cancelRetryTimer: () => void } }
+ >
+ }
+ const originalHandler = handlers.incomingRequestHandlers.get(
+ OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED
+ )
+ assert.ok(originalHandler != null)
+ let handledResponse: OCPP20CertificateSignedResponse | undefined
+ handlers.incomingRequestHandlers.set(
+ OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
+ async (chargingStation, request) => {
+ handledResponse = await originalHandler(chargingStation, request)
+ return handledResponse
+ }
+ )
+ let lifecycleCurrent = true
+ const handling = service.incomingRequestHandler(
+ station,
+ 'durable-certificate-store',
+ OCPP20IncomingRequestCommand.CERTIFICATE_SIGNED,
+ {
+ certificateChain: VALID_PEM_CERTIFICATE,
+ certificateType: CertificateSigningUseEnumType.ChargingStationCertificate,
+ },
+ station.lifecycleAbortSignal,
+ () => lifecycleCurrent,
+ () => true
+ )
+ const firstSettlement = await Promise.race([
+ storeStarted.promise.then(() => 'store-started'),
+ handling.then(() => 'handling-completed'),
+ ])
+ assert.strictEqual(firstSettlement, 'store-started')
+ lifecycleCurrent = false
+ lifecycle.lifecycleAbortController.abort()
+ lifecycle.lifecycleAbortController = new AbortController()
+ service.activate(station, station.lifecycleAbortSignal)
+ const cancelRetryTimer = mock.fn()
+ const replacementState = handlers.stationsState.get(station)
+ assert.ok(replacementState != null)
+ replacementState.certSigningRetryManager = { cancelRetryTimer }
+
+ releaseStore.resolve(undefined)
+ await handling
+
+ assert.strictEqual(storeCompleted, true)
+ assert.strictEqual(handledResponse?.status, GenericStatus.Accepted)
+ assert.strictEqual(closeWSConnectionMock.mock.callCount(), 0)
+ assert.strictEqual(cancelRetryTimer.mock.callCount(), 0)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ })
+
await it('should accept single certificate (no chain)', async () => {
stationWithCertManager.certificateManager = createMockCertificateManager({
storeCertificateResult: true,
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
import {
ChangeAvailabilityStatusEnumType,
+ ConnectorStatusEnum,
OCPP20OperationalStatusEnumType,
OCPP20RequestCommand,
ReasonCodeEnumType,
standardCleanup,
} from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import { createConnectorStatus } from '../../helpers/StationHelpers.js'
import { createOCPP20ListenerStation } from './OCPP20TestUtils.js'
await describe('G03 - ChangeAvailability', async () => {
assert.strictEqual(args[1], OCPP20RequestCommand.STATUS_NOTIFICATION)
})
+ await it('should target the exact EVSE when connector identifiers are reused', async () => {
+ const evse1 = station.getEvseStatus(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const evse1Connector = evse1.connectors.get(1)
+ assert.ok(evse1Connector != null)
+ const evse2Connector = createConnectorStatus(1, { status: ConnectorStatusEnum.Available })
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+
+ const response = testableService.handleRequestChangeAvailability(station, {
+ evse: { connectorId: 1, id: 2 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+ await flushMicrotasks()
+
+ assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Accepted)
+ assert.strictEqual(evse1Connector.availability, OCPP20OperationalStatusEnumType.Operative)
+ assert.strictEqual(evse1Connector.status, ConnectorStatusEnum.Available)
+ assert.strictEqual(evse2Connector.availability, OCPP20OperationalStatusEnumType.Inoperative)
+ assert.strictEqual(evse2Connector.status, ConnectorStatusEnum.Unavailable)
+ const statusNotification = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.STATUS_NOTIFICATION
+ )
+ assert.deepStrictEqual(statusNotification?.arguments[2], {
+ connectorId: 1,
+ connectorStatus: ConnectorStatusEnum.Unavailable,
+ evseId: 2,
+ })
+ })
+
+ await it('should snapshot only the connector targeted by connector-level changes', async () => {
+ const evse = station.getEvseStatus(1)
+ assert.ok(evse != null)
+ const connector1 = evse.connectors.get(1)
+ assert.ok(connector1 != null)
+ const connector2 = createConnectorStatus(2, { status: ConnectorStatusEnum.Available })
+ evse.connectors.set(2, connector2)
+
+ testableService.handleRequestChangeAvailability(station, {
+ evse: { connectorId: 1, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+ await flushMicrotasks()
+ connector2.status = ConnectorStatusEnum.Faulted
+ testableService.handleRequestChangeAvailability(station, {
+ evse: { connectorId: 2, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+ await flushMicrotasks()
+ testableService.handleRequestChangeAvailability(station, {
+ evse: { connectorId: 2, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Operative,
+ })
+ await flushMicrotasks()
+ testableService.handleRequestChangeAvailability(station, {
+ evse: { connectorId: 1, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Operative,
+ })
+ await flushMicrotasks()
+
+ assert.strictEqual(connector1.status, ConnectorStatusEnum.Available)
+ assert.strictEqual(connector2.status, ConnectorStatusEnum.Faulted)
+ })
+
+ await it('should restore distinct statuses for reused connector identifiers', async () => {
+ const evse1 = station.getEvseStatus(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const evse1Connector = evse1.connectors.get(1)
+ assert.ok(evse1Connector != null)
+ evse1Connector.status = ConnectorStatusEnum.Faulted
+ const evse2Connector = createConnectorStatus(1, { status: ConnectorStatusEnum.Reserved })
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+
+ testableService.handleRequestChangeAvailability(station, {
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+ await flushMicrotasks()
+ assert.strictEqual(evse1Connector.status, ConnectorStatusEnum.Unavailable)
+ assert.strictEqual(evse2Connector.status, ConnectorStatusEnum.Unavailable)
+
+ testableService.handleRequestChangeAvailability(station, {
+ operationalStatus: OCPP20OperationalStatusEnumType.Operative,
+ })
+ await flushMicrotasks()
+
+ assert.strictEqual(evse1Connector.status, ConnectorStatusEnum.Faulted)
+ assert.strictEqual(evse2Connector.status, ConnectorStatusEnum.Reserved)
+ })
+
// FR: G03.FR.02
await it('should accept CS-level Inoperative when no ongoing transaction', () => {
const response = testableService.handleRequestChangeAvailability(station, {
assert.strictEqual(getConfigurationKey(station, name)?.value, 'true')
})
+ await it('should round-trip an EVSE-scoped SendDuringIdle key', () => {
+ const name = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SendDuringIdle,
+ 'EVSE.1'
+ )
+
+ const status = incomingRequestService.changeConfiguration(station, name, 'false')
+
+ assert.strictEqual(status, ConfigurationStatus.ACCEPTED)
+ assert.strictEqual(getConfigurationKey(station, name)?.value, 'false')
+ assert.deepStrictEqual(OCPP20VariableManager.getInstance().resolveConfigurationKeyName(name), {
+ component: OCPP20ComponentName.AlignedDataCtrlr,
+ evseId: 1,
+ instance: undefined,
+ variable: OCPP20OptionalVariableName.SendDuringIdle,
+ })
+ })
+
await it('should return NotSupported for a key that does not resolve to a registry variable', () => {
const status = incomingRequestService.changeConfiguration(station, 'Not.A.RegistryKey', '42')
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { RequestParams } from '../../../../src/types/index.js'
import {
addConfigurationKey,
TEST_FIRMWARE_VERSION,
} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { createOCPP20RequestTestContext } from './OCPP20TestUtils.js'
await describe('B07 - Get Base Report', async () => {
let station: ChargingStation
}
})
+ await it('should report station and physical EVSE scopes for SendDuringIdle', () => {
+ const manager = OCPP20VariableManager.getInstance()
+ const [setResult] = manager.setVariables(station, [
+ {
+ attributeValue: 'true',
+ component: { evse: { id: 2 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+ assert.strictEqual(setResult.attributeStatus, SetVariableStatusEnumType.Accepted)
+
+ const reportData = testableService.buildReportData(station, ReportBaseEnumType.FullInventory)
+ const entries = reportData.filter(
+ (item: ReportDataType) =>
+ item.component.name === (OCPP20ComponentName.AlignedDataCtrlr as string) &&
+ item.variable.name === (OCPP20OptionalVariableName.SendDuringIdle as string)
+ )
+
+ assert.deepStrictEqual(
+ entries.map(entry => [entry.component.evse?.id, entry.variableAttribute[0]?.value]),
+ [
+ [undefined, 'false'],
+ [1, 'false'],
+ [2, 'true'],
+ [3, 'false'],
+ ]
+ )
+ assert.strictEqual(
+ reportData.some(
+ entry =>
+ entry.component.name === (OCPP20ComponentName.OCPPCommCtrlr as string) &&
+ entry.variable.name.endsWith('.EVSE.2')
+ ),
+ false
+ )
+ const configurationReport = testableService.buildReportData(
+ station,
+ ReportBaseEnumType.ConfigurationInventory
+ )
+ const scopedEntry = configurationReport.find(
+ entry =>
+ entry.component.name === (OCPP20ComponentName.AlignedDataCtrlr as string) &&
+ entry.component.evse?.id === 2 &&
+ entry.variable.name === (OCPP20OptionalVariableName.SendDuringIdle as string)
+ )
+ assert.strictEqual(scopedEntry?.variableAttribute[0]?.value, 'true')
+ assert.strictEqual(
+ configurationReport.some(
+ entry =>
+ entry.component.name === (OCPP20ComponentName.OCPPCommCtrlr as string) &&
+ entry.variable.name.endsWith('.EVSE.2')
+ ),
+ false
+ )
+ })
+
// FR: B08.FR.03
await it('should handle GetBaseReport request with SummaryInventory', () => {
const request: OCPP20GetBaseReportRequest = {
assert.strictEqual(reportData.length, 0)
})
+ await it('should remove only a GetBaseReport cache rejected before response send', async () => {
+ const { requestService } = createOCPP20RequestTestContext()
+ const responseStation = station
+ responseStation.ocppRequestService = requestService
+ responseStation.recordRequestStatistic = () => undefined
+ const responseService = new OCPP20IncomingRequestService()
+ const responseTestable = createTestableIncomingRequestService(responseService)
+ const retainedRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: 1001,
+ }
+ assert.strictEqual(
+ responseTestable.handleRequestGetBaseReport(responseStation, retainedRequest).status,
+ GenericDeviceModelStatusEnumType.Accepted
+ )
+ const oversizedRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: 1002,
+ }
+ const oversizedReport = responseTestable.buildReportData(
+ responseStation,
+ ReportBaseEnumType.ConfigurationInventory
+ )
+ oversizedReport[0].variableAttribute[0].value = 'x'.repeat(1024 * 1024)
+ mock.method(
+ responseService as unknown as {
+ buildReportData: (
+ chargingStation: ChargingStation,
+ reportBase: ReportBaseEnumType
+ ) => ReportDataType[]
+ },
+ 'buildReportData',
+ () => oversizedReport
+ )
+
+ await assert.rejects(
+ responseService.incomingRequestHandler(
+ responseStation,
+ 'oversized-get-base-report-response',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ oversizedRequest
+ ),
+ /callback capacity exceeded/
+ )
+
+ const stationState = (
+ responseService as unknown as {
+ stationsState: WeakMap<ChargingStation, { reportDataCache: Map<number, ReportDataType[]> }>
+ }
+ ).stationsState.get(responseStation)
+ assert.ok(stationState != null)
+ assert.strictEqual(stationState.reportDataCache.has(retainedRequest.requestId), true)
+ assert.strictEqual(stationState.reportDataCache.has(oversizedRequest.requestId), false)
+ })
+
+ await it('should hold report bytes until NotifyReport settles and then restore capacity', async () => {
+ const { requestService } = createOCPP20RequestTestContext()
+ station.ocppRequestService = requestService
+ station.recordRequestStatistic = () => undefined
+ const responseService = new OCPP20IncomingRequestService()
+ const responseTestable = createTestableIncomingRequestService(responseService)
+ const reportData = responseTestable.buildReportData(
+ station,
+ ReportBaseEnumType.ConfigurationInventory
+ )
+ reportData[0].variableAttribute[0].value = 'x'.repeat(600 * 1024)
+ mock.method(
+ responseService as unknown as {
+ buildReportData: (
+ chargingStation: ChargingStation,
+ reportBase: ReportBaseEnumType
+ ) => ReportDataType[]
+ },
+ 'buildReportData',
+ () => reportData
+ )
+ const sentResponses: OCPP20GetBaseReportResponse[] = []
+ mock.method(requestService, 'sendResponse', (...args: unknown[]) => {
+ sentResponses.push(args[2] as OCPP20GetBaseReportResponse)
+ const requestParams = args[4] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.resolve()
+ })
+ const firstNotifyStarted = Promise.withResolvers<undefined>()
+ const releaseFirstNotify = Promise.withResolvers<Record<string, never>>()
+ let notifyCalls = 0
+ mock.method(requestService, 'requestHandler', (): Promise<Record<string, never>> => {
+ ++notifyCalls
+ if (notifyCalls === 1) {
+ firstNotifyStarted.resolve(undefined)
+ return releaseFirstNotify.promise
+ }
+ return Promise.resolve({})
+ })
+ const firstRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: 1101,
+ }
+ const duplicateRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: firstRequest.requestId,
+ }
+ const blockedRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: 1102,
+ }
+ const retryRequest: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: firstRequest.requestId,
+ }
+
+ await responseService.incomingRequestHandler(
+ station,
+ 'first-large-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ firstRequest
+ )
+ await firstNotifyStarted.promise
+ await assert.rejects(
+ responseService.incomingRequestHandler(
+ station,
+ 'duplicate-active-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ duplicateRequest
+ ),
+ /GetBaseReport requestId 1101 is already active/
+ )
+ assert.strictEqual(sentResponses.length, 1)
+ assert.strictEqual(notifyCalls, 1)
+ await assert.rejects(
+ responseService.incomingRequestHandler(
+ station,
+ 'blocked-large-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ blockedRequest
+ ),
+ /Report callback capacity exceeded/
+ )
+
+ releaseFirstNotify.resolve({})
+ await flushMicrotasks()
+ await flushMicrotasks()
+ await responseService.incomingRequestHandler(
+ station,
+ 'retried-large-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ retryRequest
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(notifyCalls, 2)
+ assert.strictEqual(sentResponses[1]?.status, GenericDeviceModelStatusEnumType.Accepted)
+ const stationState = (
+ responseService as unknown as {
+ stationsState: WeakMap<ChargingStation, { reportDataCache: Map<number, ReportDataType[]> }>
+ }
+ ).stationsState.get(station)
+ assert.ok(stationState != null)
+ assert.strictEqual(stationState.reportDataCache.size, 0)
+ })
+
+ await it('should retain GetBaseReport data until NotifyReport completes', async () => {
+ const notifyReportStarted = Promise.withResolvers<undefined>()
+ const notifyReportCompletion = Promise.withResolvers<Record<string, never>>()
+ station.recordRequestStatistic = () => undefined
+ mock.method(
+ station.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<Record<string, never>> => {
+ const requestParams = args[4] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ }
+ )
+ mock.method(station.ocppRequestService, 'requestHandler', () => {
+ notifyReportStarted.resolve(undefined)
+ return notifyReportCompletion.promise
+ })
+ const request: OCPP20GetBaseReportRequest = {
+ reportBase: ReportBaseEnumType.ConfigurationInventory,
+ requestId: 1003,
+ }
+
+ await incomingRequestService.incomingRequestHandler(
+ station,
+ 'successful-get-base-report-response',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ request
+ )
+ await notifyReportStarted.promise
+
+ const stationState = (
+ incomingRequestService as unknown as {
+ stationsState: WeakMap<ChargingStation, { reportDataCache: Map<number, ReportDataType[]> }>
+ }
+ ).stationsState.get(station)
+ assert.ok(stationState != null)
+ assert.strictEqual(stationState.reportDataCache.has(request.requestId), true)
+
+ notifyReportCompletion.resolve({})
+ await flushMicrotasks()
+ assert.strictEqual(stationState.reportDataCache.has(request.requestId), false)
+ })
+
await describe('GET_BASE_REPORT event listener', async () => {
let listenerService: OCPP20IncomingRequestService
let sendNotifyMock: ReturnType<typeof mock.fn>
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
-
-import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+import {
+ addConfigurationKey,
+ type ChargingStation,
+} from '../../../../src/charging-station/index.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
-import { OCPPVersion } from '../../../../src/types/index.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import {
+ type EmptyObject,
+ OCPP20ComponentName,
+ OCPP20RequiredVariableName,
+ OCPP20TransactionEventEnumType,
+ OCPP20TriggerReasonEnumType,
+ OCPPVersion,
+ type RequestParams,
+} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
import {
setupConnectorWithTransaction,
standardCleanup,
+ withMockTimers,
} from '../../../helpers/TestLifecycleHelpers.js'
-import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_TRANSACTION_UUID,
+} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { buildTransactionEventRequest } from './OCPP20ResponseServiceTestUtils.js'
await describe('D14 - GetTransactionStatus', async () => {
let station: ChargingStation
assert.strictEqual(response.ongoingIndicator, true)
assert.strictEqual(response.messagesInQueue, false)
})
+
+ await it('should return ongoingIndicator false while the transaction is ending', () => {
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_UUID })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+
+ const response = testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ })
+
+ assert.strictEqual(response.ongoingIndicator, false)
+ assert.strictEqual(response.messagesInQueue, false)
+ })
+
+ await it('should return ongoingIndicator false when a matching Ended event is queued', () => {
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_UUID })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+
+ const response = testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ })
+
+ assert.strictEqual(response.ongoingIndicator, false)
+ assert.strictEqual(response.messagesInQueue, true)
+ })
+
+ await it('should ignore an unrelated queued Ended event for the current transaction', () => {
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_UUID })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ '00000000-0000-0000-0000-000000000099',
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+
+ const response = testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ })
+
+ assert.strictEqual(response.ongoingIndicator, true)
+ assert.strictEqual(response.messagesInQueue, false)
+ })
+
+ await it('should stop reporting a direct delivery after its wire response arrives', async () => {
+ const requestSent = Promise.withResolvers<undefined>()
+ const deliverWireResponse = Promise.withResolvers<undefined>()
+ const wireResponseReceived = Promise.withResolvers<undefined>()
+ const finishResponseHandler = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestSent.resolve(undefined)
+ await deliverWireResponse.promise
+ requestParams.onResponseReceived?.()
+ wireResponseReceived.resolve(undefined)
+ await finishResponseHandler.promise
+ return {}
+ })
+ station.ocppRequestService.requestHandler =
+ requestHandlerMock as typeof station.ocppRequestService.requestHandler
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_UUID })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ 1,
+ TEST_TRANSACTION_UUID
+ )
+ await requestSent.promise
+
+ assert.deepStrictEqual(
+ testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ }),
+ { messagesInQueue: true, ongoingIndicator: false }
+ )
+
+ deliverWireResponse.resolve(undefined)
+ await wireResponseReceived.promise
+
+ assert.deepStrictEqual(
+ testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ }),
+ { messagesInQueue: false, ongoingIndicator: false }
+ )
+ assert.deepStrictEqual(testableService.handleRequestGetTransactionStatus(station, {}), {
+ messagesInQueue: false,
+ })
+
+ finishResponseHandler.resolve(undefined)
+ await delivery
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should report a direct Ended delivery during its E13 retry delay', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ let attempts = 0
+ const firstAttemptFailed = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ attempts++
+ if (attempts === 1) {
+ firstAttemptFailed.resolve(undefined)
+ return Promise.reject(new Error('first attempt failed'))
+ }
+ return Promise.reject(new Error('final attempt failed'))
+ })
+ station.ocppRequestService.requestHandler = requestHandlerMock
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: TEST_TRANSACTION_UUID })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ 1,
+ TEST_TRANSACTION_UUID
+ )
+ await firstAttemptFailed.promise
+ await new Promise<void>(resolve => {
+ setImmediate(resolve)
+ })
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(
+ testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ }),
+ { messagesInQueue: true, ongoingIndicator: false }
+ )
+ assert.deepStrictEqual(testableService.handleRequestGetTransactionStatus(station, {}), {
+ messagesInQueue: true,
+ })
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => ({
+ eventType: request.eventType,
+ transactionId: request.transactionInfo.transactionId,
+ })),
+ [
+ {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ transactionId: TEST_TRANSACTION_UUID,
+ },
+ ]
+ )
+
+ t.mock.timers.tick(1000)
+ await assert.rejects(delivery, /final attempt failed/)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+
+ assert.deepStrictEqual(
+ testableService.handleRequestGetTransactionStatus(station, {
+ transactionId: TEST_TRANSACTION_UUID,
+ }),
+ { messagesInQueue: false, ongoingIndicator: false }
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ })
+ })
})
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
-import { GenericStatus, OCPPVersion, ReportBaseEnumType } from '../../../../src/types/index.js'
+import {
+ GenericStatus,
+ OCPP20IncomingRequestCommand,
+ OCPPVersion,
+ ReportBaseEnumType,
+} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
import {
flushMicrotasks,
}
interface PlumbingAccess {
+ activate: (chargingStation: ChargingStation, lifecycleSignal: AbortSignal) => void
createStationState: () => OCPP20StationStateShape
getOrCreateStationState: (chargingStation: ChargingStation) => OCPP20StationStateShape
sendNotifyReportRequest: (
assert.strictEqual(plumbing.stationsState.has(station), false)
})
+ await it('should isolate a restarted NotifyReport cache from a late prior response callback', async () => {
+ const station = createStation('nr-restart-generation')
+ const lifecycle = station as unknown as { lifecycleAbortController: AbortController }
+ lifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycle.lifecycleAbortController.signal,
+ })
+ station.started = true
+ station.inAcceptedState = () => true
+ station.recordRequestStatistic = () => undefined
+ service.activate(station, lifecycle.lifecycleAbortController.signal)
+
+ const notifyStarted = Promise.withResolvers<undefined>()
+ const releaseNotify = Promise.withResolvers<unknown>()
+ const requestHandlerMock = mock.fn((): Promise<unknown> => {
+ notifyStarted.resolve(undefined)
+ return releaseNotify.promise
+ })
+ setRequestHandler(station, requestHandlerMock)
+ let lateOldResponse: (() => void) | undefined
+ ;(
+ station.ocppRequestService as unknown as {
+ sendResponse: (...args: unknown[]) => Promise<void>
+ }
+ ).sendResponse = (...args: unknown[]): Promise<void> => {
+ const messageId = args[1]
+ const requestParams = args[4] as
+ undefined | { onMessageSent?: () => void; onRequestBuffered?: () => void }
+ if (messageId === 'old-report') {
+ requestParams?.onRequestBuffered?.()
+ lateOldResponse = requestParams?.onMessageSent
+ return Promise.reject(new Error('buffered response transport closed'))
+ }
+ requestParams?.onMessageSent?.()
+ return Promise.resolve()
+ }
+
+ await service.incomingRequestHandler(
+ station,
+ 'old-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ { reportBase: ReportBaseEnumType.FullInventory, requestId: 101 }
+ )
+ const oldState = plumbing.stationsState.get(station)
+ assert.strictEqual(oldState?.reportDataCache.has(101), true)
+
+ service.stop(station)
+ lifecycle.lifecycleAbortController.abort()
+ lifecycle.lifecycleAbortController = new AbortController()
+ service.activate(station, lifecycle.lifecycleAbortController.signal)
+ const freshState = plumbing.stationsState.get(station)
+ if (freshState == null) assert.fail('Expected fresh lifecycle state')
+ assert.notStrictEqual(freshState, oldState)
+
+ await service.incomingRequestHandler(
+ station,
+ 'fresh-report',
+ OCPP20IncomingRequestCommand.GET_BASE_REPORT,
+ { reportBase: ReportBaseEnumType.FullInventory, requestId: 202 }
+ )
+ await notifyStarted.promise
+ assert.strictEqual(freshState.reportDataCache.has(202), true)
+
+ lateOldResponse?.()
+ await flushMicrotasks()
+ assert.strictEqual(freshState.reportDataCache.has(202), true)
+
+ releaseNotify.resolve({})
+ await flushMicrotasks()
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount() > 0, true)
+ assert.strictEqual(freshState.reportDataCache.has(202), false)
+ })
+
+ await it('should stop a multi-chunk report without touching restarted state', async () => {
+ const station = createStation('nr-stop-between-chunks')
+ const lifecycle = station as unknown as { lifecycleAbortController: AbortController }
+ lifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycle.lifecycleAbortController.signal,
+ })
+ service.activate(station, lifecycle.lifecycleAbortController.signal)
+ const oldState = plumbing.getOrCreateStationState(station)
+ const requestId = 303
+ oldState.reportDataCache.set(
+ requestId,
+ Array.from({ length: 101 }, (_, index) => ({ index }))
+ )
+ const firstChunkStarted = Promise.withResolvers<undefined>()
+ const releaseFirstChunk = Promise.withResolvers<unknown>()
+ const requestHandlerMock = mock.fn((): Promise<unknown> => {
+ firstChunkStarted.resolve(undefined)
+ return releaseFirstChunk.promise
+ })
+ setRequestHandler(station, requestHandlerMock)
+
+ const report = plumbing.sendNotifyReportRequest(
+ station,
+ { reportBase: ReportBaseEnumType.FullInventory, requestId },
+ { status: GenericStatus.Accepted }
+ )
+ await firstChunkStarted.promise
+ service.stop(station)
+ lifecycle.lifecycleAbortController.abort()
+ lifecycle.lifecycleAbortController = new AbortController()
+ service.activate(station, lifecycle.lifecycleAbortController.signal)
+ const freshState = plumbing.stationsState.get(station)
+ if (freshState == null) assert.fail('Expected restarted lifecycle state')
+ const freshCacheEntry = [{ fresh: true }]
+ freshState.reportDataCache.set(requestId, freshCacheEntry)
+
+ releaseFirstChunk.resolve({})
+ await report
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(oldState.stopped, true)
+ assert.strictEqual(oldState.reportDataCache.has(requestId), false)
+ assert.strictEqual(plumbing.stationsState.get(station), freshState)
+ assert.strictEqual(freshState.reportDataCache.get(requestId), freshCacheEntry)
+ })
+
await it('should silent-drop after stop() without resurrecting the entry', async () => {
const station = createStation('nr-post-stop')
const state = plumbing.getOrCreateStationState(station)
-import type { mock } from 'node:test'
-
/**
* @file Tests for OCPP20IncomingRequestService RequestStartTransaction
* @description Unit tests for OCPP 2.0 RequestStartTransaction command handling (F01/F02)
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type {
EvseStatus,
OCPP20ChargingProfileType,
OCPP20RequestStartTransactionRequest,
OCPP20RequestStartTransactionResponse,
- OCPP20TransactionEventOptions,
OCPP20TransactionEventRequest,
} from '../../../../src/types/index.js'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
import {
AuthenticationMethod,
} from '../../../../src/charging-station/ocpp/auth/index.js'
import {
AttributeEnumType,
+ ConnectorStatusEnum,
OCPP20ChargingProfileKindEnumType,
OCPP20ChargingProfilePurposeEnumType,
OCPP20ChargingRateUnitEnumType,
import { Constants } from '../../../../src/utils/index.js'
import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
-import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { createConnectorStatus, createMockChargingStation } from '../../helpers/StationHelpers.js'
import {
createMockAuthorizationResult,
createMockAuthService,
} from '../auth/helpers/MockFactories.js'
import {
createOCPP20ListenerStation,
+ createOCPP20RequestTestContext,
resetConnectorTransactionState,
resetLimits,
resetReportingValueSize,
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxStarted)
})
+ await it('should admit only one of two remote starts authorized concurrently', async () => {
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ let authorizationCalls = 0
+ const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationCalls++
+ if (authorizationCalls === 2) authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ const requests = [
+ {
+ evseId: 1,
+ idToken: { idToken: 'CONCURRENT_TOKEN_1', type: OCPP20IdTokenEnumType.ISO14443 },
+ remoteStartId: 103,
+ },
+ {
+ evseId: 1,
+ idToken: { idToken: 'CONCURRENT_TOKEN_2', type: OCPP20IdTokenEnumType.ISO14443 },
+ remoteStartId: 104,
+ },
+ ] satisfies OCPP20RequestStartTransactionRequest[]
+
+ const responses = requests.map(request =>
+ testableService.handleRequestStartTransaction(mockStation, request)
+ )
+ await authorizationStarted.promise
+ releaseAuthorization.resolve(undefined)
+ const [firstResponse, secondResponse] = await Promise.all(responses)
+
+ const acceptedResponses = [firstResponse, secondResponse].filter(
+ response => response.status === RequestStartStopStatusEnumType.Accepted
+ )
+ const rejectedResponses = [firstResponse, secondResponse].filter(
+ response => response.status === RequestStartStopStatusEnumType.Rejected
+ )
+ assert.strictEqual(acceptedResponses.length, 1)
+ assert.strictEqual(rejectedResponses.length, 1)
+ assert.strictEqual(rejectedResponses[0].statusInfo?.reasonCode, ReasonCodeEnumType.TxStarted)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionId, acceptedResponses[0].transactionId)
+ const acceptedRequest = firstResponse === acceptedResponses[0] ? requests[0] : requests[1]
+ assert.strictEqual(connectorStatus.transactionIdTag, acceptedRequest.idToken.idToken)
+ assert.strictEqual(connectorStatus.remoteStartId, acceptedRequest.remoteStartId)
+ })
+
+ await it('should not overwrite the transaction identity while Started delivery owns the connector', async () => {
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ if (connectorStatus == null) {
+ assert.fail('Connector 1 missing on mock station')
+ }
+ const transactionId = '00000000-0000-4000-8000-000000000020'
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionIdTag = 'FIRST_TOKEN'
+ connectorStatus.remoteStartId = 100
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'SECOND_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 101,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.transactionId, undefined)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxInProgress)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionIdTag, 'FIRST_TOKEN')
+ assert.strictEqual(connectorStatus.remoteStartId, 100)
+ })
+
await it('should reject RequestStartTransaction with TxInProgress (no transactionId echo) when connector is locked without pending transaction', async () => {
const connectorStatus = mockStation.getConnectorStatus(1)
if (connectorStatus == null) {
assert.ok(response.transactionId.length > 0, 'transactionId should not be empty')
})
+ await it('should reject an explicit inoperative EVSE without reserving its connector', async () => {
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.availability = OCPP20OperationalStatusEnumType.Inoperative
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'EXPLICIT_INOPERATIVE',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 498,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotFound)
+ assert.strictEqual(connectorStatus.transactionPending, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ })
+
+ await it('should reject when the EVSE becomes inoperative during authorization', async () => {
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+
+ const handling = testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'BECOMES_INOPERATIVE',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 499,
+ })
+ await authorizationStarted.promise
+ evseStatus.availability = OCPP20OperationalStatusEnumType.Inoperative
+ releaseAuthorization.resolve(undefined)
+ const response = await handling
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotFound)
+ assert.strictEqual(connectorStatus.transactionPending, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ })
+
+ for (const explicitEvse of [false, true]) {
+ await it(`should select an eligible sibling connector for ${explicitEvse ? 'an explicit' : 'an automatic'} EVSE target`, async () => {
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ connector1.availability = OCPP20OperationalStatusEnumType.Inoperative
+ connector1.status = ConnectorStatusEnum.Unavailable
+ const connector2 = createConnectorStatus(2)
+ evseStatus.connectors.set(2, connector2)
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ ...(explicitEvse && { evseId: 1 }),
+ idToken: {
+ idToken: `MULTI_CONNECTOR_${explicitEvse.toString()}`,
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: explicitEvse ? 1496 : 1497,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ assert.strictEqual(connector1.transactionPending, undefined)
+ assert.strictEqual(connector1.transactionId, undefined)
+ assert.strictEqual(connector2.transactionPending, true)
+ assert.strictEqual(connector2.transactionId, response.transactionId)
+ })
+ }
+
+ await it('should reject a free connector when its EVSE already owns a transaction', async () => {
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ connector1.transactionId = '00000000-0000-4000-8000-000000001502'
+ connector1.transactionStarted = true
+ const connector2 = createConnectorStatus(2)
+ evseStatus.connectors.set(2, connector2)
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'SIBLING_ACTIVE_TRANSACTION',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1502,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxInProgress)
+ assert.strictEqual(connector2.transactionPending, undefined)
+ assert.strictEqual(connector2.transactionId, undefined)
+ })
+
+ await it('should echo an explicit sibling transaction that becomes pending during authorization', async () => {
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const selectedConnector = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(selectedConnector != null)
+ const siblingConnector = createConnectorStatus(2)
+ evseStatus.connectors.set(2, siblingConnector)
+ const siblingTransactionId = '00000000-0000-4000-8000-000000001504'
+
+ const handling = testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'SIBLING_PENDING_DURING_AUTH',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1504,
+ })
+ await authorizationStarted.promise
+ siblingConnector.transactionId = siblingTransactionId
+ siblingConnector.transactionPending = true
+ releaseAuthorization.resolve(undefined)
+ const response = await handling
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxStarted)
+ assert.strictEqual(response.transactionId, siblingTransactionId)
+ assert.strictEqual(selectedConnector.transactionPending, undefined)
+ assert.strictEqual(selectedConnector.transactionId, undefined)
+ })
+
+ await it('should reject when a sibling connector starts during authorization', async () => {
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const selectedConnector = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(selectedConnector != null)
+ const siblingConnector = createConnectorStatus(2)
+ evseStatus.connectors.set(2, siblingConnector)
+
+ const handling = testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'SIBLING_STARTS_DURING_AUTH',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1503,
+ })
+ await authorizationStarted.promise
+ siblingConnector.transactionId = '00000000-0000-4000-8000-000000001503'
+ siblingConnector.transactionStarting = true
+ releaseAuthorization.resolve(undefined)
+ const response = await handling
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxInProgress)
+ assert.strictEqual(selectedConnector.transactionPending, undefined)
+ assert.strictEqual(selectedConnector.transactionId, undefined)
+ })
+
+ await it('should reject an explicit connector made inoperative before authorization', async () => {
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ testableService.handleRequestChangeAvailability(mockStation, {
+ evse: { connectorId: 1, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ evseId: 1,
+ idToken: {
+ idToken: 'EXPLICIT_CONNECTOR_INOPERATIVE',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1498,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotFound)
+ assert.strictEqual(connectorStatus.availability, OCPP20OperationalStatusEnumType.Inoperative)
+ assert.strictEqual(connectorStatus.transactionPending, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ })
+
+ await it('should skip a connector made inoperative during automatic EVSE selection', async () => {
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 2)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ testableService.handleRequestChangeAvailability(mockStation, {
+ evse: { connectorId: 1, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+
+ const response = await testableService.handleRequestStartTransaction(mockStation, {
+ idToken: {
+ idToken: 'AUTO_CONNECTOR_INOPERATIVE',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1499,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ assert.strictEqual(connector1.transactionPending, undefined)
+ assert.strictEqual(connector1.transactionId, undefined)
+ assert.strictEqual(connector2.transactionPending, true)
+ assert.strictEqual(connector2.transactionId, response.transactionId)
+ })
+
+ for (const explicitEvse of [false, true]) {
+ await it(`should reject an ${explicitEvse ? 'explicit' : 'automatically selected'} connector made inoperative during authorization`, async () => {
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = mockStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+
+ const handling = testableService.handleRequestStartTransaction(mockStation, {
+ ...(explicitEvse && { evseId: 1 }),
+ idToken: {
+ idToken: `BECOMES_CONNECTOR_INOPERATIVE_${explicitEvse.toString()}`,
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: explicitEvse ? 1500 : 1501,
+ })
+ await authorizationStarted.promise
+ testableService.handleRequestChangeAvailability(mockStation, {
+ evse: { connectorId: 1, id: 1 },
+ operationalStatus: OCPP20OperationalStatusEnumType.Inoperative,
+ })
+ releaseAuthorization.resolve(undefined)
+ const response = await handling
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotFound)
+ assert.strictEqual(connectorStatus.availability, OCPP20OperationalStatusEnumType.Inoperative)
+ assert.strictEqual(connectorStatus.transactionPending, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ })
+ }
+
// FR: F01 — EVSE availability during auto-selection
await it('should skip inoperative EVSEs during auto-selection without evseId', async () => {
// Arrange
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotFound)
})
+ await it('should roll back and notify only the resolved EVSE-local connector after definite response failure', async () => {
+ const statusNotifications: unknown[][] = []
+ const { station } = createMockChargingStation({
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppRequestService: {
+ requestHandler: async (...args: unknown[]) => {
+ if (args[1] === OCPP20RequestCommand.STATUS_NOTIFICATION) {
+ statusNotifications.push(args)
+ }
+ return Promise.resolve({})
+ },
+ },
+ stationInfo: { ocppStrictCompliance: false, ocppVersion: OCPPVersion.VERSION_201 },
+ })
+ const evse1 = station.getEvseStatus(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const evse1Connector = evse1.connectors.get(1)
+ assert.ok(evse1Connector != null)
+ const evse2Connector = createConnectorStatus(1, { status: ConnectorStatusEnum.Occupied })
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+ evse1Connector.status = ConnectorStatusEnum.Unavailable
+ evse1Connector.transactionPending = true
+ evse1Connector.transactionId = 'evse-1-existing-transaction'
+ const service = new OCPP20IncomingRequestService()
+ const testable = createTestableIncomingRequestService(service)
+ const stationId = station.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 2,
+ idToken: { idToken: 'EVSE_2_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ remoteStartId: 2001,
+ }
+
+ const response = await testable.handleRequestStartTransaction(station, request)
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ assert.strictEqual(evse2Connector.transactionId, response.transactionId)
+
+ await testable.onResponseSendError(
+ station,
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+
+ assert.strictEqual(evse1Connector.status, ConnectorStatusEnum.Unavailable)
+ assert.strictEqual(evse1Connector.transactionPending, true)
+ assert.strictEqual(evse1Connector.transactionId, 'evse-1-existing-transaction')
+ assert.strictEqual(evse2Connector.status, ConnectorStatusEnum.Available)
+ assert.strictEqual(evse2Connector.transactionPending, false)
+ assert.strictEqual(evse2Connector.transactionId, undefined)
+ assert.strictEqual(statusNotifications.length, 1)
+ assert.deepStrictEqual(statusNotifications[0]?.[2], {
+ connectorId: 1,
+ evseId: 2,
+ status: ConnectorStatusEnum.Available,
+ })
+ })
+
+ await it('should start and meter the exact EVSE-local connector after response delivery', async () => {
+ const transactionEvents: OCPP20TransactionEventRequest[] = []
+ const { station } = createMockChargingStation({
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppRequestService: {
+ requestHandler: async (...args: unknown[]) => {
+ if (args[1] === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ transactionEvents.push(args[2] as OCPP20TransactionEventRequest)
+ }
+ return Promise.resolve({})
+ },
+ },
+ stationInfo: { ocppStrictCompliance: false, ocppVersion: OCPPVersion.VERSION_201 },
+ })
+ const evse1 = station.getEvseStatus(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const evse1Connector = evse1.connectors.get(1)
+ assert.ok(evse1Connector != null)
+ const evse2Connector = createConnectorStatus(1)
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+ evse1Connector.transactionSeqNo = 91
+ evse2Connector.transactionSeqNo = 92
+ const service = new OCPP20IncomingRequestService()
+ const testable = createTestableIncomingRequestService(service)
+ const stationId = station.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+ const coherentSession = mock.method(station, 'createCoherentSession', () => undefined)
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 2,
+ idToken: { idToken: 'EVSE_2_TOKEN', type: OCPP20IdTokenEnumType.ISO14443 },
+ remoteStartId: 2002,
+ }
+
+ const response = await testable.handleRequestStartTransaction(station, request)
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ assert.strictEqual(evse1Connector.transactionSeqNo, 91)
+ assert.strictEqual(evse2Connector.transactionSeqNo, undefined)
+ service.emit(OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION, station, request, response)
+ await flushMicrotasks()
+
+ assert.strictEqual(coherentSession.mock.callCount(), 1)
+ assert.deepStrictEqual(coherentSession.mock.calls[0].arguments, [response.transactionId, 1, 2])
+ assert.strictEqual(transactionEvents.length, 1)
+ assert.deepStrictEqual(transactionEvents[0].evse, { connectorId: 1, id: 2 })
+ assert.strictEqual(evse1Connector.transactionId, undefined)
+ assert.strictEqual(evse2Connector.transactionId, response.transactionId)
+ })
+
await describe('REQUEST_START_TRANSACTION event listener', async () => {
let listenerService: OCPP20IncomingRequestService
let requestHandlerMock: ReturnType<typeof mock.fn>
assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
requestHandlerMock.mock.resetCalls()
- const request: OCPP20RequestStartTransactionRequest = {
- evseId: 1,
- idToken: {
- idToken: 'LISTENER_TOKEN_1',
- type: OCPP20IdTokenEnumType.ISO14443,
- },
- remoteStartId: 1,
- }
const response: OCPP20RequestStartTransactionResponse = {
status: RequestStartStopStatusEnumType.Accepted,
transactionId: startResponse.transactionId,
listenerService.emit(
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
listenerStation,
- request,
+ startRequest,
response
)
assert.strictEqual(args[2].eventType, OCPP20TransactionEventEnumType.Started)
})
+ await it('should not send Started when termination begins before response delivery', async () => {
+ const startRequest: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'ENDING_BEFORE_RESPONSE',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 105,
+ }
+ const response = await testableService.handleRequestStartTransaction(
+ listenerStation,
+ startRequest
+ )
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+ requestHandlerMock.mock.resetCalls()
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ listenerStation,
+ startRequest,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ })
+
+ await it('should not duplicate Started when a local start owns the pending transaction', async () => {
+ const startRequest: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'LOCAL_START_OWNS_PENDING',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 106,
+ }
+ const response = await testableService.handleRequestStartTransaction(
+ listenerStation,
+ startRequest
+ )
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarting = true
+ requestHandlerMock.mock.resetCalls()
+ const createCoherentSession = mock.method(
+ listenerStation,
+ 'createCoherentSession',
+ () => undefined
+ )
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ listenerStation,
+ startRequest,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(createCoherentSession.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+ })
+
+ await it('should reject a non-whitelisted response callback while stopping', async () => {
+ resetConnectorTransactionState(listenerStation)
+ requestHandlerMock.mock.resetCalls()
+ const responseSendStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ let deliveryCallbacks: undefined | { onMessageSent?: () => void }
+ mock.method(
+ listenerStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<void> => {
+ deliveryCallbacks = args[4] as typeof deliveryCallbacks
+ responseSendStarted.resolve(undefined)
+ return releaseResponse.promise
+ }
+ )
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'STOPPING_RESPONSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 106,
+ }
+ const handling = listenerService.incomingRequestHandler(
+ listenerStation,
+ 'stopping-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+ await responseSendStarted.promise
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+
+ ;(listenerStation as unknown as { stopping: boolean }).stopping = true
+ deliveryCallbacks?.onMessageSent?.()
+ releaseResponse.resolve(undefined)
+ await handling
+ ;(listenerStation as unknown as { stopping: boolean }).stopping = false
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ })
+
+ await it('should dispatch a buffered Accepted response replayed on a replacement socket', async () => {
+ resetConnectorTransactionState(listenerStation)
+ requestHandlerMock.mock.resetCalls()
+ let deliveryCallbacks:
+ | undefined
+ | { onError?: () => void; onMessageSent?: () => void; onRequestBuffered?: () => void }
+ mock.method(
+ listenerStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<void> => {
+ deliveryCallbacks = args[4] as typeof deliveryCallbacks
+ deliveryCallbacks?.onRequestBuffered?.()
+ return Promise.reject(new Error('response retained for replay'))
+ }
+ )
+ let sourceIsCurrent = true
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'REPLAYED_RESPONSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 105,
+ }
+
+ await listenerService.incomingRequestHandler(
+ listenerStation,
+ 'replayed-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request,
+ listenerStation.lifecycleAbortSignal,
+ () => true,
+ () => sourceIsCurrent
+ )
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+
+ sourceIsCurrent = false
+ deliveryCallbacks?.onMessageSent?.()
+ deliveryCallbacks?.onError?.()
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(typeof connectorStatus.transactionId, 'string')
+ assert.strictEqual(connectorStatus.transactionIdTag, request.idToken.idToken)
+ assert.strictEqual(connectorStatus.remoteStartId, request.remoteStartId)
+ })
+
await it('should NOT call TransactionEvent when response is Rejected', () => {
const request: OCPP20RequestStartTransactionRequest = {
evseId: 1,
assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
requestHandlerMock.mock.resetCalls()
- const request: OCPP20RequestStartTransactionRequest = {
- evseId: 2,
- idToken: {
- idToken: 'TRIGGER_REASON_TOKEN',
- type: OCPP20IdTokenEnumType.ISO14443,
- },
- remoteStartId: 3,
- }
const response: OCPP20RequestStartTransactionResponse = {
status: RequestStartStopStatusEnumType.Accepted,
transactionId: startResponse.transactionId,
listenerService.emit(
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
listenerStation,
- request,
+ startRequest,
response
)
const transactionEvent = args[2]
assert.strictEqual(transactionEvent.triggerReason, OCPP20TriggerReasonEnumType.RemoteStart)
// F01.FR.25: remoteStartId SHALL be included in TransactionEventRequest
- assert.strictEqual(
- (transactionEvent as unknown as OCPP20TransactionEventOptions).remoteStartId,
- 3
- )
+ assert.strictEqual(transactionEvent.transactionInfo.remoteStartId, 3)
})
await it('should handle TransactionEvent failure gracefully', async () => {
OCPPAuthServiceFactory.setInstanceForTesting(failStationId, createMockAuthService())
resetConnectorTransactionState(failStation)
- const startResponse = await testableService.handleRequestStartTransaction(failStation, {
+ const request: OCPP20RequestStartTransactionRequest = {
evseId: 1,
idToken: {
idToken: 'FAIL_START_TOKEN',
type: OCPP20IdTokenEnumType.ISO14443,
},
remoteStartId: 999,
- })
+ }
+ const startResponse = await testableService.handleRequestStartTransaction(
+ failStation,
+ request
+ )
assert.strictEqual(startResponse.status, RequestStartStopStatusEnumType.Accepted)
listenerService.emit(
OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
failStation,
- {
- evseId: 1,
- idToken: {
- idToken: 'FAIL_START_TOKEN',
- type: OCPP20IdTokenEnumType.ISO14443,
- },
- remoteStartId: 999,
- } satisfies OCPP20RequestStartTransactionRequest,
+ request,
{
status: RequestStartStopStatusEnumType.Accepted,
transactionId: startResponse.transactionId,
assert.strictEqual(transactionEventCallCount, 1)
})
+ await it('should roll back provisional state when an accepted response cannot be sent', async () => {
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'UNSENT_RESPONSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1000,
+ }
+ const response = await testableService.handleRequestStartTransaction(listenerStation, request)
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.transactionId, response.transactionId)
+ assert.strictEqual(connectorStatus.remoteStartId, request.remoteStartId)
+
+ await testableService.onResponseSendError(
+ listenerStation,
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should not reserve a transaction when the request source reconnects during authorization', async () => {
+ const { requestService, station: responseStation } = createOCPP20RequestTestContext({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-STALE-AUTH-SOURCE`,
+ })
+ const responseService = new OCPP20IncomingRequestService()
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = responseStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ resetConnectorTransactionState(responseStation)
+ resetLimits(responseStation)
+ resetReportingValueSize(responseStation)
+ responseStation.started = true
+ responseStation.recordRequestStatistic = () => undefined
+ responseStation.emitChargingStationEvent = () => undefined
+ Object.assign(responseStation, {
+ ocppIncomingRequestService: responseService,
+ ocppRequestService: requestService,
+ })
+ const sendResponse = mock.method(requestService, 'sendResponse', () => Promise.resolve())
+ let sourceCurrent = true
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'STALE_SOURCE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1001,
+ }
+
+ const handling = responseService.incomingRequestHandler(
+ responseStation,
+ 'stale-source-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request,
+ responseStation.lifecycleAbortSignal,
+ () => true,
+ () => sourceCurrent
+ )
+ await authorizationStarted.promise
+ sourceCurrent = false
+ releaseAuthorization.resolve(undefined)
+ await handling
+
+ const connectorStatus = responseStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.notStrictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.remoteStartId, undefined)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ })
+
+ await it('should not mutate a restarted transaction when old authorization completes', async () => {
+ const { requestService, station: responseStation } = createOCPP20RequestTestContext({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-STALE-HANDLER-ROLLBACK`,
+ })
+ const responseService = new OCPP20IncomingRequestService()
+ const authorizationStarted = Promise.withResolvers<undefined>()
+ const releaseAuthorization = Promise.withResolvers<undefined>()
+ const stationId = responseStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(
+ stationId,
+ createMockAuthService({
+ authorize: async () => {
+ authorizationStarted.resolve(undefined)
+ await releaseAuthorization.promise
+ return createMockAuthorizationResult({
+ method: AuthenticationMethod.REMOTE_AUTHORIZATION,
+ status: AuthResultStatus.ACCEPTED,
+ })
+ },
+ })
+ )
+ resetConnectorTransactionState(responseStation)
+ resetLimits(responseStation)
+ resetReportingValueSize(responseStation)
+ responseStation.started = true
+ responseStation.recordRequestStatistic = () => undefined
+ responseStation.emitChargingStationEvent = () => undefined
+ Object.assign(responseStation, {
+ ocppIncomingRequestService: responseService,
+ ocppRequestService: requestService,
+ })
+ const sendResponse = mock.method(requestService, 'sendResponse', () => Promise.resolve())
+ const stationLifecycle = responseStation as unknown as {
+ lifecycleAbortController: AbortController
+ performStop: () => Promise<void>
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(responseStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ stationLifecycle.performStop = () => {
+ responseService.stop(responseStation)
+ stationLifecycle.lifecycleAbortController.abort()
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ responseStation.started = false
+ return Promise.resolve()
+ }
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'STALE_HANDLER_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1002,
+ }
+
+ const handling = responseService.incomingRequestHandler(
+ responseStation,
+ 'stale-handler-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+ await authorizationStarted.promise
+ const connectorStatus = responseStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.notStrictEqual(connectorStatus.transactionPending, true)
+
+ await ChargingStation.prototype.stop.call(responseStation, undefined, false)
+ responseStation.start()
+ const replacementTransactionId = '00000000-0000-4000-8000-000000000105'
+ connectorStatus.transactionPending = true
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionIdTag = 'RESTARTED_WINNER'
+ connectorStatus.remoteStartId = 1003
+ releaseAuthorization.resolve(undefined)
+ await handling
+
+ assert.strictEqual(responseStation.started, true)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionIdTag, 'RESTARTED_WINNER')
+ assert.strictEqual(connectorStatus.remoteStartId, 1003)
+ assert.strictEqual(sendResponse.mock.callCount(), 0)
+ })
+
+ await it('should roll back an accepted remote start when stop cancels its in-flight response', async () => {
+ const { requestService, station: responseStation } = createOCPP20RequestTestContext({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-RESPONSE-CANCELLATION`,
+ })
+ const responseService = new OCPP20IncomingRequestService()
+ const stationId = responseStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+ resetConnectorTransactionState(responseStation)
+ resetLimits(responseStation)
+ resetReportingValueSize(responseStation)
+ responseStation.started = true
+ responseStation.recordRequestStatistic = () => undefined
+ responseStation.emitChargingStationEvent = () => undefined
+ Object.assign(responseStation, {
+ ocppIncomingRequestService: responseService,
+ ocppRequestService: requestService,
+ })
+ const transactionEventSpy = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () =>
+ Promise.resolve({})
+ )
+ const wsConnection = responseStation.wsConnection
+ assert.ok(wsConnection != null)
+ const responseTransportStarted = Promise.withResolvers<undefined>()
+ let sendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ sendCallback = callback
+ responseTransportStarted.resolve(undefined)
+ })
+ ;(
+ responseStation as unknown as {
+ performStop: () => Promise<void>
+ }
+ ).performStop = () => {
+ responseService.stop(responseStation)
+ responseStation.releaseAllBufferedMessageCallbacks()
+ responseStation.started = false
+ return Promise.resolve()
+ }
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'IN_FLIGHT_RESPONSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1001,
+ }
+
+ const handling = responseService.incomingRequestHandler(
+ responseStation,
+ 'in-flight-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+ await responseTransportStarted.promise
+ const connectorStatus = responseStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+
+ await Promise.all([
+ handling,
+ ChargingStation.prototype.stop.call(responseStation, undefined, false),
+ ])
+
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(transactionEventSpy.mock.callCount(), 0)
+
+ sendCallback?.()
+ await flushMicrotasks()
+
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(transactionEventSpy.mock.callCount(), 0)
+ })
+
+ await it('should roll back Accepted side effects when an ambiguous response is discarded on stop', async () => {
+ const { requestService, station: responseStation } = createOCPP20RequestTestContext({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-BUFFERED-RESPONSE-CANCELLATION`,
+ })
+ const responseService = new OCPP20IncomingRequestService()
+ const stationId = responseStation.stationInfo?.chargingStationId ?? 'unknown'
+ OCPPAuthServiceFactory.setInstanceForTesting(stationId, createMockAuthService())
+ resetConnectorTransactionState(responseStation)
+ resetLimits(responseStation)
+ resetReportingValueSize(responseStation)
+ responseStation.started = true
+ responseStation.recordRequestStatistic = () => undefined
+ responseStation.emitChargingStationEvent = () => undefined
+ Object.assign(responseStation, {
+ ocppIncomingRequestService: responseService,
+ ocppRequestService: requestService,
+ })
+ const transactionEventSpy = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', () =>
+ Promise.resolve({})
+ )
+ const wsConnection = responseStation.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.(new Error('response transport became ambiguous'))
+ })
+ ;(
+ responseStation as unknown as {
+ performStop: () => Promise<void>
+ }
+ ).performStop = () => {
+ responseService.stop(responseStation)
+ responseStation.releaseAllBufferedMessageCallbacks()
+ responseStation.started = false
+ return Promise.resolve()
+ }
+ const request: OCPP20RequestStartTransactionRequest = {
+ evseId: 1,
+ idToken: {
+ idToken: 'BUFFERED_RESPONSE_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ remoteStartId: 1002,
+ }
+
+ await responseService.incomingRequestHandler(
+ responseStation,
+ 'buffered-ambiguous-response',
+ OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
+ request
+ )
+ const connectorStatus = responseStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(transactionEventSpy.mock.callCount(), 0)
+
+ await ChargingStation.prototype.stop.call(responseStation, undefined, false)
+ await flushMicrotasks()
+
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(transactionEventSpy.mock.callCount(), 0)
+ })
+
// E01.FR.07 + E01.FR.16 + E03.FR.01: Verify transaction sequence number reset
await it('should reset transaction sequence number before setting up new transaction state', async () => {
const connectorStatus = mockStation.getConnectorStatus(1)
* @description Unit tests for OCPP 2.0 RequestStopTransaction command handling (F03)
*/
-import type { mock } from 'node:test'
-
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type {
return startResponse.transactionId as string
}
+ /**
+ * Hydrates a persisted final event for the selected connector.
+ * @param station - Station containing the transaction.
+ * @param connectorId - Connector that owns the transaction.
+ * @param queuedTransactionId - Transaction referenced by the queued Ended event.
+ */
+ function queueEndedTransaction (
+ station: ChargingStation,
+ connectorId: number,
+ queuedTransactionId: UUIDv4
+ ): void {
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const queuedAt = new Date('2026-09-07T12:00:00.000Z')
+ connectorStatus.transactionRestored = true
+ connectorStatus.transactionEventQueue = [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 1,
+ timestamp: queuedAt,
+ transactionInfo: { transactionId: queuedTransactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ },
+ seqNo: 1,
+ timestamp: queuedAt,
+ },
+ ]
+ }
+
await describe('Handler validation', async () => {
// FR: F03.FR.02, F03.FR.03, F03.FR.07, F03.FR.09
await it('should return Accepted for valid active transaction', async () => {
assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
})
+ await it('should reject a restored transaction with a matching queued Ended event', async () => {
+ const transactionId = await startTransaction(mockStation, 1, 101)
+ queueEndedTransaction(mockStation, 1, transactionId as UUIDv4)
+
+ const response = testableService.handleRequestStopTransaction(mockStation, {
+ transactionId: transactionId as UUIDv4,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+ })
+
+ await it('should accept an active transaction when queued Ended belongs to another transaction', async () => {
+ const transactionId = await startTransaction(mockStation, 1, 102)
+ queueEndedTransaction(mockStation, 1, '00000000-0000-4000-8000-000000000099')
+
+ const response = testableService.handleRequestStopTransaction(mockStation, {
+ transactionId: transactionId as UUIDv4,
+ })
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Accepted)
+ })
+
// FR: F03.FR.02, F03.FR.03
await it('should handle multiple active transactions correctly', async () => {
resetConnectorTransactionState(mockStation)
await flushMicrotasks()
- assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
const args = requestHandlerMock.mock.calls[0].arguments as [
unknown,
string,
}
)
+ await it('should not emit a second Ended event for a restored queued final transaction', async () => {
+ const transactionId = await startTransaction(listenerStation, 1, 101)
+ queueEndedTransaction(listenerStation, 1, transactionId as UUIDv4)
+ requestHandlerMock.mock.resetCalls()
+ const request = {
+ transactionId: transactionId as UUIDv4,
+ } satisfies OCPP20RequestStopTransactionRequest
+ const response = testableService.handleRequestStopTransaction(listenerStation, request)
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ listenerStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(response.status, RequestStartStopStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(listenerStation.getConnectorStatus(1)?.transactionEventQueue?.length, 1)
+ })
+
+ await it('should refuse a stale accepted dispatch when the transaction is already ending', async () => {
+ const transactionId = await startTransaction(listenerStation, 1, 102)
+ queueEndedTransaction(listenerStation, 1, transactionId as UUIDv4)
+ requestHandlerMock.mock.resetCalls()
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ listenerStation,
+ { transactionId: transactionId as UUIDv4 } satisfies OCPP20RequestStopTransactionRequest,
+ {
+ status: RequestStartStopStatusEnumType.Accepted,
+ } satisfies OCPP20RequestStopTransactionResponse
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(listenerStation.getConnectorStatus(1)?.transactionEventQueue?.length, 1)
+ })
+
+ await it('should not stop a replacement transaction from a stale accepted response', async () => {
+ const transactionId = await startTransaction(listenerStation, 1, 103)
+ const replacementTransactionId = '00000000-0000-4000-8000-000000000103'
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ requestHandlerMock.mock.resetCalls()
+ mock.method(listenerStation, 'getConnectorIdByTransactionId', () => 1)
+ mock.method(listenerStation, 'getEvseIdByTransactionId', () => {
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionEnding = false
+ return 1
+ })
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ listenerStation,
+ { transactionId: transactionId as UUIDv4 } satisfies OCPP20RequestStopTransactionRequest,
+ {
+ status: RequestStartStopStatusEnumType.Accepted,
+ } satisfies OCPP20RequestStopTransactionResponse
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnding, false)
+ })
+
+ await it('should drop a prior-lifecycle Accepted callback during a later shutdown', async () => {
+ const transactionId = await startTransaction(listenerStation, 1, 104)
+ requestHandlerMock.mock.resetCalls()
+ const responseSendStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ let deliveryCallbacks: undefined | { onMessageSent?: () => void }
+ mock.method(
+ listenerStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<void> => {
+ deliveryCallbacks = args[4] as typeof deliveryCallbacks
+ responseSendStarted.resolve(undefined)
+ return releaseResponse.promise
+ }
+ )
+ const stationLifecycle = listenerStation as unknown as {
+ lifecycleAbortController: AbortController
+ stopping: boolean
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(listenerStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ const admittedLifecycleSignal = listenerStation.lifecycleAbortSignal
+ const request = {
+ transactionId: transactionId as UUIDv4,
+ } satisfies OCPP20RequestStopTransactionRequest
+
+ const handling = listenerService.incomingRequestHandler(
+ listenerStation,
+ 'stale-request-stop-response',
+ OCPP20IncomingRequestCommand.REQUEST_STOP_TRANSACTION,
+ request,
+ admittedLifecycleSignal,
+ () =>
+ listenerStation.lifecycleAbortSignal === admittedLifecycleSignal &&
+ !admittedLifecycleSignal.aborted,
+ () => true
+ )
+ await responseSendStarted.promise
+ listenerService.stop(listenerStation)
+ stationLifecycle.lifecycleAbortController.abort()
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ listenerService.activate(listenerStation, listenerStation.lifecycleAbortSignal)
+ stationLifecycle.stopping = true
+ deliveryCallbacks?.onMessageSent?.()
+ releaseResponse.resolve(undefined)
+ await handling
+ stationLifecycle.stopping = false
+
+ const connectorStatus = listenerStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ })
+
await it('should NOT call requestStopTransaction when response is Rejected', () => {
const request: OCPP20RequestStopTransactionRequest = {
transactionId: 'any-transaction-id' as UUIDv4,
} satisfies OCPP20RequestStopTransactionResponse
)
+ await flushMicrotasks()
+
assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
const args = requestHandlerMock.mock.calls[0].arguments as [
unknown,
string,
- Partial<OCPP20TransactionEventRequest>
+ OCPP20TransactionEventRequest
]
- const minimalParams = args[2]
-
- assert.strictEqual(minimalParams.eventType, OCPP20TransactionEventEnumType.Ended)
- assert.strictEqual(minimalParams.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
- assert.strictEqual(minimalParams.transactionId, transactionId)
- assert.strictEqual(minimalParams.stoppedReason, OCPP20ReasonEnumType.Remote)
- assert.notStrictEqual(minimalParams.connectorId, undefined)
- assert.strictEqual(typeof minimalParams.connectorId, 'number')
+ const transactionEvent = args[2]
+
+ assert.strictEqual(transactionEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+ assert.strictEqual(transactionEvent.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
+ assert.strictEqual(transactionEvent.transactionInfo.transactionId, transactionId)
+ assert.strictEqual(
+ transactionEvent.transactionInfo.stoppedReason,
+ OCPP20ReasonEnumType.Remote
+ )
+ assert.strictEqual(transactionEvent.evse?.id, 2)
+ assert.strictEqual(transactionEvent.evse.connectorId, undefined)
})
// FR: F03.FR.09
} satisfies OCPP20RequestStopTransactionResponse
)
+ await flushMicrotasks()
+
assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
const args = requestHandlerMock.mock.calls[0].arguments as [
unknown,
import {
FirmwareStatus,
OCPP20ComponentName,
+ OCPP20IncomingRequestCommand,
ReasonCodeEnumType,
ResetEnumType,
ResetStatusEnumType,
} from '../../../../src/types/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
import { TEST_ONE_HOUR_MS } from '../../ChargingStationTestConstants.js'
import { ResetTestFixtures } from './OCPP20TestUtils.js'
})
// FR: B11.FR.03
- await it('should handle EVSE-specific reset request when no transactions', async () => {
+ await it('should handle EVSE-specific reset request when no transactions', () => {
const resetRequest: OCPP20ResetRequest = {
evseId: 1,
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
)
})
- await it('should reject reset for non-existent EVSE when no transactions', async () => {
+ await it('should send the Reset response before starting the station reset', async () => {
+ mockStation.started = true
+ mockStation.inAcceptedState = () => true
+ mockStation.recordRequestStatistic = () => undefined
+ const callOrder: string[] = []
+ const reset = mock.method(mockStation, 'reset', () => {
+ callOrder.push('reset')
+ return Promise.resolve()
+ })
+ const sendResponse = mock.method(
+ mockStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]) => {
+ callOrder.push('response')
+ const requestParams = args[4] as undefined | { onMessageSent?: () => void }
+ requestParams?.onMessageSent?.()
+ return Promise.resolve()
+ }
+ )
+
+ await incomingRequestService.incomingRequestHandler(
+ mockStation,
+ 'reset-response-before-stop',
+ OCPP20IncomingRequestCommand.RESET,
+ { type: ResetEnumType.Immediate }
+ )
+
+ assert.strictEqual(sendResponse.mock.callCount(), 1)
+ assert.strictEqual(reset.mock.callCount(), 1)
+ assert.deepStrictEqual(callOrder, ['response', 'reset'])
+ })
+
+ await it('should resolve reset-created async work against the replacement lifecycle', async () => {
+ const stationLifecycle = mockStation as unknown as {
+ lifecycleAbortController: AbortController
+ }
+ const stationStates = incomingRequestService as unknown as {
+ stationsState: WeakMap<object, { certSigningRetryManager?: unknown; stopped?: boolean }>
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(mockStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ mockStation.started = true
+ mockStation.inAcceptedState = () => true
+ mockStation.recordRequestStatistic = () => undefined
+ const resetFinished = Promise.withResolvers<undefined>()
+ let oldState: undefined | { certSigningRetryManager?: unknown; stopped?: boolean }
+ let freshState: undefined | { certSigningRetryManager?: unknown; stopped?: boolean }
+ let retryManager: unknown
+ mock.method(mockStation, 'reset', async () => {
+ oldState = stationStates.stationsState.get(mockStation)
+ incomingRequestService.stop(mockStation)
+ stationLifecycle.lifecycleAbortController.abort()
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ incomingRequestService.activate(
+ mockStation,
+ stationLifecycle.lifecycleAbortController.signal
+ )
+ freshState = stationStates.stationsState.get(mockStation)
+ await Promise.resolve()
+ retryManager = incomingRequestService.getCertSigningRetryManager(mockStation)
+ resetFinished.resolve(undefined)
+ })
+ mock.method(mockStation.ocppRequestService, 'sendResponse', (...args: unknown[]) => {
+ const requestParams = args[4] as undefined | { onMessageSent?: () => void }
+ requestParams?.onMessageSent?.()
+ return Promise.resolve()
+ })
+
+ await incomingRequestService.incomingRequestHandler(
+ mockStation,
+ 'reset-context-exit',
+ OCPP20IncomingRequestCommand.RESET,
+ { type: ResetEnumType.Immediate }
+ )
+ await resetFinished.promise
+
+ assert.ok(oldState != null)
+ assert.ok(freshState != null)
+ assert.notStrictEqual(freshState, oldState)
+ assert.strictEqual(oldState.stopped, true)
+ assert.notStrictEqual(retryManager, undefined)
+ assert.strictEqual(freshState.certSigningRetryManager, retryManager)
+ assert.strictEqual(oldState.certSigningRetryManager, undefined)
+ })
+
+ for (const scope of ['evse', 'station'] as const) {
+ for (const type of [ResetEnumType.Immediate, ResetEnumType.OnIdle] as const) {
+ await it(`should re-evaluate a ${scope} ${type} reset when a transaction starts before response delivery`, async () => {
+ const responseStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ let responseCallbacks: undefined | { onMessageSent?: () => void }
+ let responseStatus: ResetStatusEnumType | undefined
+ mockStation.started = true
+ mockStation.inAcceptedState = () => true
+ mockStation.recordRequestStatistic = () => undefined
+ mock.method(
+ mockStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<void> => {
+ responseStatus = (args[2] as OCPP20ResetResponse).status
+ responseCallbacks = args[4] as typeof responseCallbacks
+ responseStarted.resolve(undefined)
+ return releaseResponse.promise
+ }
+ )
+ const resetActions = incomingRequestService as unknown as {
+ scheduleEvseReset: (...args: unknown[]) => void
+ scheduleEvseResetOnIdle: (...args: unknown[]) => void
+ scheduleResetOnIdle: (...args: unknown[]) => void
+ terminateAllTransactions: (...args: unknown[]) => Promise<void>
+ terminateEvseTransactions: (...args: unknown[]) => Promise<void>
+ }
+ const scheduleEvseReset = mock.method(resetActions, 'scheduleEvseReset', () => undefined)
+ const scheduleEvseResetOnIdle = mock.method(
+ resetActions,
+ 'scheduleEvseResetOnIdle',
+ () => undefined
+ )
+ const scheduleResetOnIdle = mock.method(
+ resetActions,
+ 'scheduleResetOnIdle',
+ () => undefined
+ )
+ const terminateAllTransactions = mock.method(
+ resetActions,
+ 'terminateAllTransactions',
+ () => Promise.resolve()
+ )
+ const terminateEvseTransactions = mock.method(
+ resetActions,
+ 'terminateEvseTransactions',
+ () => Promise.resolve()
+ )
+ const reset = mock.method(mockStation, 'reset', () => Promise.resolve())
+ const request: OCPP20ResetRequest = {
+ ...(scope === 'evse' && { evseId: 1 }),
+ type,
+ }
+
+ const handling = incomingRequestService.incomingRequestHandler(
+ mockStation,
+ `delayed-${scope}-${type}`,
+ OCPP20IncomingRequestCommand.RESET,
+ request
+ )
+ await responseStarted.promise
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'delayed-reset-tx' })
+ if (scope === 'station') mockStation.getNumberOfRunningTransactions = () => 1
+ responseCallbacks?.onMessageSent?.()
+ releaseResponse.resolve(undefined)
+ await handling
+ await flushMicrotasks()
+
+ assert.strictEqual(responseStatus, ResetStatusEnumType.Accepted)
+ if (type === ResetEnumType.Immediate) {
+ assert.strictEqual(terminateEvseTransactions.mock.callCount(), scope === 'evse' ? 1 : 0)
+ assert.strictEqual(
+ terminateAllTransactions.mock.callCount(),
+ scope === 'station' ? 1 : 0
+ )
+ assert.strictEqual(scheduleEvseReset.mock.callCount(), scope === 'evse' ? 1 : 0)
+ assert.strictEqual(reset.mock.callCount(), scope === 'station' ? 1 : 0)
+ } else {
+ assert.strictEqual(scheduleEvseResetOnIdle.mock.callCount(), scope === 'evse' ? 1 : 0)
+ assert.strictEqual(scheduleResetOnIdle.mock.callCount(), scope === 'station' ? 1 : 0)
+ assert.strictEqual(reset.mock.callCount(), 0)
+ }
+ })
+ }
+ }
+
+ await it('should discard a Reset action when its response settles in a newer lifecycle', async () => {
+ const responseStarted = Promise.withResolvers<undefined>()
+ const releaseResponse = Promise.withResolvers<undefined>()
+ const stationLifecycle = mockStation as unknown as {
+ lifecycleAbortController: AbortController
+ }
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ Object.defineProperty(mockStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => stationLifecycle.lifecycleAbortController.signal,
+ })
+ mockStation.started = true
+ mockStation.inAcceptedState = () => true
+ mockStation.recordRequestStatistic = () => undefined
+ const reset = mock.method(mockStation, 'reset', () => Promise.resolve())
+ mock.method(mockStation.ocppRequestService, 'sendResponse', () => {
+ responseStarted.resolve(undefined)
+ return releaseResponse.promise
+ })
+
+ const handling = incomingRequestService.incomingRequestHandler(
+ mockStation,
+ 'stale-reset-response',
+ OCPP20IncomingRequestCommand.RESET,
+ { type: ResetEnumType.Immediate }
+ )
+ await responseStarted.promise
+ stationLifecycle.lifecycleAbortController.abort()
+ stationLifecycle.lifecycleAbortController = new AbortController()
+ releaseResponse.resolve(undefined)
+ await handling
+
+ assert.strictEqual(reset.mock.callCount(), 0)
+ })
+
+ await it('should reject reset for non-existent EVSE when no transactions', () => {
const resetRequest: OCPP20ResetRequest = {
evseId: 999, // Non-existent EVSE
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
})
// FR: B11.FR.01
- await it('should return proper response structure for immediate reset without transactions', async () => {
+ await it('should return proper response structure for immediate reset without transactions', () => {
const resetRequest: OCPP20ResetRequest = {
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
}
})
- await it('should return proper response structure for OnIdle reset without transactions', async () => {
+ await it('should return proper response structure for OnIdle reset without transactions', () => {
const resetRequest: OCPP20ResetRequest = {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
- await it('should reject EVSE-specific reset when EVSEs not supported (non-EVSE mode)', async () => {
+ await it('should reject EVSE-specific reset when EVSEs not supported (non-EVSE mode)', () => {
// Station configured without EVSE support
Object.defineProperty(mockStation, 'hasEvses', {
configurable: true,
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
})
})
- await it('should handle EVSE-specific reset without transactions', async () => {
+ await it('should handle EVSE-specific reset without transactions', () => {
const resetRequest: OCPP20ResetRequest = {
evseId: 1,
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
})
// FR: B12.FR.02
- await it('should handle immediate reset with active transactions', async () => {
+ await it('should handle immediate reset with active transactions', () => {
// Set active transaction count to 1
mockStation.getNumberOfRunningTransactions = () => 1
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
assert.strictEqual(response.statusInfo, undefined)
})
// FR: B12.FR.01
- await it('should handle OnIdle reset with active transactions', async () => {
+ await it('should handle OnIdle reset with active transactions', () => {
// Set active transaction count to 1
mockStation.getNumberOfRunningTransactions = () => 1
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
})
// FR: B12.FR.03
- await it('should handle EVSE-specific reset with active transactions', async () => {
+ await it('should handle EVSE-specific reset with active transactions', () => {
// Set active transaction count to 1
mockStation.getNumberOfRunningTransactions = () => 1
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
)
})
- await it('should reject EVSE reset when not supported with active transactions', async () => {
+ await it('should reject EVSE reset when not supported with active transactions', () => {
// Station configured without EVSE support and active transactions
Object.defineProperty(mockStation, 'hasEvses', {
configurable: true,
type: ResetEnumType.Immediate,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
mockStation,
resetRequest
)
await describe('Firmware Update Blocking', async () => {
// FR: B12.FR.04.01 - Station NOT idle during firmware operations
- await it('should return Rejected/FwUpdateInProgress when firmware is Downloading', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Downloading', () => {
const station = createTestStation()
// Firmware check runs before OnIdle idle-state logic — always returns Rejected
if (station.stationInfo == null) {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.FwUpdateInProgress)
})
- await it('should return Rejected/FwUpdateInProgress when firmware is Downloaded', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Downloaded', () => {
const station = createTestStation()
// Firmware check runs before OnIdle idle-state logic — always returns Rejected
if (station.stationInfo == null) {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.FwUpdateInProgress)
})
- await it('should return Rejected/FwUpdateInProgress when firmware is Installing', async () => {
+ await it('should return Rejected/FwUpdateInProgress when firmware is Installing', () => {
const station = createTestStation()
// Firmware check runs before OnIdle idle-state logic — always returns Rejected
if (station.stationInfo == null) {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.FwUpdateInProgress)
})
- await it('should return Accepted when firmware is Installed (complete)', async () => {
+ await it('should return Accepted when firmware is Installed (complete)', () => {
const station = createTestStation()
// Firmware status: Installed (complete)
if (station.stationInfo == null) {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
- await it('should return Accepted when firmware status is Idle', async () => {
+ await it('should return Accepted when firmware status is Idle', () => {
const station = createTestStation()
// Firmware status: Idle
if (station.stationInfo == null) {
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
await describe('Reservation Blocking', async () => {
// FR: B12.FR.04.02 - Station NOT idle with non-expired reservations
- await it('should return Scheduled when connector has non-expired reservation', async () => {
+ await it('should return Scheduled when connector has non-expired reservation', () => {
const station = createTestStation()
// Non-expired reservation (expires in 1 hour)
const futureExpiryDate = new Date(Date.now() + TEST_ONE_HOUR_MS)
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.status, ResetStatusEnumType.Scheduled)
})
- await it('should return Accepted when reservation is expired', async () => {
+ await it('should return Accepted when reservation is expired', () => {
const station = createTestStation()
// Expired reservation (1 hour ago)
const pastExpiryDate = new Date(Date.now() - TEST_ONE_HOUR_MS)
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
- await it('should return Accepted when no reservations exist', async () => {
+ await it('should return Accepted when no reservations exist', () => {
const station = createTestStation()
// No reservations (default)
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
await describe('Idle Condition', async () => {
// FR: B12.FR.04.03 - True idle: no transactions, no firmware update, no reservations
- await it('should return Accepted when all conditions clear (true idle state)', async () => {
+ await it('should return Accepted when all conditions clear (true idle state)', () => {
const station = createTestStation()
// No transactions
station.getNumberOfRunningTransactions = () => 0
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
- await it('should return Scheduled when multiple blocking conditions exist', async () => {
+ await it('should return Scheduled when multiple blocking conditions exist', () => {
const station = createTestStation()
// Transaction active
station.getNumberOfRunningTransactions = () => 1
type: ResetEnumType.OnIdle,
}
- const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+ const response: OCPP20ResetResponse = testableService.handleRequestReset(
station,
resetRequest
)
VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = savedDefaultValue
})
- await it('should reject with NotEnabled when AllowReset is false', async () => {
+ await it('should reject with NotEnabled when AllowReset is false', () => {
const station = ResetTestFixtures.createStandardStation()
VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'false'
const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
- const response = await testableService.handleRequestReset(station, request)
+ const response = testableService.handleRequestReset(station, request)
assert.strictEqual(response.status, ResetStatusEnumType.Rejected)
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
})
- await it('should proceed normally when AllowReset is true', async () => {
+ await it('should proceed normally when AllowReset is true', () => {
const station = ResetTestFixtures.createStandardStation()
VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = 'true'
const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
- const response = await testableService.handleRequestReset(station, request)
+ const response = testableService.handleRequestReset(station, request)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
- await it('should proceed normally when AllowReset defaultValue is undefined', async () => {
+ await it('should proceed normally when AllowReset defaultValue is undefined', () => {
const station = ResetTestFixtures.createStandardStation()
VARIABLE_REGISTRY[ALLOW_RESET_KEY].defaultValue = undefined
const request: OCPP20ResetRequest = { type: ResetEnumType.Immediate }
- const response = await testableService.handleRequestReset(station, request)
+ const response = testableService.handleRequestReset(station, request)
assert.strictEqual(response.status, ResetStatusEnumType.Accepted)
})
})
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type {
+ ConnectorStatus,
EvseStatus,
OCPP20FirmwareStatusNotificationRequest,
OCPP20MeterValuesRequest,
- OCPP20StatusNotificationRequest,
- OCPP20TransactionEventOptions,
+ OCPP20TransactionEventRequest,
OCPP20TriggerMessageRequest,
OCPP20TriggerMessageResponse,
RequestParams,
+ StatusNotificationOptions,
} from '../../../../src/types/index.js'
import type { MockChargingStation } from '../../helpers/StationHelpers.js'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
import { addConfigurationKey, buildConfigKey } from '../../../../src/charging-station/index.js'
+import { TransactionMeterValueDeliveryBarrier } from '../../../../src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.js'
import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
-import { buildTransactionEvent } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPPError } from '../../../../src/exception/index.js'
import {
+ CurrentType,
+ ErrorType,
MessageTriggerEnumType,
OCPP20ChargingStateEnumType,
OCPP20ComponentName,
+ OCPP20ConnectorStatusEnumType,
OCPP20FirmwareStatusEnumType,
OCPP20IncomingRequestCommand,
+ OCPP20LocationEnumType,
OCPP20MeasurandEnumType,
+ OCPP20OptionalVariableName,
OCPP20ReadingContextEnumType,
OCPP20RequestCommand,
OCPP20RequiredVariableName,
OCPP20TransactionEventEnumType,
OCPP20TriggerReasonEnumType,
OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
ReasonCodeEnumType,
RegistrationStatusEnumType,
+ SigningMethodEnumType,
TriggerMessageStatusEnumType,
} from '../../../../src/types/index.js'
-import { Constants } from '../../../../src/utils/index.js'
+import { Constants, isJsonObject } from '../../../../src/utils/index.js'
import {
flushMicrotasks,
setupConnectorWithTransaction,
standardCleanup,
} from '../../../helpers/TestLifecycleHelpers.js'
-import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
-import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_PUBLIC_KEY_HEX,
+} from '../../ChargingStationTestConstants.js'
+import { createConnectorStatus, createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { createOCPP20RequestTestContext } from './OCPP20TestUtils.js'
+
+/**
+ * Adds another connector to an EVSE in the TriggerMessage test topology.
+ * @param chargingStation - Station whose EVSE is extended.
+ * @param evseId - EVSE receiving the connector.
+ * @param connectorId - EVSE-local connector identifier.
+ * @param energyValue - Initial active-import register value.
+ * @returns The newly added connector status.
+ */
+function addTriggerMessageConnector (
+ chargingStation: MockChargingStation,
+ evseId: number,
+ connectorId: number,
+ energyValue: number
+): ConnectorStatus {
+ const evseStatus = chargingStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ const connectorStatus = createConnectorStatus(connectorId)
+ connectorStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.energyActiveImportRegisterValue = energyValue
+ evseStatus.connectors.set(connectorId, connectorStatus)
+ return connectorStatus
+}
+
+/**
+ * Configures opposite aligned and sampled signing policies for dispatch-path assertions.
+ * @param chargingStation - Station receiving signing configuration.
+ * @param aligned - Whether AlignedDataCtrlr signing is enabled.
+ * @param sampled - Whether SampledDataCtrlr signing is enabled.
+ */
+function configureSigningPolicies (
+ chargingStation: MockChargingStation,
+ aligned: boolean,
+ sampled: boolean
+): void {
+ for (const [component, enabled] of [
+ [OCPP20ComponentName.AlignedDataCtrlr, aligned],
+ [OCPP20ComponentName.SampledDataCtrlr, sampled],
+ ] as const) {
+ addConfigurationKey(
+ chargingStation,
+ buildConfigKey(component, OCPP20OptionalVariableName.SignReadings),
+ enabled.toString(),
+ undefined,
+ { overwrite: true, save: false }
+ )
+ }
+ addConfigurationKey(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ TEST_PUBLIC_KEY_HEX,
+ undefined,
+ { overwrite: true, save: false }
+ )
+ addConfigurationKey(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'SigningMethod'),
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256,
+ undefined,
+ { overwrite: true, save: false }
+ )
+}
/**
* Create a mock station suitable for TriggerMessage tests.
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
const mockStation = station as MockChargingStation
+ addConfigurationKey(
+ mockStation,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ for (const { connectorStatus, evseId } of mockStation.iterateConnectors(true)) {
+ connectorStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.energyActiveImportRegisterValue = (evseId ?? 0) * 10
+ }
return { mockStation, requestHandlerMock }
}
assert.strictEqual(response.statusInfo, undefined)
})
+ await it('should reject an idle MeterValues trigger without a configured sample', () => {
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ for (const connectorStatus of evseStatus.connectors.values()) {
+ connectorStatus.MeterValues = []
+ }
+
+ const response = testableService.handleRequestTriggerMessage(mockStation, {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ })
+
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
+ })
+
+ await it('should reject MeterValues for pending and restored transaction state', () => {
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'txn-meter-inactive-state' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+
+ connectorStatus.transactionPending = true
+ const pendingResponse = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(pendingResponse.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(pendingResponse.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
+
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionRestored = true
+ const restoredResponse = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(restoredResponse.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(restoredResponse.statusInfo?.reasonCode, ReasonCodeEnumType.NotEnabled)
+ })
+
+ await it('should reject a second MeterValues trigger until reserved capacity is released', async () => {
+ const firstRequest: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const secondRequest: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+
+ const firstResponse = testableService.handleRequestTriggerMessage(mockStation, firstRequest)
+ const rejectedResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ secondRequest
+ )
+
+ assert.strictEqual(firstResponse.status, TriggerMessageStatusEnumType.Accepted)
+ assert.strictEqual(rejectedResponse.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(rejectedResponse.statusInfo?.reasonCode, ReasonCodeEnumType.OutOfMemory)
+
+ await testableService.onResponseSendError(
+ mockStation,
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ firstRequest
+ )
+ const retriedResponse = testableService.handleRequestTriggerMessage(
+ mockStation,
+ secondRequest
+ )
+ assert.strictEqual(retriedResponse.status, TriggerMessageStatusEnumType.Accepted)
+ })
+
await it('should return Accepted for FirmwareStatusNotification trigger', () => {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.FirmwareStatusNotification,
assert.ok(response.statusInfo.additionalInfo.includes('999'))
})
+ await it('should reject a connector that does not belong to the specified EVSE', () => {
+ const response = testableService.handleRequestTriggerMessage(mockStation, {
+ evse: { connectorId: 2, id: 1 },
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ })
+
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+ if (response.statusInfo?.additionalInfo == null) {
+ assert.fail('Expected statusInfo with additionalInfo to be defined')
+ }
+ assert.strictEqual(response.statusInfo.reasonCode, ReasonCodeEnumType.UnknownConnectorId)
+ assert.ok(response.statusInfo.additionalInfo.includes('Connector 2 on EVSE 1'))
+ })
+
await it('should accept trigger when evse is undefined', () => {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.Heartbeat,
incomingRequestServiceForListener = new OCPP20IncomingRequestService()
})
+ /**
+ * Emits a triggered interval sample through a controlled transport failure.
+ * @param deliveryAmbiguous - Whether the transport may have delivered bytes.
+ * @returns Post-attempt interval accounting and the represented sample value.
+ */
+ async function emitTriggeredIntervalWithFailure (
+ deliveryAmbiguous: boolean
+ ): Promise<{ baseline: number | undefined; carry: number | undefined; represented: number }> {
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ mockStation.stationInfo.conversionEfficiency = 0.9
+ const baselineKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ addConfigurationKey(
+ mockStation,
+ baselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { overwrite: true, save: false }
+ )
+ setupConnectorWithTransaction(mockStation, 1, {
+ energyImport: 150,
+ transactionId: `txn-meter-trigger-${deliveryAmbiguous ? 'ambiguous' : 'unsent'}`,
+ })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = [
+ {
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 150
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 60 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 0 }
+ const failure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ deliveryAmbiguous ? 'ambiguous send failure' : 'pre-send failure',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ let represented: number | undefined
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ const payload = args[2] as OCPP20MeterValuesRequest
+ const requestParams = args[3] as RequestParams
+ const intervalSample = payload.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ represented = intervalSample?.value
+ requestParams.onTransportError?.(failure, deliveryAmbiguous)
+ return Promise.reject(failure)
+ })
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+ if (represented == null) assert.fail('Expected a triggered interval sample')
+ return {
+ baseline: connectorStatus.transactionEnergyActiveImportIntervalBaselines[baselineKey],
+ carry: connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey],
+ represented,
+ }
+ }
+
await it('should register TRIGGER_MESSAGE event listener in constructor', () => {
assert.strictEqual(
incomingRequestServiceForListener.listenerCount(
)
})
+ await it('should retain response delivery callbacks through reconnect replay', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const { requestService, station } = createOCPP20RequestTestContext()
+ station.recordRequestStatistic = () => undefined
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (onComplete: () => void, messageIdx?: number) => void
+ }
+ ).sendMessageBuffer.bind(station)
+ Object.assign(station, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageEntries: [],
+ bufferMessage: ChargingStation.prototype.bufferMessage.bind(station),
+ clearIntervalFlushMessageBuffer: () => undefined,
+ removeBufferedMessage: ChargingStation.prototype.removeBufferedMessage.bind(station),
+ sendMessageBuffer,
+ setIntervalFlushMessageBuffer: () => undefined,
+ })
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ let sendAttempts = 0
+ let buffered = false
+ let sent = false
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ sendAttempts++
+ callback?.(sendAttempts === 1 ? new Error('connection lost') : undefined)
+ }
+ )
+
+ await assert.rejects(
+ requestService.sendResponse(
+ station,
+ 'buffered-trigger-response',
+ { status: TriggerMessageStatusEnumType.Accepted },
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ {
+ onMessageSent: () => {
+ sent = true
+ },
+ onRequestBuffered: () => {
+ buffered = true
+ },
+ }
+ )
+ )
+ assert.strictEqual(buffered, true)
+ assert.strictEqual(sent, false)
+ const replayComplete = Promise.withResolvers<undefined>()
+ sendMessageBuffer(() => {
+ replayComplete.resolve(undefined)
+ })
+ assert.strictEqual(sendAttempts, 2)
+ assert.strictEqual(sent, true)
+ t.mock.timers.tick(60_000)
+ await replayComplete.promise
+ })
+
+ await it('should release both reservations for duplicate buffered response ids', async () => {
+ const { station } = createOCPP20RequestTestContext()
+ const service = new OCPP20IncomingRequestService()
+ station.recordRequestStatistic = () => undefined
+ Object.assign(station, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageEntries: [],
+ bufferMessage: ChargingStation.prototype.bufferMessage.bind(station),
+ clearIntervalFlushMessageBuffer: () => undefined,
+ clearMessageBuffer: ChargingStation.prototype.clearMessageBuffer.bind(station),
+ messageQueue: [],
+ setIntervalFlushMessageBuffer: () => undefined,
+ })
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ for (const evseId of [1, 2]) {
+ const connectorStatus = station.getConnectorStatus(evseId, evseId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.transactionId = `duplicate-response-${evseId.toString()}`
+ connectorStatus.transactionStarted = true
+ }
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (): never => {
+ throw new Error('response transport unavailable before send')
+ })
+ const firstRequest: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const secondRequest: OCPP20TriggerMessageRequest = {
+ evse: { id: 2 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+
+ await service.incomingRequestHandler(
+ station,
+ 'duplicate-inbound-id',
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ firstRequest
+ )
+ await service.incomingRequestHandler(
+ station,
+ 'duplicate-inbound-id',
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ secondRequest
+ )
+ const stationInternals = station as unknown as { messageQueue: string[] }
+ assert.strictEqual(stationInternals.messageQueue.length, 2)
+ assert.strictEqual(stationInternals.messageQueue[0], stationInternals.messageQueue[1])
+
+ station.clearMessageBuffer()
+ const testable = createTestableIncomingRequestService(service)
+ assert.strictEqual(
+ testable.handleRequestTriggerMessage(station, { ...firstRequest }).status,
+ TriggerMessageStatusEnumType.Accepted
+ )
+ assert.strictEqual(
+ testable.handleRequestTriggerMessage(station, { ...secondRequest }).status,
+ TriggerMessageStatusEnumType.Accepted
+ )
+ })
+
+ await it('should reject a tiny buffered response retaining an oversized DataTransfer request', async () => {
+ const { station } = createOCPP20RequestTestContext()
+ const service = new OCPP20IncomingRequestService()
+ station.recordRequestStatistic = () => undefined
+ Object.assign(station, {
+ bufferedMessageCallbackBytes: 0,
+ bufferedMessageCallbackCount: 0,
+ bufferedMessageEntries: [],
+ bufferMessage: ChargingStation.prototype.bufferMessage.bind(station),
+ clearIntervalFlushMessageBuffer: () => undefined,
+ messageQueue: [],
+ setIntervalFlushMessageBuffer: () => undefined,
+ })
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ mock.method(wsConnection, 'send', (_message: unknown, callback?: (error?: Error) => void) => {
+ callback?.(new Error('response transport failed'))
+ })
+
+ await assert.rejects(
+ service.incomingRequestHandler(
+ station,
+ 'large-data-transfer-response',
+ OCPP20IncomingRequestCommand.DATA_TRANSFER,
+ { data: 'x'.repeat(1024 * 1024), vendorId: 'large-vendor-request' }
+ )
+ )
+
+ const stationInternals = station as unknown as {
+ bufferedMessageCallbackBytes: number
+ bufferedMessageCallbackCount: number
+ messageQueue: string[]
+ }
+ assert.deepStrictEqual(stationInternals.messageQueue, [])
+ assert.strictEqual(stationInternals.bufferedMessageCallbackCount, 0)
+ assert.strictEqual(stationInternals.bufferedMessageCallbackBytes, 0)
+ })
+
+ await it('should defer an accepted trigger until its buffered response is sent', async () => {
+ let responseParams: RequestParams | undefined
+ mock.method(
+ mockStation.ocppRequestService,
+ 'sendResponse',
+ (...args: unknown[]): Promise<never> => {
+ responseParams = args[4] as RequestParams
+ responseParams.onRequestBuffered?.()
+ return Promise.reject(new Error('response buffered for reconnect'))
+ }
+ )
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+
+ await incomingRequestServiceForListener.incomingRequestHandler(
+ mockStation,
+ 'trigger-response-buffered',
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ request
+ )
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.ok(responseParams != null)
+
+ responseParams.onMessageSent?.()
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(
+ requestHandlerMock.mock.calls[0].arguments[1],
+ OCPP20RequestCommand.HEARTBEAT
+ )
+ })
+
await it('should NOT fire requestHandler when response status is Rejected', () => {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.Heartbeat,
})
}
- await it('should broadcast MeterValuesRequest for all EVSEs on Accepted (F06.FR.06)', () => {
+ await it('should broadcast MeterValuesRequest for all EVSEs on Accepted (F06.FR.06)', async () => {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.MeterValues,
}
- const response: OCPP20TriggerMessageResponse = {
- status: TriggerMessageStatusEnumType.Accepted,
- }
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
incomingRequestServiceForListener.emit(
OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
request,
response
)
+ await flushMicrotasks()
// F06.FR.06: TriggerMessage(MessageTrigger.MeterValues) without a
// specific EVSE MUST emit one MeterValuesRequest per EVSE. Fixture has
)
assert.strictEqual(
firstSample.measurand,
- OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
- 'Expected sampledValue[0].measurand = Power.Active.Import (placeholder emits Power.Active.Import = 0 W, truthful when idle, regardless of AlignedDataCtrlr.Measurands default which is Energy.Active.Import.Register)'
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
)
+ assert.strictEqual(firstSample.value, payload.evseId * 10)
assert.strictEqual(options.skipBufferingOnError, true)
assert.strictEqual(options.triggerMessage, true)
}
)
})
- await it('should broadcast StatusNotification for all EVSEs on Accepted without specific EVSE', () => {
+ await it('should sample only the exact connector requested for MeterValues', async () => {
+ addTriggerMessageConnector(mockStation, 1, 2, 20)
const request: OCPP20TriggerMessageRequest = {
- requestedMessage: MessageTriggerEnumType.StatusNotification,
- }
- const response: OCPP20TriggerMessageResponse = {
- status: TriggerMessageStatusEnumType.Accepted,
+ evse: { connectorId: 2, id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
}
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
incomingRequestServiceForListener.emit(
OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
request,
response
)
+ await flushMicrotasks()
- // 3 EVSEs (1, 2, 3) × 1 connector each = 3 StatusNotification calls
- const callCount = requestHandlerMock.mock.callCount()
- assert.strictEqual(callCount, 3)
- for (const call of requestHandlerMock.mock.calls) {
- const args = call.arguments as [
- unknown,
- string,
- Partial<OCPP20StatusNotificationRequest>,
- RequestParams
- ]
- const [, command, payload, options] = args
- assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
- assert.notStrictEqual(payload, undefined)
- assert.ok('evseId' in payload, 'Expected payload to include evseId')
- assert.ok('connectorId' in payload, 'Expected payload to include connectorId')
- assert.ok('connectorStatus' in payload, 'Expected payload to include connectorStatus')
- assert.ok(
- payload.evseId != null && payload.evseId > 0,
- 'Expected evseId > 0 (EVSE 0 excluded)'
- )
- assert.strictEqual(options.skipBufferingOnError, true)
- assert.strictEqual(options.triggerMessage, true)
- }
+ const meterValuesCalls = requestHandlerMock.mock.calls.filter(
+ call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES
+ )
+ assert.strictEqual(meterValuesCalls.length, 1)
+ const payload = meterValuesCalls[0].arguments[2] as OCPP20MeterValuesRequest
+ assert.strictEqual(payload.evseId, 1)
+ assert.strictEqual(payload.meterValue.length, 1)
+ assert.strictEqual(payload.meterValue[0].sampledValue[0].value, 20)
})
- await it('should fire StatusNotification for specific EVSE and connector via listener', () => {
+ await it('should sample every active connector when only an EVSE is requested', async () => {
+ setupConnectorWithTransaction(mockStation, 1, {
+ energyImport: 111,
+ transactionId: 'txn-evse-1-connector-1',
+ })
+ const connectorStatus = addTriggerMessageConnector(mockStation, 1, 2, 222)
+ connectorStatus.idTagAuthorized = true
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 222
+ connectorStatus.transactionId = 'txn-evse-1-connector-2'
+ connectorStatus.transactionIdTag = 'TAG-txn-evse-1-connector-2'
+ connectorStatus.transactionStart = new Date()
+ connectorStatus.transactionStarted = true
const request: OCPP20TriggerMessageRequest = {
- evse: { connectorId: 1, id: 1 },
- requestedMessage: MessageTriggerEnumType.StatusNotification,
- }
- const response: OCPP20TriggerMessageResponse = {
- status: TriggerMessageStatusEnumType.Accepted,
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
}
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
incomingRequestServiceForListener.emit(
OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
request,
response
)
+ await flushMicrotasks()
- assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
- const args = requestHandlerMock.mock.calls[0].arguments as [
- unknown,
- string,
- Partial<OCPP20StatusNotificationRequest>,
- RequestParams
- ]
- const [, command, payload, options] = args
- assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
+ const meterValuesCalls = requestHandlerMock.mock.calls.filter(
+ call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES
+ )
+ assert.strictEqual(meterValuesCalls.length, 1)
+ const payload = meterValuesCalls[0].arguments[2] as OCPP20MeterValuesRequest
assert.strictEqual(payload.evseId, 1)
- assert.strictEqual(payload.connectorId, 1)
- assert.ok('connectorStatus' in payload)
- assert.strictEqual(options.skipBufferingOnError, true)
- assert.strictEqual(options.triggerMessage, true)
+ assert.strictEqual(payload.meterValue.length, 2)
})
- await it('should handle requestHandler rejection gracefully via errorHandler', async () => {
- const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error')))
- const { station: rejectStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 3,
- evseConfiguration: { evsesCount: 3 },
- ocppRequestService: {
- requestHandler: rejectingMock,
- },
- stationInfo: {
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ await it('should use AlignedDataCtrlr signing for triggered MeterValues', async () => {
+ configureSigningPolicies(mockStation, true, false)
+ setupConnectorWithTransaction(mockStation, 1, {
+ energyImport: 150,
+ transactionId: 'txn-meter-trigger-aligned-signing',
})
-
const request: OCPP20TriggerMessageRequest = {
- requestedMessage: MessageTriggerEnumType.Heartbeat,
- }
- const response: OCPP20TriggerMessageResponse = {
- status: TriggerMessageStatusEnumType.Accepted,
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
}
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
incomingRequestServiceForListener.emit(
OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
- rejectStation,
+ mockStation,
request,
response
)
-
- // Flush microtask queue so .catch(errorHandler) executes
await flushMicrotasks()
- assert.strictEqual(rejectingMock.mock.callCount(), 1)
+ const meterValuesCall = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES
+ )
+ assert.ok(meterValuesCall != null)
+ const payload = meterValuesCall.arguments[2] as OCPP20MeterValuesRequest
+ assert.ok(
+ payload.meterValue.every(meterValue =>
+ meterValue.sampledValue.every(sampledValue => sampledValue.signedMeterValue != null)
+ )
+ )
})
- })
- await describe('F06 - FirmwareStatusNotification trigger last-sent semantics (L01.FR.20/25/26, L02.FR.14/16/17)', async () => {
- let incomingRequestServiceForListener: OCPP20IncomingRequestService
- let mockStation: MockChargingStation
- let requestHandlerMock: ReturnType<typeof mock.fn>
- let testableService: ReturnType<typeof createTestableIncomingRequestService>
+ await it('should reserve an exact active MeterValues snapshot without mutating interval state', async () => {
+ setupConnectorWithTransaction(mockStation, 1, {
+ energyImport: 150,
+ transactionId: 'txn-meter-trigger-snapshot',
+ })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const lastUpdatedAt = new Date('2026-09-12T12:00:00.000Z')
+ connectorStatus.energyActiveImportRegisterValue = 150
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = lastUpdatedAt
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { aligned: 120 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { aligned: 7 }
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
- beforeEach(() => {
- ;({ mockStation, requestHandlerMock } = createTriggerMessageStation())
- incomingRequestServiceForListener = new OCPP20IncomingRequestService()
- testableService = createTestableIncomingRequestService(incomingRequestServiceForListener)
- })
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
- /**
- * Emit TRIGGER_MESSAGE(FirmwareStatusNotification) with an Accepted response and
- * capture the resulting requestHandler payload.
- * @returns The captured request handler payload and the options object it was invoked with
- */
- function captureTriggeredFirmwareStatusPayload (): {
- options: RequestParams
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 150)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt,
+ lastUpdatedAt
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, {
+ aligned: 120,
+ })
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ aligned: 7,
+ })
+
+ connectorStatus.energyActiveImportRegisterValue = 999
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 999
+ connectorStatus.MeterValues = []
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const meterValuesCall = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES
+ )
+ assert.ok(meterValuesCall != null)
+ const payload = meterValuesCall.arguments[2] as OCPP20MeterValuesRequest
+ const energyRegisterSample = payload.meterValue[0].sampledValue.find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(energyRegisterSample?.value, 150)
+ })
+
+ await it('should restore triggered interval energy after a definite pre-send failure', async () => {
+ const { baseline, carry, represented } = await emitTriggeredIntervalWithFailure(false)
+
+ assert.strictEqual(represented, 100)
+ assert.strictEqual(baseline, 150)
+ assert.strictEqual(carry, 90)
+ })
+
+ await it('should keep triggered interval energy consumed after an ambiguous send failure', async () => {
+ const { baseline, carry, represented } = await emitTriggeredIntervalWithFailure(true)
+
+ assert.strictEqual(represented, 100)
+ assert.strictEqual(baseline, 150)
+ assert.strictEqual(carry, 0)
+ })
+
+ await it('should emit an admitted idle MeterValues snapshot after a transaction starts', async () => {
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 150
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'txn-started-after-meter-trigger',
+ })
+ connectorStatus.energyActiveImportRegisterValue = 999
+ connectorStatus.MeterValues = []
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const meterValuesCall = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES
+ )
+ assert.ok(meterValuesCall != null)
+ const payload = meterValuesCall.arguments[2] as OCPP20MeterValuesRequest
+ const energyRegisterSample = payload.meterValue[0].sampledValue.find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(energyRegisterSample?.value, 150)
+ })
+
+ await it('should skip a reserved MeterValues snapshot after its transaction is no longer active', async () => {
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'txn-meter-trigger-ended-before-dispatch',
+ })
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should reserve triggered MeterValues before Accepted and delay Ended through EVSE serialization', async () => {
+ const transactionId = 'txn-meter-trigger-stop-race'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const firstMeterValuesStarted = Promise.withResolvers<undefined>()
+ const releaseFirstMeterValues = Promise.withResolvers<undefined>()
+ const triggeredMeterValuesStarted = Promise.withResolvers<undefined>()
+ const releaseTriggeredMeterValues = Promise.withResolvers<undefined>()
+ const deliveryOrder: string[] = []
+ let meterValuesCount = 0
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]) => {
+ const command = args[1] as OCPP20RequestCommand
+ const requestParams = args[3] as RequestParams
+ if (command === OCPP20RequestCommand.METER_VALUES) {
+ meterValuesCount++
+ deliveryOrder.push(`MeterValues-${meterValuesCount.toString()}`)
+ requestParams.onMessageSent?.()
+ if (meterValuesCount === 1) {
+ firstMeterValuesStarted.resolve(undefined)
+ await releaseFirstMeterValues.promise
+ } else {
+ triggeredMeterValuesStarted.resolve(undefined)
+ await releaseTriggeredMeterValues.promise
+ }
+ requestParams.onResponseReceived?.()
+ } else if (command === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ deliveryOrder.push(payload.eventType)
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ }
+ return {}
+ })
+ const firstDelivery = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(mockStation, 1, {
+ evseId: 1,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ },
+ ],
+ })
+ await firstMeterValuesStarted.promise
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const listenerTestable = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ )
+ const response = listenerTestable.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ const stop = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['MeterValues-1'])
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ releaseFirstMeterValues.resolve(undefined)
+ await triggeredMeterValuesStarted.promise
+ assert.deepStrictEqual(deliveryOrder, ['MeterValues-1', 'MeterValues-2'])
+
+ releaseTriggeredMeterValues.resolve(undefined)
+ await Promise.all([firstDelivery, stop])
+ assert.deepStrictEqual(deliveryOrder, [
+ 'MeterValues-1',
+ 'MeterValues-2',
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ })
+
+ await it('should coalesce periodic updates behind an accepted triggered MeterValues snapshot', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'txn-meter-trigger-periodic-order',
+ })
+ const triggeredStarted = Promise.withResolvers<undefined>()
+ const releaseTriggered = Promise.withResolvers<undefined>()
+ const periodicStarted = Promise.withResolvers<undefined>()
+ const deliveryOrder: string[] = []
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]) => {
+ const command = args[1] as OCPP20RequestCommand
+ const requestParams = args[3] as RequestParams
+ if (command === OCPP20RequestCommand.METER_VALUES) {
+ deliveryOrder.push('triggered')
+ requestParams.onMessageSent?.()
+ triggeredStarted.resolve(undefined)
+ await releaseTriggered.promise
+ requestParams.onResponseReceived?.()
+ } else if (command === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ deliveryOrder.push('periodic')
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ periodicStarted.resolve(undefined)
+ }
+ return {}
+ })
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const listenerTestable = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ )
+ const response = listenerTestable.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await triggeredStarted.promise
+
+ OCPP20ServiceUtils.startUpdatedMeterValues(mockStation, 1, 1000, 1)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['triggered'])
+
+ releaseTriggered.resolve(undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['triggered'])
+ t.mock.timers.tick(1000)
+ await periodicStarted.promise
+ assert.deepStrictEqual(deliveryOrder, ['triggered', 'periodic'])
+ OCPP20ServiceUtils.stopUpdatedMeterValues(mockStation, 1, 1)
+ })
+
+ await it('should emit one accepted trigger behind a pending periodic TransactionEvent', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const transactionId = 'txn-periodic-before-trigger'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const releasePeriodic = Promise.withResolvers<undefined>()
+ const triggeredStarted = Promise.withResolvers<undefined>()
+ const deliveryOrder: string[] = []
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]) => {
+ const command = args[1] as OCPP20RequestCommand
+ const requestParams = args[3] as RequestParams
+ if (command === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ deliveryOrder.push('periodic')
+ requestParams.onMessageSent?.()
+ await releasePeriodic.promise
+ requestParams.onResponseReceived?.()
+ } else if (command === OCPP20RequestCommand.METER_VALUES) {
+ deliveryOrder.push('triggered')
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ triggeredStarted.resolve(undefined)
+ }
+ return {}
+ })
+
+ OCPP20ServiceUtils.startUpdatedMeterValues(mockStation, 1, 1000, 1)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['periodic'])
+
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const listenerTestable = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ )
+ const response = listenerTestable.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+ assert.deepStrictEqual(deliveryOrder, ['periodic'])
+
+ releasePeriodic.resolve(undefined)
+ await triggeredStarted.promise
+ assert.deepStrictEqual(deliveryOrder, ['periodic', 'triggered'])
+ OCPP20ServiceUtils.stopUpdatedMeterValues(mockStation, 1, 1)
+ })
+
+ await it('should preserve restored and newly accrued interval energy behind a rejected predecessor', async () => {
+ const baselineKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ addConfigurationKey(
+ mockStation,
+ baselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { overwrite: true, save: false }
+ )
+ const transactionId = 'txn-trigger-rejected-predecessor'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 0 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 0 }
+ const predecessor = TransactionMeterValueDeliveryBarrier.begin(connectorStatus, transactionId)
+ assert.ok(predecessor != null)
+ const triggerStarted = Promise.withResolvers<undefined>()
+ let triggeredInterval: number | undefined
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ if (args[1] !== OCPP20RequestCommand.METER_VALUES) return Promise.resolve({})
+ const payload = args[2] as OCPP20MeterValuesRequest
+ const requestParams = args[3] as RequestParams
+ triggeredInterval = payload.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ triggerStarted.resolve(undefined)
+ return Promise.resolve({})
+ })
+
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 20
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const response = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ ).handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 30
+ await flushMicrotasks()
+ assert.strictEqual(triggeredInterval, undefined)
+
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey] = 50
+ predecessor.settle(true)
+ await triggerStarted.promise
+
+ assert.strictEqual(triggeredInterval, 20)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines[baselineKey],
+ 30
+ )
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[baselineKey],
+ 60
+ )
+ })
+
+ await it('should coalesce TxEnded sampling behind an admitted triggered snapshot', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ configureSigningPolicies(mockStation, true, false)
+ addConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue
+ ),
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ undefined,
+ { overwrite: true, save: false }
+ )
+ const transactionId = 'txn-trigger-ended-sampler-order'
+ setupConnectorWithTransaction(mockStation, 1, { energyImport: 150, transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const alignedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ const endedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ )
+ for (const [key, measurands] of [
+ [
+ alignedMeasurandsKey,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER}`,
+ ],
+ [endedMeasurandsKey, OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL],
+ ] as const) {
+ addConfigurationKey(mockStation, key, measurands, undefined, {
+ overwrite: true,
+ save: false,
+ })
+ }
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 150
+ // Pin the register read ahead of the sample window so the coalesced interval energy
+ // reflects only the register delta, never wall-clock proration (deterministic on any host).
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(
+ Date.now() + 60_000
+ )
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [alignedMeasurandsKey]: 120,
+ [endedMeasurandsKey]: 120,
+ }
+ const triggerStarted = Promise.withResolvers<OCPP20MeterValuesRequest>()
+ const releaseTrigger = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]) => {
+ if (args[1] === OCPP20RequestCommand.METER_VALUES) {
+ const triggeredRequest = args[2] as OCPP20MeterValuesRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ triggerStarted.resolve(triggeredRequest)
+ await releaseTrigger.promise
+ requestParams.onResponseReceived?.()
+ }
+ return {}
+ })
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const listenerTestable = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ )
+ const response = listenerTestable.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ OCPP20ServiceUtils.startEndedMeterValues(mockStation, 1, 1000, 1)
+ t.mock.timers.tick(1000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(connectorStatus.transactionEndedMeterValues, [])
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ const triggeredRequest = await triggerStarted.promise
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 180
+ // Pin the register read ahead of the sample window so the coalesced interval energy
+ // reflects only the register delta, never wall-clock proration (deterministic on any host).
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(
+ Date.now() + 60_000
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEndedMeterValues, [])
+
+ releaseTrigger.resolve(undefined)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+ t.mock.timers.tick(1000)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+ OCPP20ServiceUtils.stopEndedMeterValues(mockStation, 1, 1)
+
+ const triggeredInterval = triggeredRequest.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const completedConnectorStatus = mockStation.getConnectorStatus(1, 1)
+ const endedMeterValue = completedConnectorStatus?.transactionEndedMeterValues?.[0]
+ assert.ok(endedMeterValue != null)
+ const endedInterval = endedMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.ok(triggeredInterval != null)
+ assert.ok(endedInterval != null)
+ assert.strictEqual(triggeredInterval.value, 30)
+ assert.strictEqual(endedInterval.value, 60)
+ assert.strictEqual(triggeredInterval.context, OCPP20ReadingContextEnumType.TRIGGER)
+ assert.strictEqual(endedInterval.context, OCPP20ReadingContextEnumType.SAMPLE_PERIODIC)
+ const publicKeyCarrierCount = [
+ ...triggeredRequest.meterValue.flatMap(meterValue => meterValue.sampledValue),
+ endedInterval,
+ ].filter(sample => {
+ const signedMeterValue: unknown = sample.signedMeterValue
+ return (
+ isJsonObject(signedMeterValue) &&
+ typeof signedMeterValue.publicKey === 'string' &&
+ signedMeterValue.publicKey.length > 0
+ )
+ }).length
+ assert.strictEqual(publicKeyCarrierCount, 1)
+ assert.ok(completedConnectorStatus != null)
+ assert.strictEqual(completedConnectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should release a triggered MeterValues delivery barrier when the response cannot be sent', async () => {
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'txn-meter-trigger-response-failure',
+ })
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.MeterValues,
+ }
+ const listenerTestable = createTestableIncomingRequestService(
+ incomingRequestServiceForListener
+ )
+ const response = listenerTestable.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ const stop = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+
+ await listenerTestable.onResponseSendError(
+ mockStation,
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ request
+ )
+ await stop
+
+ const transactionEvents = requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(call => call.arguments[2] as OCPP20TransactionEventRequest)
+ assert.deepStrictEqual(
+ transactionEvents.map(({ eventType }) => eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ })
+
+ await it('should broadcast StatusNotification to every connector when EVSE is absent', () => {
+ addTriggerMessageConnector(mockStation, 1, 2, 20)
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 4)
+ const targets = new Set<string>()
+ for (const call of requestHandlerMock.mock.calls) {
+ const args = call.arguments as [unknown, string, StatusNotificationOptions, RequestParams]
+ const [, command, payload, options] = args
+ assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
+ assert.ok(payload.evseId != null)
+ targets.add(`${payload.evseId.toString()}:${payload.connectorId.toString()}`)
+ assert.strictEqual(options.skipBufferingOnError, true)
+ assert.strictEqual(options.triggerMessage, true)
+ }
+ assert.deepStrictEqual(targets, new Set(['1:1', '1:2', '2:2', '3:3']))
+ })
+
+ await it('should notify every connector of only the specified EVSE', () => {
+ addTriggerMessageConnector(mockStation, 1, 2, 20)
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ const payloads = requestHandlerMock.mock.calls.map(
+ call => call.arguments[2] as StatusNotificationOptions
+ )
+ assert.deepStrictEqual(
+ payloads.map(({ connectorId, evseId }) => {
+ assert.ok(evseId != null)
+ return `${evseId.toString()}:${connectorId.toString()}`
+ }),
+ ['1:1', '1:2']
+ )
+ })
+
+ await it('should fire StatusNotification only for the exact requested connector', () => {
+ const connectorStatus = addTriggerMessageConnector(mockStation, 1, 2, 20)
+ connectorStatus.status = OCPP20ConnectorStatusEnumType.Occupied
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { connectorId: 2, id: 1 },
+ requestedMessage: MessageTriggerEnumType.StatusNotification,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const args = requestHandlerMock.mock.calls[0].arguments as [
+ unknown,
+ string,
+ StatusNotificationOptions,
+ RequestParams
+ ]
+ const [, command, payload, options] = args
+ assert.strictEqual(command, OCPP20RequestCommand.STATUS_NOTIFICATION)
+ assert.strictEqual(payload.evseId, 1)
+ assert.strictEqual(payload.connectorId, 2)
+ assert.strictEqual(payload.connectorStatus, OCPP20ConnectorStatusEnumType.Occupied)
+ assert.strictEqual(options.skipBufferingOnError, true)
+ assert.strictEqual(options.triggerMessage, true)
+ })
+
+ await it('should handle requestHandler rejection gracefully via errorHandler', async () => {
+ const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error')))
+ const { station: rejectStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 3,
+ evseConfiguration: { evsesCount: 3 },
+ ocppRequestService: {
+ requestHandler: rejectingMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+
+ const request: OCPP20TriggerMessageRequest = {
+ requestedMessage: MessageTriggerEnumType.Heartbeat,
+ }
+ const response: OCPP20TriggerMessageResponse = {
+ status: TriggerMessageStatusEnumType.Accepted,
+ }
+
+ incomingRequestServiceForListener.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ rejectStation,
+ request,
+ response
+ )
+
+ // Flush microtask queue so .catch(errorHandler) executes
+ await flushMicrotasks()
+
+ assert.strictEqual(rejectingMock.mock.callCount(), 1)
+ })
+ })
+
+ await describe('F06 - FirmwareStatusNotification trigger last-sent semantics (L01.FR.20/25/26, L02.FR.14/16/17)', async () => {
+ let incomingRequestServiceForListener: OCPP20IncomingRequestService
+ let mockStation: MockChargingStation
+ let requestHandlerMock: ReturnType<typeof mock.fn>
+ let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+ beforeEach(() => {
+ ;({ mockStation, requestHandlerMock } = createTriggerMessageStation())
+ incomingRequestServiceForListener = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestServiceForListener)
+ })
+
+ /**
+ * Emit TRIGGER_MESSAGE(FirmwareStatusNotification) with an Accepted response and
+ * capture the resulting requestHandler payload.
+ * @returns The captured request handler payload and the options object it was invoked with
+ */
+ function captureTriggeredFirmwareStatusPayload (): {
+ options: RequestParams
payload: OCPP20FirmwareStatusNotificationRequest
} {
const request: OCPP20TriggerMessageRequest = {
* @param evse - Optional EVSE scope for the trigger request.
* @returns The captured TransactionEvent request payloads.
*/
- function emitTransactionEventTrigger (
+ async function emitTransactionEventTrigger (
evse?: OCPP20TriggerMessageRequest['evse']
- ): OCPP20TransactionEventOptions[] {
+ ): Promise<OCPP20TransactionEventRequest[]> {
const request: OCPP20TriggerMessageRequest = {
requestedMessage: MessageTriggerEnumType.TransactionEvent,
...(evse != null && { evse }),
}
- const response: OCPP20TriggerMessageResponse = {
- status: TriggerMessageStatusEnumType.Accepted,
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ if (response.status === TriggerMessageStatusEnumType.Accepted) {
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
}
- listenerService.emit(
- OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
- mockStation,
- request,
- response
- )
+ await flushMicrotasks()
return requestHandlerMock.mock.calls
.map(
call =>
call.arguments as [
unknown,
OCPP20RequestCommand,
- OCPP20TransactionEventOptions,
+ OCPP20TransactionEventRequest,
RequestParams
]
)
assert.strictEqual(response.statusInfo, undefined)
})
+ await it('should deliver an accepted TransactionEvent without enqueueing into a saturated queue', async () => {
+ const transactionId = 'txn-capacity-direct'
+ seedActiveTransaction(1, transactionId)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ {
+ deliveryAttempted: false,
+ request: {
+ customData: {
+ payload: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ vendorId: 'test',
+ },
+ eventType: OCPP20TransactionEventEnumType.Started,
+ seqNo: 0,
+ timestamp: new Date(),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000000' },
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ },
+ seqNo: 0,
+ timestamp: new Date(),
+ },
+ ]
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const transactionEvents = requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(call => call.arguments[2] as OCPP20TransactionEventRequest)
+ assert.strictEqual(transactionEvents.at(-1)?.transactionInfo.transactionId, transactionId)
+ assert.strictEqual(
+ transactionEvents.at(-1)?.triggerReason,
+ OCPP20TriggerReasonEnumType.Trigger
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should release a TransactionEvent reservation when the response cannot be sent', async () => {
+ seedActiveTransaction(1, 'txn-reservation-release')
+ const firstRequest: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const duplicateRequest: OCPP20TriggerMessageRequest = {
+ ...firstRequest,
+ evse: { id: 1 },
+ }
+ assert.strictEqual(
+ testableService.handleRequestTriggerMessage(mockStation, firstRequest).status,
+ TriggerMessageStatusEnumType.Accepted
+ )
+ assert.strictEqual(
+ testableService.handleRequestTriggerMessage(mockStation, duplicateRequest).status,
+ TriggerMessageStatusEnumType.Rejected
+ )
+
+ await testableService.onResponseSendError(
+ mockStation,
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ firstRequest
+ )
+
+ assert.strictEqual(
+ testableService.handleRequestTriggerMessage(mockStation, duplicateRequest).status,
+ TriggerMessageStatusEnumType.Accepted
+ )
+ })
+
+ await it('should emit an accepted triggered Updated before a racing Ended event', async () => {
+ const transactionId = 'txn-trigger-stop-race'
+ seedActiveTransaction(1, transactionId)
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+
+ const stop = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await stop
+
+ const eventTypes = requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(call => (call.arguments[2] as OCPP20TransactionEventRequest).eventType)
+ assert.deepStrictEqual(eventTypes, [
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ })
+
+ await it('should skip a reserved TransactionEvent after its transaction is no longer active', async () => {
+ seedActiveTransaction(1, 'txn-trigger-ended-before-dispatch')
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should reject pending and restored transactions for TransactionEvent', () => {
+ seedActiveTransaction(1, 'txn-trigger-inactive-state')
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+
+ connectorStatus.transactionPending = true
+ const pendingResponse = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(pendingResponse.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(pendingResponse.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionRestored = true
+ const restoredResponse = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(restoredResponse.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(restoredResponse.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+ })
+
+ await it('should not emit Updated after an Ended event is committed', async () => {
+ const transactionId = 'txn-ending'
+ seedActiveTransaction(1, transactionId)
+ const endedStarted = Promise.withResolvers<undefined>()
+ const releaseEnded = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]) => {
+ const command = args[1] as OCPP20RequestCommand
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ if (
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ ) {
+ requestParams.onMessageSent?.()
+ endedStarted.resolve(undefined)
+ await releaseEnded.promise
+ requestParams.onResponseReceived?.()
+ }
+ return {}
+ })
+
+ const stop = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ await endedStarted.promise
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ try {
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ const transactionEvents = requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(call => call.arguments[2] as OCPP20TransactionEventRequest)
+ assert.deepStrictEqual(
+ transactionEvents.map(({ eventType }) => eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ } finally {
+ releaseEnded.resolve(undefined)
+ await stop
+ }
+ })
+
+ await it('should reject a restored transaction with a matching queued Ended event', async () => {
+ const transactionId = 'txn-restored-ended'
+ seedActiveTransaction(1, transactionId)
+ mock.method(mockStation, 'isWebSocketConnectionOpened', () => false)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ 1,
+ transactionId,
+ { evseId: 1 }
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue?.[0]?.request.eventType,
+ OCPP20TransactionEventEnumType.Ended
+ )
+
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const response = testableService.handleRequestTriggerMessage(mockStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+ assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.TxNotFound)
+
+ listenerService.emit(
+ OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+ mockStation,
+ request,
+ response
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
await it('should treat evse.id === 0 as broadcast scope and Accept when a transaction is active (F06.FR.11)', () => {
seedActiveTransaction(1, 'txn-evse-1')
assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnknownEvse)
})
- await it('should emit one TransactionEvent(Updated, Trigger) for a specified EVSE (F06.FR.07)', () => {
+ await it('should emit one TransactionEvent(Updated, Trigger) for a specified EVSE (F06.FR.07)', async () => {
seedActiveTransaction(1, 'txn-evse-1')
- const payloads = emitTransactionEventTrigger({ id: 1 })
+ const payloads = await emitTransactionEventTrigger({ id: 1 })
assert.strictEqual(payloads.length, 1)
const payload = payloads[0]
assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
- assert.strictEqual(payload.transactionId, 'txn-evse-1')
- assert.strictEqual(payload.connectorId, 1)
+ assert.strictEqual(payload.transactionInfo.transactionId, 'txn-evse-1')
+ assert.strictEqual(payload.evse?.id, 1)
+ assert.strictEqual(mockStation.getConnectorStatus(1, 1)?.transactionEventQueue, undefined)
const meterValue = payload.meterValue
if (meterValue == null) {
assert.fail('Expected meterValue with TxUpdatedMeasurands to be defined')
meterValue[0].sampledValue[0].context,
OCPP20ReadingContextEnumType.TRIGGER
)
- // F06.FR.07: building the emitted params yields transactionInfo.chargingState.
- const built = buildTransactionEvent(mockStation, payload)
- assert.strictEqual(built.transactionInfo.chargingState, OCPP20ChargingStateEnumType.Charging)
+ assert.strictEqual(
+ payload.transactionInfo.chargingState,
+ OCPP20ChargingStateEnumType.Charging
+ )
+ })
+
+ await it('should emit TransactionEvent only for the exact requested connector', async () => {
+ seedActiveTransaction(1, 'txn-evse-1-connector-1')
+ const connectorStatus = addTriggerMessageConnector(mockStation, 1, 2, 222)
+ connectorStatus.idTagAuthorized = true
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 222
+ connectorStatus.transactionId = 'txn-evse-1-connector-2'
+ connectorStatus.transactionIdTag = 'TAG-txn-evse-1-connector-2'
+ connectorStatus.transactionStart = new Date()
+ connectorStatus.transactionStarted = true
+
+ const payloads = await emitTransactionEventTrigger({ connectorId: 2, id: 1 })
+
+ assert.strictEqual(payloads.length, 1)
+ assert.strictEqual(payloads[0].transactionInfo.transactionId, 'txn-evse-1-connector-2')
+ assert.deepStrictEqual(payloads[0].evse, { connectorId: 2, id: 1 })
+ })
+
+ await it('should use SampledDataCtrlr signing for triggered TransactionEvent', async () => {
+ configureSigningPolicies(mockStation, false, true)
+ seedActiveTransaction(1, 'txn-trigger-sampled-signing')
+
+ const [payload] = await emitTransactionEventTrigger({ id: 1 })
+ const samples = payload.meterValue?.flatMap(meterValue => meterValue.sampledValue) ?? []
+ assert.ok(samples.length > 0)
+ assert.ok(samples.every(sample => sample.signedMeterValue != null))
+ })
+
+ await it('should preserve EVSE identity when connector ids are local to each EVSE', async () => {
+ const evseStatus = mockStation.getEvseStatus(2)
+ const connectorStatus = evseStatus?.connectors.get(2)
+ assert.ok(evseStatus != null && connectorStatus != null)
+ evseStatus.connectors.delete(2)
+ evseStatus.connectors.set(1, connectorStatus)
+ evseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as EvseStatus['MeterValues']
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 'txn-evse-2-local-connector'
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ addConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ undefined,
+ { save: false }
+ )
+
+ const payloads = await emitTransactionEventTrigger({ id: 2 })
+
+ assert.strictEqual(payloads.length, 1)
+ assert.strictEqual(payloads[0].transactionInfo.transactionId, 'txn-evse-2-local-connector')
+ assert.deepStrictEqual(payloads[0].evse, { connectorId: 1, id: 2 })
})
- await it('should still emit a TransactionEvent carrying chargingState when no TxUpdated sample is produced (F06.FR.10)', () => {
+ await it('should still emit a TransactionEvent carrying chargingState when no TxUpdated sample is produced (F06.FR.10)', async () => {
// Active transaction but no TxUpdatedMeasurands configured: buildMeterValue
// yields no sampledValue, yet an Accepted trigger MUST still send the event
// with the mandatory chargingState (F06.FR.07/FR.10), meterValue omitted.
energyImport: 1234,
transactionId: 'txn-evse-1',
})
+ const connectorStatus = mockStation.getEvseStatus(1)?.connectors.get(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = []
- const payloads = emitTransactionEventTrigger({ id: 1 })
+ const payloads = await emitTransactionEventTrigger({ id: 1 })
assert.strictEqual(payloads.length, 1)
const payload = payloads[0]
assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
assert.strictEqual(payload.meterValue, undefined)
- const built = buildTransactionEvent(mockStation, payload)
- assert.strictEqual(built.transactionInfo.chargingState, OCPP20ChargingStateEnumType.Charging)
+ assert.strictEqual(
+ payload.transactionInfo.chargingState,
+ OCPP20ChargingStateEnumType.Charging
+ )
})
- await it('should still emit a TransactionEvent when the meterValue build throws (F06.FR.10)', () => {
+ await it('should still emit a TransactionEvent when the meterValue build throws (F06.FR.10)', async () => {
seedActiveTransaction(1, 'txn-evse-1')
- // Force buildMeterValue to throw: it resolves the connector via
- // getConnectorIdByTransactionId (which sendTransactionEvent does not use),
- // so the build fails while the event send stays reachable.
- mock.method(mockStation, 'getConnectorIdByTransactionId', () => {
+ // Force buildMeterValue to throw before sendTransactionEvent builds the event.
+ mock.method(mockStation, 'getCoherentSession', () => {
throw new Error('meterValue build failure')
})
- const payloads = emitTransactionEventTrigger({ id: 1 })
+ const payloads = await emitTransactionEventTrigger({ id: 1 })
assert.strictEqual(payloads.length, 1)
const payload = payloads[0]
assert.strictEqual(payload.meterValue, undefined)
})
- await it('should emit a TransactionEvent for every EVSE with an active transaction when EVSE is omitted (F06.FR.11)', () => {
+ await it('should emit a TransactionEvent for every EVSE with an active transaction when EVSE is omitted (F06.FR.11)', async () => {
seedActiveTransaction(1, 'txn-evse-1')
seedActiveTransaction(3, 'txn-evse-3')
- const payloads = emitTransactionEventTrigger()
+ const payloads = await emitTransactionEventTrigger()
assert.strictEqual(payloads.length, 2)
- const observedTransactions = new Set(payloads.map(payload => payload.transactionId))
+ const observedTransactions = new Set(
+ payloads.map(payload => payload.transactionInfo.transactionId)
+ )
assert.deepStrictEqual([...observedTransactions].sort(), ['txn-evse-1', 'txn-evse-3'])
for (const payload of payloads) {
assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
}
})
- await it('should emit no TransactionEvent when no transaction is active', () => {
- const payloads = emitTransactionEventTrigger()
+ await it('should emit no TransactionEvent when no transaction is active', async () => {
+ const payloads = await emitTransactionEventTrigger()
assert.strictEqual(payloads.length, 0)
})
transactionId: 'txn-evse-1',
})
+ const request: OCPP20TriggerMessageRequest = {
+ evse: { id: 1 },
+ requestedMessage: MessageTriggerEnumType.TransactionEvent,
+ }
+ const response = testableService.handleRequestTriggerMessage(rejectStation, request)
+ assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
listenerService.emit(
OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
rejectStation,
- { evse: { id: 1 }, requestedMessage: MessageTriggerEnumType.TransactionEvent },
- { status: TriggerMessageStatusEnumType.Accepted }
+ request,
+ response
)
await flushMicrotasks()
assert.strictEqual(sentPayload.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
})
+ await it('should resolve an omitted connector within the supplied EVSE', async () => {
+ await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
+ eventType: OCPP20TransactionEventEnumType.Started,
+ evse: { id: 2 },
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+
+ assert.strictEqual(sendMessageMock.mock.calls.length, 1)
+ const sentPayload = sendMessageMock.mock.calls[0]
+ .arguments[2] as OCPP20TransactionEventRequest
+ assert.deepStrictEqual(sentPayload.evse, { id: 2 })
+ assert.strictEqual(station.getConnectorStatus(1, 1)?.transactionSeqNo, undefined)
+ assert.strictEqual(station.getConnectorStatus(2, 2)?.transactionSeqNo, 0)
+ })
+
await it('should resolve connectorId from evse when passed in OCPP wire format', async () => {
await service.requestHandler(station, OCPP20RequestCommand.TRANSACTION_EVENT, {
eventType: OCPP20TransactionEventEnumType.Started,
- evse: { connectorId: 2, id: 1 },
+ evse: { connectorId: 2, id: 2 },
triggerReason: OCPP20TriggerReasonEnumType.Authorized,
})
/**
- * @file Tests for OCPP20RequestService Heartbeat
- * @description Unit tests for OCPP 2.0 Heartbeat request building (G02)
+ * @file Tests for OCPP20RequestService Heartbeat and outgoing CALL serialization
+ * @description Unit tests for OCPP 2.0 Heartbeat request building (G02) and the
+ * station-wide outgoing CALL serialization gate (FIFO ordering, waiter bounds,
+ * buffered replay, and cancellation on WebSocket close).
*/
import assert from 'node:assert/strict'
-import { afterEach, beforeEach, describe, it } from 'node:test'
-
-import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+import { BroadcastChannel } from 'node:worker_threads'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
+import { addConfigurationKey } from '../../../../src/charging-station/index.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPPConstants } from '../../../../src/charging-station/ocpp/OCPPConstants.js'
+import { OCPPError } from '../../../../src/exception/index.js'
import {
+ type EmptyObject,
+ ErrorType,
+ GenericStatus,
+ OCPP20ComponentName,
type OCPP20HeartbeatRequest,
+ OCPP20IncomingRequestCommand,
OCPP20RequestCommand,
+ OCPP20RequiredVariableName,
+ OCPP20TransactionEventEnumType,
+ type OCPP20TransactionEventRequest,
+ OCPP20TriggerReasonEnumType,
OCPPVersion,
+ RegistrationStatusEnumType,
+ type RequestParams,
+ WebSocketCloseEventStatusCode,
} from '../../../../src/types/index.js'
-import { has } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { Constants, has } from '../../../../src/utils/index.js'
+import {
+ flushMicrotasks,
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
import {
TEST_CHARGE_POINT_MODEL,
TEST_CHARGE_POINT_SERIAL_NUMBER,
type TestableOCPP20RequestService,
} from './OCPP20TestUtils.js'
-await describe('G02 - Heartbeat', async () => {
+await describe('G02 - Heartbeat and outgoing CALL serialization', async () => {
let testableRequestService: TestableOCPP20RequestService
let station: ChargingStation
// Ensure it's a plain object and not an instance of another type
assert.strictEqual(Object.getPrototypeOf(payload), Object.prototype)
})
+
+ await it('should force persisted TransactionEvent schema validation when strict mode is disabled', () => {
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.ocppStrictCompliance = false
+ const invalidRequest = {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 0,
+ timestamp: new Date(),
+ transactionInfo: { transactionId: 'transaction-id' },
+ triggerReason: 'InvalidTriggerReason',
+ } as unknown as OCPP20TransactionEventRequest
+
+ assert.strictEqual(
+ station.ocppRequestService.validateRequestPayload(
+ station,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ invalidRequest
+ ),
+ true
+ )
+ assert.throws(
+ () =>
+ station.ocppRequestService.validateRequestPayload(
+ station,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ invalidRequest,
+ { forceValidation: true }
+ ),
+ (error: unknown) => error instanceof OCPPError && error.code === ErrorType.FORMAT_VIOLATION
+ )
+ })
+
+ await it('should serialize concurrent outgoing CALLs and releases before response handlers', async t => {
+ const workerChannelSubscriber = new BroadcastChannel('worker')
+ workerChannelSubscriber.onmessage = () => undefined
+ t.after(() => {
+ workerChannelSubscriber.close()
+ })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const responseHandlerGate = Promise.withResolvers<undefined>()
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => responseHandlerGate.promise)
+
+ const first = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const second = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const [firstMessageType, firstMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ assert.strictEqual(firstMessageType, 2)
+ context.station.requests.get(firstMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+
+ const [, secondMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(secondMessageId)?.[0]({ currentTime: new Date() }, {})
+ responseHandlerGate.resolve(undefined)
+ await Promise.all([first, second])
+ })
+
+ await it('should reject a CALL whose station state becomes pending while it waits for the gate', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.bootNotificationResponse = {
+ currentTime: new Date(),
+ interval: 60,
+ status: RegistrationStatusEnumType.ACCEPTED,
+ }
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const pendingResponse = {
+ currentTime: new Date(),
+ interval: 60,
+ status: RegistrationStatusEnumType.PENDING,
+ }
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => {
+ context.station.bootNotificationResponse = pendingResponse
+ return Promise.resolve(undefined)
+ })
+
+ const bootNotification = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.BOOT_NOTIFICATION,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const heartbeat = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const heartbeatRejected = assert.rejects(heartbeat, error => {
+ assert.ok(error instanceof OCPPError)
+ assert.strictEqual(error.code, ErrorType.SECURITY_ERROR)
+ assert.match(error.message, /Pending state/)
+ return true
+ })
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+ const [, bootMessageId, bootCommand] = JSON.parse(wireMessages[0]) as [number, string, string]
+ assert.strictEqual(bootCommand, OCPP20RequestCommand.BOOT_NOTIFICATION)
+
+ context.station.requests.get(bootMessageId)?.[0](pendingResponse, {})
+ await Promise.all([bootNotification, heartbeatRejected])
+
+ assert.strictEqual(wireMessages.length, 1)
+ assert.strictEqual(context.station.requests.size, 0)
+
+ const nextBootNotification = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.BOOT_NOTIFICATION,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, nextBootMessageId, nextBootCommand] = JSON.parse(wireMessages[1]) as [
+ number,
+ string,
+ string
+ ]
+ assert.strictEqual(nextBootCommand, OCPP20RequestCommand.BOOT_NOTIFICATION)
+ context.station.requests.get(nextBootMessageId)?.[0](pendingResponse, {})
+ await nextBootNotification
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should cancel an immediately acquired CALL before its request is cached', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const sendSpy = mock.method(wsConnection, 'send')
+ const transportAmbiguities: boolean[] = []
+ const directTransport = context.requestService as unknown as {
+ sendMessage: (
+ chargingStation: ChargingStation,
+ messageId: string,
+ messagePayload: EmptyObject,
+ commandName: OCPP20RequestCommand,
+ params: RequestParams
+ ) => Promise<unknown>
+ }
+
+ const pendingRequest = directTransport.sendMessage(
+ context.station,
+ 'cancelled-before-cache',
+ {},
+ OCPP20RequestCommand.HEARTBEAT,
+ {
+ onTransportError: (_error, deliveryAmbiguous) => {
+ transportAmbiguities.push(deliveryAmbiguous)
+ },
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(
+ pendingRequest,
+ /Charging station stopped while awaiting an OCPP response/
+ )
+ context.requestService.cancelPendingRequests(context.station)
+
+ await rejectedRequest
+ assert.strictEqual(sendSpy.mock.callCount(), 0)
+ assert.deepStrictEqual(transportAmbiguities, [false])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should not overwrite an immediate destructive cancellation with a later retained one', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const sendSpy = mock.method(wsConnection, 'send')
+ const directTransport = context.requestService as unknown as {
+ sendMessage: (
+ chargingStation: ChargingStation,
+ messageId: string,
+ messagePayload: EmptyObject,
+ commandName: OCPP20RequestCommand,
+ params: RequestParams
+ ) => Promise<unknown>
+ }
+
+ const pendingRequest = directTransport.sendMessage(
+ context.station,
+ 'destructive-cancellation-before-cache',
+ {},
+ OCPP20RequestCommand.HEARTBEAT,
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /Station permanently deleted/)
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Station permanently deleted',
+ true
+ )
+ context.requestService.cancelPendingRequests(context.station, 'WebSocket closed', false)
+
+ await rejectedRequest
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(sendSpy.mock.callCount(), 0)
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ })
+
+ await it('should reject a CALL created by a callback while permanent cancellation completes', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const sendSpy = mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ }
+ )
+ let reentrantRequest: Promise<unknown> | undefined
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onError: () => {
+ reentrantRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.BOOT_NOTIFICATION,
+ {},
+ { throwError: true }
+ )
+ },
+ responseTimeoutMs: 3_600_000,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ const rejectedPending = assert.rejects(pendingRequest, /Station permanently deleted/)
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Station permanently deleted',
+ true
+ )
+ await rejectedPending
+ assert.ok(reentrantRequest != null)
+ await assert.rejects(reentrantRequest, /Station permanently deleted/)
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(sendSpy.mock.callCount(), 1)
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ })
+
+ await it('should let an incoming CALL response bypass a blocked outgoing CALL', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const outgoing = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ await context.requestService.sendResponse(
+ context.station,
+ 'incoming-call',
+ { status: GenericStatus.Accepted },
+ OCPP20IncomingRequestCommand.RESET
+ )
+
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number])[0]),
+ [2, 3]
+ )
+ const [, outgoingMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(outgoingMessageId)?.[0]({ currentTime: new Date() }, {})
+ await outgoing
+ })
+
+ await it('should reject a CALL suspended in its pre-request hook after station stop cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const preRequestHookEntered = Promise.withResolvers<undefined>()
+ const resumePreRequestHook = Promise.withResolvers<undefined>()
+ let preRequestHookCalls = 0
+ const requestServiceInternals = context.requestService as unknown as {
+ preRequestHook: () => Promise<void>
+ }
+ requestServiceInternals.preRequestHook = async (): Promise<void> => {
+ preRequestHookCalls++
+ if (preRequestHookCalls === 1) {
+ preRequestHookEntered.resolve(undefined)
+ await resumePreRequestHook.promise
+ }
+ }
+ let stopping = false
+ context.station.isStopping = () => stopping
+
+ const staleRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await preRequestHookEntered.promise
+ const cancellationMessage = 'Station stopped during pre-request hook'
+ const staleRequestRejected = assert.rejects(
+ staleRequest,
+ (error: unknown) => error instanceof OCPPError && error.message === cancellationMessage
+ )
+ stopping = true
+ context.requestService.cancelPendingRequests(context.station, cancellationMessage)
+ resumePreRequestHook.resolve(undefined)
+ await staleRequestRejected
+ assert.deepStrictEqual(wireMessages, [])
+ assert.strictEqual(context.station.requests.size, 0)
+
+ const shutdownRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+ const [, shutdownMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(shutdownMessageId)?.[0]({ currentTime: new Date() }, {})
+ await shutdownRequest
+ })
+
+ await it('should bound stalled outgoing CALL waiters and clears them on cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ const sendSpy = mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ }
+ )
+ const activeMessageId = 'bounded-active-call'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 3_600_000)
+ const waiters = Array.from({ length: Constants.MAX_OUTGOING_CALL_WAITERS }, (_, index) =>
+ context.requestService.acquireOutgoingCall(
+ context.station,
+ `bounded-waiter-${index.toString()}`,
+ 3_600_000
+ )
+ )
+ const settledWaiters = Promise.allSettled(waiters)
+ const outgoingCallGates = (
+ context.requestService as unknown as {
+ outgoingCallGates: WeakMap<ChargingStation, { waiters: unknown[] }>
+ }
+ ).outgoingCallGates
+ const gate = outgoingCallGates.get(context.station)
+ assert.ok(gate != null)
+ assert.strictEqual(gate.waiters.length, Constants.MAX_OUTGOING_CALL_WAITERS)
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ ),
+ (error: unknown) =>
+ error instanceof OCPPError &&
+ error.message.includes(
+ `waiter limit of ${Constants.MAX_OUTGOING_CALL_WAITERS.toString()} reached`
+ )
+ )
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(gate.waiters.length, Constants.MAX_OUTGOING_CALL_WAITERS)
+ assert.strictEqual(sendSpy.mock.callCount(), 0)
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+
+ const cancellationMessage = 'Station stopped with a saturated CALL gate'
+ context.requestService.cancelPendingRequests(context.station, cancellationMessage)
+ const outcomes = await settledWaiters
+ assert.strictEqual(gate.waiters.length, 0)
+ assert.ok(
+ outcomes.every(
+ outcome =>
+ outcome.status === 'rejected' &&
+ outcome.reason instanceof OCPPError &&
+ outcome.reason.message === cancellationMessage
+ )
+ )
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+ })
+
+ await it('should preserve a retainable active CALL during its acquire-to-send handoff', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const directTransport = context.requestService as unknown as {
+ sendMessage: (
+ chargingStation: ChargingStation,
+ messageId: string,
+ messagePayload: EmptyObject,
+ commandName: OCPP20RequestCommand,
+ params: RequestParams
+ ) => Promise<unknown>
+ }
+
+ const retained = directTransport.sendMessage(
+ context.station,
+ 'retainable-active',
+ {},
+ OCPP20RequestCommand.HEARTBEAT,
+ {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ responseTimeoutMs: 3_600_000,
+ throwError: true,
+ }
+ )
+ context.requestService.cancelPendingRequests(context.station)
+ await flushMicrotasks()
+
+ assert.strictEqual(wireMessages.length, 1)
+ const [, messageId] = JSON.parse(wireMessages[0]) as [number, string]
+ assert.strictEqual(messageId, 'retainable-active')
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ context.station.requests.get(messageId)?.[0]({ currentTime: new Date() }, {})
+ await retained
+ })
+
+ await it('should materialize active and queued shutdown CALLs with their exact payloads once', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const directTransport = context.requestService as unknown as {
+ sendMessage: (
+ chargingStation: ChargingStation,
+ messageId: string,
+ messagePayload: EmptyObject,
+ commandName: OCPP20RequestCommand,
+ params: RequestParams
+ ) => Promise<unknown>
+ }
+ const callbackCounts = new Map<string, { buffered: number; sent: number; transport: number }>()
+ const sendTerminalCall = (messageId: string): Promise<unknown> => {
+ const counts = { buffered: 0, sent: 0, transport: 0 }
+ callbackCounts.set(messageId, counts)
+ return directTransport.sendMessage(
+ context.station,
+ messageId,
+ {},
+ OCPP20RequestCommand.HEARTBEAT,
+ {
+ bufferOnErrorDuringStationStop: true,
+ materializeOnCancellationBeforeSend: true,
+ onMessageSent: () => {
+ counts.sent++
+ },
+ onRequestBuffered: () => {
+ counts.buffered++
+ },
+ onTransportError: () => {
+ counts.transport++
+ },
+ responseTimeoutMs: 3_600_000,
+ throwError: true,
+ }
+ )
+ }
+
+ const active = sendTerminalCall('terminal-active')
+ const queued = sendTerminalCall('terminal-queued')
+ const rejected = Promise.all([
+ assert.rejects(active, /shutdown materialization/),
+ assert.rejects(queued, /shutdown materialization/),
+ ])
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'shutdown materialization',
+ false,
+ { handleTransportStartedSends: true, preserveRetainableWaiters: false }
+ )
+ await rejected
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(
+ bufferedStation.messageQueue
+ .map(message => JSON.parse(message) as [number, string])
+ .map(([, id]) => id),
+ ['terminal-active', 'terminal-queued']
+ )
+ assert.deepStrictEqual(
+ callbackCounts,
+ new Map([
+ ['terminal-active', { buffered: 1, sent: 1, transport: 0 }],
+ ['terminal-queued', { buffered: 1, sent: 0, transport: 1 }],
+ ])
+ )
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'repeated shutdown materialization',
+ false,
+ { handleTransportStartedSends: true, preserveRetainableWaiters: false }
+ )
+ assert.strictEqual(bufferedStation.messageQueue.length, 2)
+ assert.deepStrictEqual(
+ callbackCounts,
+ new Map([
+ ['terminal-active', { buffered: 1, sent: 1, transport: 0 }],
+ ['terminal-queued', { buffered: 1, sent: 0, transport: 1 }],
+ ])
+ )
+ })
+
+ await it('should cancel FIFO waiters without leaving the station CALL gate locked', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const first = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const second = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const firstRejected = assert.rejects(first, /station stopped/)
+ const secondRejected = assert.rejects(second, /station stopped/)
+ await flushMicrotasks()
+ context.requestService.cancelPendingRequests(context.station)
+ await Promise.all([firstRejected, secondRejected])
+
+ const third = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, thirdMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(thirdMessageId)?.[0]({ currentTime: new Date() }, {})
+ await third
+ })
+
+ await it('should expire a FIFO waiter without disturbing the active CALL or the next caller', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const activeMessageId = 'active'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 100)
+ const expiredWaiter = context.requestService.acquireOutgoingCall(
+ context.station,
+ 'expired-waiter',
+ 100
+ )
+ const waiterRejected = assert.rejects(
+ expiredWaiter,
+ /waiting to acquire the outgoing CALL gate/
+ )
+
+ t.mock.timers.tick(200)
+ await waiterRejected
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+
+ await context.requestService.acquireOutgoingCall(context.station, 'next', 100)
+ context.requestService.releaseOutgoingCall(context.station, 'next')
+ })
+
+ await it('should use the active CALL deadline when a short-budget waiter joins the gate', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const active = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ let waiterSettled = false
+ const waiter = context.requestService
+ .requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 1_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ .finally(() => {
+ waiterSettled = true
+ })
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(61_000)
+ await flushMicrotasks()
+ assert.strictEqual(waiterSettled, false)
+ assert.strictEqual(wireMessages.length, 1)
+
+ const [, activeMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(activeMessageId)?.[0]({ currentTime: new Date() }, {})
+ await active
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+
+ const [, waiterMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(waiterMessageId)?.[0]({ currentTime: new Date() }, {})
+ await waiter
+ })
+
+ await it('should budget both WebSocket send and response phases for an active CALL slot', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ setTimeout(() => callback?.(), 59_000)
+ } else {
+ callback?.()
+ }
+ })
+
+ const active = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 30_001, skipBufferingOnError: true, throwError: true }
+ )
+ let waiterSettled = false
+ const waiter = context.requestService
+ .requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 30_001,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ .finally(() => {
+ waiterSettled = true
+ })
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(59_000)
+ await flushMicrotasks()
+ t.mock.timers.tick(1_000)
+ await flushMicrotasks()
+ assert.strictEqual(waiterSettled, false)
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(29_000)
+ const [, activeMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(activeMessageId)?.[0]({ currentTime: new Date() }, {})
+ await active
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+
+ const [, waiterMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(waiterMessageId)?.[0]({ currentTime: new Date() }, {})
+ await waiter
+ assert.strictEqual(waiterSettled, true)
+ })
+
+ await it('should budget one timeout window for every CALL ahead in the FIFO', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const activeMessageId = 'fifo-active'
+ const firstWaiterId = 'fifo-first'
+ const secondWaiterId = 'fifo-second'
+ const thirdWaiterId = 'fifo-third'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 100)
+ const firstWaiter = context.requestService.acquireOutgoingCall(
+ context.station,
+ firstWaiterId,
+ 100
+ )
+ const firstWaiterRejected = assert.rejects(
+ firstWaiter,
+ /waiting to acquire the outgoing CALL gate/
+ )
+ const secondWaiter = context.requestService.acquireOutgoingCall(
+ context.station,
+ secondWaiterId,
+ 100
+ )
+ const thirdWaiter = context.requestService.acquireOutgoingCall(
+ context.station,
+ thirdWaiterId,
+ 100
+ )
+
+ t.mock.timers.tick(200)
+ await firstWaiterRejected
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+ await secondWaiter
+ context.requestService.releaseOutgoingCall(context.station, secondWaiterId)
+ await thirdWaiter
+ context.requestService.releaseOutgoingCall(context.station, thirdWaiterId)
+ })
+
+ await it('should buffer a gate waiter retained by ordinary pending-request cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let online = true
+ context.station.isWebSocketConnectionOpened = () => online
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const activeCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const retainedWaiter = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const activeRejected = assert.rejects(activeCall, /station stopped/)
+ const waiterRejected = assert.rejects(retainedWaiter, /WebSocket closed/)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+ online = false
+
+ context.requestService.cancelPendingRequests(context.station)
+ await Promise.all([activeRejected, waiterRejected])
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(bufferedStation.messageQueue.length, 1)
+ assert.strictEqual((JSON.parse(bufferedStation.messageQueue[0]) as [number])[0], 2)
+ assert.strictEqual(context.station.requests.size, 1)
+
+ context.requestService.cancelPendingRequests(context.station, undefined, true)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should not retain an ordinary buffered waiter during station stop', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let stopping = false
+ context.station.isStopping = () => stopping
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const activeCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const ordinaryWaiter = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const activeRejected = assert.rejects(activeCall, /station stopped/)
+ const waiterRejected = assert.rejects(ordinaryWaiter, /station stopped/)
+ await flushMicrotasks()
+ stopping = true
+
+ context.requestService.cancelPendingRequests(context.station)
+ await Promise.all([activeRejected, waiterRejected])
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(wireMessages.length, 1)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should keep a graceful-stop CALL active until its response timeout', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let stopping = false
+ context.station.isStopping = () => stopping
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ })
+
+ const pendingCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 1_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ waitForResponseOnStationStop: true,
+ }
+ )
+ const timedOut = assert.rejects(pendingCall, /Timeout/)
+ await flushMicrotasks()
+ stopping = true
+ context.requestService.cancelPendingRequests(context.station)
+
+ assert.strictEqual(context.station.requests.size, 1)
+ t.mock.timers.tick(1_000)
+ await timedOut
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should settle a transport-started graceful-stop CALL during final cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ })
+
+ const pendingCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ waitForResponseOnStationStop: true,
+ }
+ )
+ const rejected = assert.rejects(pendingCall, /final shutdown/)
+ await flushMicrotasks()
+
+ context.requestService.cancelPendingRequests(context.station, 'final shutdown', false, {
+ handleTransportStartedSends: true,
+ preserveRetainableWaiters: false,
+ })
+
+ await rejected
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should evaluate stop-only waiter retention when cancellation begins', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let online = true
+ let stopping = false
+ context.station.isWebSocketConnectionOpened = () => online
+ context.station.isStopping = () => stopping
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ })
+
+ const activeCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const retainedWaiter = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const activeRejected = assert.rejects(activeCall, /station stopped/)
+ const waiterRejected = assert.rejects(retainedWaiter, /WebSocket closed/)
+ await flushMicrotasks()
+ stopping = true
+ online = false
+
+ context.requestService.cancelPendingRequests(context.station)
+ await Promise.all([activeRejected, waiterRejected])
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(bufferedStation.messageQueue.length, 1)
+ assert.strictEqual(context.station.requests.size, 1)
+
+ context.requestService.cancelPendingRequests(context.station, undefined, true)
+ })
+
+ await it('should discard a buffered gate waiter during permanent deletion cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const activeCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const discardedWaiter = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const activeRejected = assert.rejects(activeCall, /station deleted/)
+ const waiterRejected = assert.rejects(discardedWaiter, /station deleted/)
+ await flushMicrotasks()
+
+ context.requestService.cancelPendingRequests(context.station, 'Charging station deleted', true)
+ await Promise.all([activeRejected, waiterRejected])
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(wireMessages.length, 1)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should defer saturated buffered replay until a later interval without a busy loop', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ const realSetInterval = globalThis.setInterval
+ let flushIntervalCallback: (() => void) | undefined
+ mock.method(globalThis, 'setInterval', ((callback: () => void, delay?: number) => {
+ if (delay === Constants.DEFAULT_MESSAGE_BUFFER_FLUSH_INTERVAL_MS) {
+ flushIntervalCallback = callback
+ }
+ return realSetInterval(() => undefined, 3_600_000)
+ }) as typeof setInterval)
+ const tickFlushInterval = (): void => {
+ assert.ok(flushIntervalCallback != null)
+ flushIntervalCallback()
+ }
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 3_600_000, throwError: true }
+ ),
+ /Buffered message id/
+ )
+ const bufferedStation = context.station as unknown as {
+ clearIntervalFlushMessageBuffer: () => void
+ flushMessageBuffer: () => void
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ setIntervalFlushMessageBuffer: () => void
+ }
+ const bufferMethods = ChargingStation.prototype as unknown as {
+ clearIntervalFlushMessageBuffer: (this: ChargingStation) => void
+ flushMessageBuffer: (this: ChargingStation) => void
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ setIntervalFlushMessageBuffer: (this: ChargingStation) => void
+ }
+ bufferedStation.clearIntervalFlushMessageBuffer = bufferMethods.clearIntervalFlushMessageBuffer
+ bufferedStation.flushMessageBuffer = bufferMethods.flushMessageBuffer
+ bufferedStation.sendMessageBuffer = bufferMethods.sendMessageBuffer
+ bufferedStation.setIntervalFlushMessageBuffer = bufferMethods.setIntervalFlushMessageBuffer
+ const bufferedCall = bufferedStation.messageQueue[0]
+
+ const activeCall = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+ const waitingIds = Array.from(
+ { length: Constants.MAX_OUTGOING_CALL_WAITERS },
+ (_, index) => `saturated-replay-waiter-${index.toString()}`
+ )
+ const waiters = waitingIds.map(messageId =>
+ context.requestService.acquireOutgoingCall(context.station, messageId, 3_600_000)
+ )
+ const settledWaiters = Promise.allSettled(waiters)
+ const acquireSpy = mock.method(context.requestService, 'acquireOutgoingCall')
+ let completedDrains = 0
+
+ bufferedStation.sendMessageBuffer(() => {
+ completedDrains++
+ })
+ await flushMicrotasks()
+ assert.strictEqual(acquireSpy.mock.callCount(), 1)
+ assert.strictEqual(completedDrains, 1)
+ assert.strictEqual(wireMessages.length, 1)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [bufferedCall])
+ tickFlushInterval()
+ await flushMicrotasks()
+ assert.strictEqual(acquireSpy.mock.callCount(), 2)
+ assert.strictEqual(wireMessages.length, 1)
+
+ const cancellationError = new OCPPError(ErrorType.GENERIC_ERROR, 'clear saturated waiters')
+ for (const messageId of waitingIds) {
+ assert.strictEqual(
+ context.requestService.cancelOutgoingCallWaiter(
+ context.station,
+ messageId,
+ cancellationError
+ ),
+ true
+ )
+ }
+ const waiterOutcomes = await settledWaiters
+ assert.ok(waiterOutcomes.every(outcome => outcome.status === 'rejected'))
+ const [, activeMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(activeMessageId)?.[0]({ currentTime: new Date() }, {})
+ await activeCall
+
+ tickFlushInterval()
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [wireMessages[0], bufferedCall])
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ const [, replayedMessageId] = JSON.parse(bufferedCall) as [number, string]
+ context.station.requests.get(replayedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ tickFlushInterval()
+ assert.strictEqual(wireMessages.length, 2)
+ bufferedStation.clearIntervalFlushMessageBuffer()
+ })
+
+ await it('should serialize buffered replay with live CALL traffic', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => Promise.resolve(undefined))
+
+ const buffered = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await assert.rejects(buffered, /Buffered message id/)
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ context.station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+ sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const live = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const [, replayedMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(replayedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, liveMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(liveMessageId)?.[0]({ currentTime: new Date() }, {})
+ await live
+ })
+
+ await it('should budget a buffered replay slot through its cached response timeout', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => Promise.resolve(undefined))
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 120_000, throwError: true }
+ ),
+ /Buffered message id/
+ )
+ const bufferedStation = context.station as unknown as {
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ bufferedStation.sendMessageBuffer = sendMessageBuffer
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ setTimeout(() => callback?.(), 59_000)
+ } else {
+ callback?.()
+ }
+ })
+
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ let waiterSettled = false
+ const waiter = context.requestService
+ .requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 1_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ .finally(() => {
+ waiterSettled = true
+ })
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(59_000)
+ await flushMicrotasks()
+ t.mock.timers.tick(62_000)
+ await flushMicrotasks()
+ assert.strictEqual(waiterSettled, false)
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(57_999)
+ const [, replayedMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(replayedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+
+ const [, waiterMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(waiterMessageId)?.[0]({ currentTime: new Date() }, {})
+ await waiter
+ })
+
+ await it('should retain a replayed CALL when registration becomes pending during gate handoff', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const acceptedBootNotificationResponse = context.station.bootNotificationResponse
+ assert.ok(acceptedBootNotificationResponse != null)
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 3_600_000, throwError: true }
+ ),
+ /Buffered message id/
+ )
+ const bufferedStation = context.station as unknown as {
+ clearIntervalFlushMessageBuffer: () => void
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ const bufferMethods = ChargingStation.prototype as unknown as {
+ clearIntervalFlushMessageBuffer: (this: ChargingStation) => void
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ bufferedStation.clearIntervalFlushMessageBuffer = bufferMethods.clearIntervalFlushMessageBuffer
+ bufferedStation.sendMessageBuffer = bufferMethods.sendMessageBuffer
+ bufferedStation.clearIntervalFlushMessageBuffer()
+ const bufferedCall = bufferedStation.messageQueue[0]
+ const [, bufferedMessageId] = JSON.parse(bufferedCall) as [number, string]
+ const activeMessageId = 'pending-handoff-active-call'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 3_600_000)
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [])
+
+ context.station.bootNotificationResponse = {
+ ...acceptedBootNotificationResponse,
+ status: RegistrationStatusEnumType.PENDING,
+ }
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(wireMessages, [])
+ assert.deepStrictEqual(bufferedStation.messageQueue, [bufferedCall])
+ assert.strictEqual(context.station.requests.has(bufferedMessageId), true)
+
+ context.station.bootNotificationResponse = acceptedBootNotificationResponse
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [bufferedCall])
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ context.station.requests.get(bufferedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should keep a TransactionEvent waiter alive for the active WebSocket send budget', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageTimeout}.Default`,
+ '30',
+ undefined,
+ { save: false }
+ )
+ const transactionId = 'transaction-event-gate-timeout'
+ setupConnectorWithTransaction(context.station, 1, { transactionId })
+ const wireMessages: string[] = []
+ let activeSendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ activeSendCallback = callback
+ } else {
+ callback?.()
+ }
+ })
+
+ const active = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const activeRejected = assert.rejects(active, /Timeout .* reached/)
+ await flushMicrotasks()
+ assert.ok(activeSendCallback != null)
+ const transactionEvent = OCPP20ServiceUtils.sendTransactionEvent(
+ context.station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ )
+ await flushMicrotasks()
+
+ t.mock.timers.tick(30_000)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(30_000)
+ await activeRejected
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, transactionEventMessageId, , transactionEventPayload] = JSON.parse(
+ wireMessages[1]
+ ) as [number, string, OCPP20RequestCommand, OCPP20TransactionEventRequest]
+ context.station.requests.get(transactionEventMessageId)?.[0]({}, transactionEventPayload)
+ await transactionEvent
+ })
+
+ await it('should persist an Ended event while the global CALL gate blocks its first send', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const transactionId = 'transaction-ended-global-gate'
+ setupConnectorWithTransaction(context.station, 1, { transactionId })
+ const connectorStatus = context.station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const durableSnapshots: OCPP20TransactionEventEnumType[][] = []
+ context.station.saveTransactionEventQueues = () => {
+ durableSnapshots.push(
+ (connectorStatus.transactionEventQueue ?? []).map(({ request }) => request.eventType)
+ )
+ }
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const heartbeat = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ await flushMicrotasks()
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ context.station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ 1,
+ transactionId,
+ {},
+ { throwError: true }
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(wireMessages.length, 1)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ assert.deepStrictEqual(durableSnapshots.at(-1), [OCPP20TransactionEventEnumType.Ended])
+
+ const [, heartbeatMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(heartbeatMessageId)?.[0]({ currentTime: new Date() }, {})
+ await heartbeat
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, endedMessageId, , endedPayload] = JSON.parse(wireMessages[1]) as [
+ number,
+ string,
+ OCPP20RequestCommand,
+ OCPP20TransactionEventRequest
+ ]
+ context.station.requests.get(endedMessageId)?.[0]({}, endedPayload)
+ await ended
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.deepStrictEqual(durableSnapshots.at(-1), [])
+ })
+
+ await it('should retry and retains a TransactionEvent that expires behind the CALL gate', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ const transactionId = 'transaction-event-expired-gate'
+ setupConnectorWithTransaction(context.station, 1, { transactionId })
+ const connectorStatus = context.station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ const activeMessageId = 'blocked-global-call'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 1)
+ const transportAmbiguities: boolean[] = []
+
+ const transactionEvent = OCPP20ServiceUtils.sendTransactionEvent(
+ context.station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId,
+ {},
+ {
+ onTransportError: (_error, deliveryAmbiguous) => {
+ transportAmbiguities.push(deliveryAmbiguous)
+ },
+ responseTimeoutMs: 1,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+
+ t.mock.timers.tick(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS + 2)
+ await flushMicrotasks()
+ t.mock.timers.tick(1_000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(transportAmbiguities, [false])
+
+ t.mock.timers.tick(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS + 1)
+ await transactionEvent
+ assert.deepStrictEqual(transportAmbiguities, [false, false])
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.at(0)?.request.transactionInfo.transactionId,
+ transactionId
+ )
+
+ context.station.isWebSocketConnectionOpened = () => false
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+ await flushMicrotasks()
+ })
+
+ await it('should use the configured OCPP 2.0 MessageTimeout to release the CALL gate', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageTimeout}.Default`,
+ '120',
+ undefined,
+ { save: false }
+ )
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const first = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ )
+ const firstRejected = assert.rejects(first, /Timeout .* waiting for response/)
+ const second = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(Constants.DEFAULT_MESSAGE_TIMEOUT_SECONDS * 1000)
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 1)
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(90_000)
+ await firstRejected
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+
+ const [, secondMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(secondMessageId)?.[0]({ currentTime: new Date() }, {})
+ await second
+ })
+
+ await it('should bound a persisted MessageTimeout to the canonical timer range', () => {
+ const context = createOCPP20RequestTestContext()
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageTimeout}.Default`,
+ Number.MAX_SAFE_INTEGER.toString(),
+ undefined,
+ { save: false }
+ )
+
+ const timeoutMs = OCPP20ServiceUtils.getMessageTimeout(context.station)
+
+ assert.strictEqual(timeoutMs, 3_600_000)
+ assert.strictEqual(Number.isFinite(timeoutMs), true)
+ })
+
+ await it('should cancel active and waiting CALLs when the WebSocket closes', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.started = false
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const first = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const second = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const firstRejected = assert.rejects(first, /WebSocket closed while awaiting/)
+ const secondRejected = assert.rejects(second, /WebSocket closed while awaiting/)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const stationInternals = context.station as unknown as {
+ restoreAcknowledgedBufferedMessages: () => void
+ wsConnection: null | typeof wsConnection
+ wsConnectionsClosedByRequest: WeakSet<object>
+ }
+ stationInternals.restoreAcknowledgedBufferedMessages = () => undefined
+ stationInternals.wsConnectionsClosedByRequest = new WeakSet()
+ ;(
+ ChargingStation.prototype as unknown as {
+ onClose: (
+ this: ChargingStation,
+ connection: typeof wsConnection,
+ code: WebSocketCloseEventStatusCode,
+ reason: Buffer
+ ) => void
+ }
+ ).onClose.call(
+ context.station,
+ wsConnection,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('')
+ )
+ await Promise.all([firstRejected, secondRejected])
+
+ stationInternals.wsConnection = wsConnection
+ const next = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ const [, nextMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(nextMessageId)?.[0]({ currentTime: new Date() }, {})
+ await next
+ })
+
+ await it('should buffer a retainable CALL waiting behind the active request when the connection closes', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.started = false
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ const active = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const waiting = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const activeRejected = assert.rejects(active, /WebSocket closed while awaiting/)
+ const waitingRejected = assert.rejects(waiting, /WebSocket closed/)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const stationInternals = context.station as unknown as {
+ messageQueue: string[]
+ restoreAcknowledgedBufferedMessages: () => void
+ wsConnection: null | typeof wsConnection
+ wsConnectionsClosedByRequest: WeakSet<object>
+ }
+ stationInternals.restoreAcknowledgedBufferedMessages = () => undefined
+ stationInternals.wsConnectionsClosedByRequest = new WeakSet()
+ ;(
+ ChargingStation.prototype as unknown as {
+ onClose: (
+ this: ChargingStation,
+ connection: typeof wsConnection,
+ code: WebSocketCloseEventStatusCode,
+ reason: Buffer
+ ) => void
+ }
+ ).onClose.call(
+ context.station,
+ wsConnection,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('')
+ )
+ await Promise.all([activeRejected, waitingRejected])
+
+ assert.strictEqual(wireMessages.length, 1)
+ assert.strictEqual(stationInternals.messageQueue.length, 1)
+ const [, , bufferedCommand] = JSON.parse(stationInternals.messageQueue[0]) as [
+ number,
+ string,
+ string
+ ]
+ assert.strictEqual(bufferedCommand, OCPP20RequestCommand.HEARTBEAT)
+ assert.strictEqual(context.station.requests.size, 1)
+
+ context.requestService.cancelPendingRequests(context.station, undefined, true)
+ })
+
+ await it('should buffer an in-flight WebSocket send exactly once when the connection closes', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.started = false
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ let sendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ sendCallback = callback
+ })
+
+ const request = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ const rejectedRequest = assert.rejects(request, /WebSocket closed while awaiting/)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+ assert.strictEqual(context.station.requests.size, 1)
+
+ const stationInternals = context.station as unknown as {
+ messageQueue: string[]
+ restoreAcknowledgedBufferedMessages: () => void
+ wsConnection: null | typeof wsConnection
+ wsConnectionsClosedByRequest: WeakSet<object>
+ }
+ stationInternals.restoreAcknowledgedBufferedMessages = () => undefined
+ stationInternals.wsConnectionsClosedByRequest = new WeakSet()
+ ;(
+ ChargingStation.prototype as unknown as {
+ onClose: (
+ this: ChargingStation,
+ connection: typeof wsConnection,
+ code: WebSocketCloseEventStatusCode,
+ reason: Buffer
+ ) => void
+ }
+ ).onClose.call(
+ context.station,
+ wsConnection,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('')
+ )
+ await rejectedRequest
+
+ assert.strictEqual(stationInternals.wsConnection, null)
+ assert.deepStrictEqual(stationInternals.messageQueue, [wireMessages[0]])
+ assert.strictEqual(context.station.requests.size, 1)
+
+ sendCallback?.()
+ sendCallback?.(new OCPPError(ErrorType.GENERIC_ERROR, 'late send failure'))
+ assert.deepStrictEqual(stationInternals.messageQueue, [wireMessages[0]])
+ assert.strictEqual(context.station.requests.size, 1)
+
+ context.requestService.cancelPendingRequests(context.station, undefined, true)
+ assert.deepStrictEqual(stationInternals.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should keep an interrupted TransactionEvent out of the raw message buffer', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.started = false
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const transactionId = 'transaction-event-in-flight-close'
+ setupConnectorWithTransaction(context.station, 1, { transactionId })
+ addConfigurationKey(
+ context.station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown) => {
+ wireMessages.push(String(data))
+ })
+
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ context.station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ const stationInternals = context.station as unknown as {
+ messageQueue: string[]
+ restoreAcknowledgedBufferedMessages: () => void
+ wsConnection: null | typeof wsConnection
+ wsConnectionsClosedByRequest: WeakSet<object>
+ }
+ stationInternals.restoreAcknowledgedBufferedMessages = () => undefined
+ stationInternals.wsConnectionsClosedByRequest = new WeakSet()
+ ;(
+ ChargingStation.prototype as unknown as {
+ onClose: (
+ this: ChargingStation,
+ connection: typeof wsConnection,
+ code: WebSocketCloseEventStatusCode,
+ reason: Buffer
+ ) => void
+ }
+ ).onClose.call(
+ context.station,
+ wsConnection,
+ WebSocketCloseEventStatusCode.CLOSE_NORMAL,
+ Buffer.from('')
+ )
+ await delivery
+
+ const connectorStatus = context.station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.deepStrictEqual(stationInternals.messageQueue, [])
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Updated]
+ )
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should retract a replay promoted during CALL gate handoff so a buffered CALLRESULT passes first', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 3_600_000, throwError: true }
+ ),
+ /Buffered message id/
+ )
+ const bufferedStation = context.station as unknown as {
+ bufferMessage: (message: string, prepend?: boolean) => void
+ flushMessageBuffer: () => void
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ setIntervalFlushMessageBuffer: () => void
+ }
+ const bufferMethods = ChargingStation.prototype as unknown as {
+ bufferMessage: (this: ChargingStation, message: string, prepend?: boolean) => void
+ clearIntervalFlushMessageBuffer: (this: ChargingStation) => void
+ flushMessageBuffer: (this: ChargingStation) => void
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ bufferMethods.clearIntervalFlushMessageBuffer.call(context.station)
+ bufferedStation.setIntervalFlushMessageBuffer = () => undefined
+ bufferedStation.bufferMessage = bufferMethods.bufferMessage
+ bufferedStation.flushMessageBuffer = bufferMethods.flushMessageBuffer
+ bufferedStation.sendMessageBuffer = bufferMethods.sendMessageBuffer
+ const bufferedCall = bufferedStation.messageQueue[0]
+ const [, bufferedMessageId] = JSON.parse(bufferedCall) as [number, string]
+ const activeMessageId = 'active-call'
+ await context.requestService.acquireOutgoingCall(context.station, activeMessageId, 3_600_000)
+ bufferMethods.sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [])
+
+ context.requestService.releaseOutgoingCall(context.station, activeMessageId)
+ const bufferedResponse = '[3,"incoming-call",{}]'
+ context.station.bufferMessage(bufferedResponse)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(wireMessages, [bufferedResponse])
+ assert.deepStrictEqual(bufferedStation.messageQueue, [bufferedCall])
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [bufferedResponse, bufferedCall])
+ assert.strictEqual(wireMessages.filter(message => message === bufferedCall).length, 1)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+
+ context.station.requests.get(bufferedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should prioritize a buffered CALLRESULT over a replay CALL waiting for the gate', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { bufferWithoutSending: true, responseTimeoutMs: 3_600_000, throwError: true }
+ ),
+ /Buffered message id/
+ )
+ const bufferedStation = context.station as unknown as {
+ bufferMessage: (message: string, prepend?: boolean) => void
+ clearIntervalFlushMessageBuffer: () => void
+ flushMessageBuffer: () => void
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ setIntervalFlushMessageBuffer: () => void
+ }
+ bufferedStation.clearIntervalFlushMessageBuffer = () => undefined
+ bufferedStation.setIntervalFlushMessageBuffer = () => undefined
+ const bufferMethods = ChargingStation.prototype as unknown as {
+ bufferMessage: (this: ChargingStation, message: string, prepend?: boolean) => void
+ flushMessageBuffer: (this: ChargingStation) => void
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ bufferedStation.bufferMessage = bufferMethods.bufferMessage
+ bufferedStation.flushMessageBuffer = bufferMethods.flushMessageBuffer
+ bufferedStation.sendMessageBuffer = bufferMethods.sendMessageBuffer
+ const bufferedCall = bufferedStation.messageQueue[0]
+
+ const live = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ bufferMethods.sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number])[0]),
+ [2]
+ )
+
+ const bufferedResponse = '[3,"incoming-call",{}]'
+ context.station.bufferMessage(bufferedResponse)
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number])[0]),
+ [2, 3]
+ )
+
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+ const [, liveMessageId] = JSON.parse(wireMessages[0]) as [number, string]
+ context.station.requests.get(liveMessageId)?.[0]({ currentTime: new Date() }, {})
+ await live
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ wireMessages.map(message => (JSON.parse(message) as [number])[0]),
+ [2, 3, 2]
+ )
+
+ const [, bufferedMessageId] = JSON.parse(bufferedCall) as [number, string]
+ context.station.requests.get(bufferedMessageId)?.[0]({ currentTime: new Date() }, {})
+ })
+
+ await it('should expire a sent request that receives no OCPP response', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let messageSentCount = 0
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ callback?.()
+ }
+ )
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onMessageSent: () => {
+ messageSentCount++
+ throw new Error('observer failure')
+ },
+ responseTimeoutMs: 5,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ ),
+ /Timeout .* waiting for response/
+ )
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.strictEqual(messageSentCount, 1)
+ })
+
+ await it('should report an asynchronous WebSocket send failure as delivery-ambiguous', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const observedTransportErrors: [OCPPError, boolean][] = []
+ const socketFailure = new OCPPError(ErrorType.GENERIC_ERROR, 'socket write failed')
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ callback?.(socketFailure)
+ }
+ )
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onTransportError: (error, deliveryAmbiguous) => {
+ observedTransportErrors.push([error, deliveryAmbiguous])
+ },
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ ),
+ /WebSocket errored/
+ )
+
+ assert.strictEqual(observedTransportErrors.length, 1)
+ assert.match(observedTransportErrors[0][0].message, /WebSocket errored/)
+ assert.strictEqual(observedTransportErrors[0][1], true)
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should not resurrect a request discarded by its transport-error hook', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let sendCallback: ((error?: Error) => void) | undefined
+ let sendCount = 0
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ sendCount++
+ sendCallback = callback
+ })
+
+ const request = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onTransportError: () => {
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Transport hook discarded request',
+ true
+ )
+ },
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ assert.ok(sendCallback != null)
+ const rejectedRequest = assert.rejects(request, /Transport hook discarded request/)
+
+ sendCallback(new OCPPError(ErrorType.GENERIC_ERROR, 'socket write failed'))
+ await rejectedRequest
+
+ const bufferedStation = context.station as unknown as {
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ bufferedStation.sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+
+ sendCallback()
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(sendCount, 1)
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ })
+
+ await it('should not duplicate a request retained by its transport-error hook', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let stopping = true
+ context.station.isStopping = () => stopping
+ const wireMessages: string[] = []
+ let firstSendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ firstSendCallback = callback
+ } else {
+ callback?.()
+ }
+ })
+
+ const request = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ onTransportError: () => {
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Transport hook retained request'
+ )
+ },
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ assert.ok(firstSendCallback != null)
+ const rejectedRequest = assert.rejects(request, /Transport hook retained request/)
+
+ firstSendCallback(new OCPPError(ErrorType.GENERIC_ERROR, 'socket write failed'))
+ await rejectedRequest
+
+ const bufferedStation = context.station as unknown as {
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ bufferedStation.sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ assert.deepStrictEqual(bufferedStation.messageQueue, [wireMessages[0]])
+ assert.strictEqual(context.station.requests.size, 1)
+
+ firstSendCallback()
+ stopping = false
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+
+ const [, messageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(messageId)?.[0]({ currentTime: new Date() }, {})
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 0)
+ bufferedStation.sendMessageBuffer(() => undefined)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ })
+
+ await it('should release the CALL gate before a transport-error hook starts a nested CALL', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const events: string[] = []
+ const wireMessages: string[] = []
+ const socketFailure = new OCPPError(ErrorType.GENERIC_ERROR, 'socket write failed')
+ const releaseOutgoingCall = context.requestService.releaseOutgoingCall.bind(
+ context.requestService
+ )
+ mock.method(
+ context.requestService,
+ 'releaseOutgoingCall',
+ (station: ChargingStation, messageId: string): void => {
+ events.push(`release:${messageId}`)
+ releaseOutgoingCall(station, messageId)
+ }
+ )
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.(wireMessages.length === 1 ? socketFailure : undefined)
+ })
+ let nestedRequest: Promise<unknown> | undefined
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onTransportError: () => {
+ events.push('transport-error-hook')
+ nestedRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ },
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ ),
+ /WebSocket errored/
+ )
+ await flushMicrotasks()
+
+ assert.match(events[0], /^release:/)
+ assert.strictEqual(events[1], 'transport-error-hook')
+ assert.strictEqual(wireMessages.length, 2)
+ assert.ok(nestedRequest != null)
+ const [, nestedMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(nestedMessageId)?.[0]({ currentTime: new Date() }, {})
+ await nestedRequest
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should mark a stalled WebSocket send callback as delivery-ambiguous', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const observedAmbiguity: boolean[] = []
+ mock.method(wsConnection, 'send', () => undefined)
+
+ const request = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onTransportError: (_error, deliveryAmbiguous) => {
+ observedAmbiguity.push(deliveryAmbiguous)
+ },
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(request, /Timeout .* reached for non buffered message/)
+ await flushMicrotasks()
+ t.mock.timers.tick(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)
+
+ await rejectedRequest
+ assert.deepStrictEqual(observedAmbiguity, [true])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should report a synchronous WebSocket send throw as definitely not sent', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const observedAmbiguity: boolean[] = []
+ const socketFailure = new OCPPError(ErrorType.GENERIC_ERROR, 'synchronous socket failure')
+ mock.method(wsConnection, 'send', () => {
+ throw socketFailure
+ })
+
+ await assert.rejects(
+ context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onTransportError: (_error, deliveryAmbiguous) => {
+ observedAmbiguity.push(deliveryAmbiguous)
+ },
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ ),
+ /WebSocket errored/
+ )
+
+ assert.deepStrictEqual(observedAmbiguity, [false])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should cancel pending response timers when deleting the station', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.started = false
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ callback?.()
+ }
+ )
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 1)
+ const rejectedRequest = assert.rejects(
+ pendingRequest,
+ /deleted while awaiting an OCPP response/
+ )
+ await context.station.delete(false)
+
+ await rejectedRequest
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should not cancel an answered request while its response handler is running', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ })
+ const responseHandlerGate = Promise.withResolvers<undefined>()
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => responseHandlerGate.promise)
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ const cachedRequest = [...context.station.requests.values()][0]
+
+ cachedRequest[0]({ currentTime: new Date().toISOString() }, {})
+ assert.strictEqual(context.station.requests.size, 0)
+ context.requestService.cancelPendingRequests(context.station)
+ responseHandlerGate.resolve(undefined)
+
+ await pendingRequest
+ })
+
+ await it('should cancel a request while WebSocket.send is still pending', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ let sendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ sendCallback = callback
+ })
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(context.station.requests.size, 1)
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during WebSocket send/)
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during WebSocket send'
+ )
+ sendCallback?.()
+
+ await rejectedRequest
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should buffer an unacknowledged station-stop request exactly once before cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ let sendCallback: ((error?: Error) => void) | undefined
+ let serializedMessage: string | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ serializedMessage = String(data)
+ sendCallback = callback
+ })
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during station stop/)
+ await flushMicrotasks()
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during station stop'
+ )
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(bufferedStation.messageQueue, [serializedMessage])
+ assert.strictEqual(context.station.requests.size, 1)
+
+ sendCallback?.(new Error('late send failure'))
+ sendCallback?.(new Error('duplicate late send failure'))
+ await rejectedRequest
+
+ assert.deepStrictEqual(bufferedStation.messageQueue, [serializedMessage])
+ assert.strictEqual(context.station.requests.size, 1)
+ })
+
+ await it('should force-buffer an acknowledged station-stop request exactly once before cancellation', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ let serializedMessage: string | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ serializedMessage = String(data)
+ callback?.()
+ })
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during station stop/)
+ await flushMicrotasks()
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during station stop',
+ false,
+ { bufferInFlightSends: true }
+ )
+ await rejectedRequest
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+
+ assert.deepStrictEqual(bufferedStation.messageQueue, [serializedMessage])
+ assert.strictEqual(context.station.requests.size, 1)
+ })
+
+ await it('should not buffer an acknowledged ordinary request during station stop', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ callback?.()
+ })
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: true, throwError: true }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during station stop/)
+ await flushMicrotasks()
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during station stop',
+ false,
+ { bufferInFlightSends: true }
+ )
+ await rejectedRequest
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should retract an acknowledged force-buffered request during an unrelated flush', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ const wireMessages: string[] = []
+ let initialSendCallback: ((error?: Error) => void) | undefined
+ let bufferedSendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1) {
+ initialSendCallback = callback
+ } else if (wireMessages.length === 2) {
+ bufferedSendCallback = callback
+ } else {
+ callback?.()
+ }
+ })
+ const responseService = (
+ context.requestService as unknown as {
+ ocppResponseService: { responseHandler: () => Promise<undefined> }
+ }
+ ).ocppResponseService
+ mock.method(responseService, 'responseHandler', () => Promise.resolve(undefined))
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during station stop/)
+ await flushMicrotasks()
+ const serializedRequest = wireMessages[0]
+ const unrelatedMessage = '[2,"unrelated","Heartbeat",{}]'
+ context.station.bufferMessage(unrelatedMessage)
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during station stop'
+ )
+ await rejectedRequest
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(bufferedStation.messageQueue, [unrelatedMessage, serializedRequest])
+ const cachedRequest = [...context.station.requests.values()][0]
+ context.station.isStopping = () => false
+
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ context.station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+ sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [serializedRequest, unrelatedMessage])
+
+ cachedRequest[0]({ currentTime: new Date().toISOString() }, {})
+ assert.deepStrictEqual(bufferedStation.messageQueue, [unrelatedMessage])
+ assert.strictEqual(context.station.requests.size, 0)
+
+ bufferedSendCallback?.()
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.deepStrictEqual(wireMessages, [serializedRequest, unrelatedMessage])
+ initialSendCallback?.(new Error('late send failure'))
+ cachedRequest[0]({ currentTime: new Date().toISOString() }, {})
+ assert.deepStrictEqual(wireMessages, [serializedRequest, unrelatedMessage])
+ })
+
+ await it('should retract a force-buffered request when CALLERROR terminates it', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ context.station.isStopping = () => true
+ mock.method(wsConnection, 'send', () => undefined)
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ bufferOnErrorDuringStationStop: true,
+ responseTimeoutMs: 3_600_000,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during station stop/)
+ await flushMicrotasks()
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during station stop'
+ )
+ await rejectedRequest
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(bufferedStation.messageQueue.length, 1)
+ const cachedRequest = [...context.station.requests.values()][0]
+
+ cachedRequest[1](new OCPPError(ErrorType.GENERIC_ERROR, 'Terminal CALLERROR'))
+ cachedRequest[1](new OCPPError(ErrorType.GENERIC_ERROR, 'Duplicate CALLERROR'))
+
+ assert.deepStrictEqual(bufferedStation.messageQueue, [])
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should not remove the next frame when an in-flight buffered frame is retracted', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ const targetMessage = '[2,"target","Heartbeat",{}]'
+ const unrelatedMessage = '[2,"unrelated","Heartbeat",{}]'
+ const wireMessages: string[] = []
+ const sendCallbacks: ((error?: Error) => void)[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (callback != null) sendCallbacks.push(callback)
+ })
+ context.station.bufferMessage(targetMessage)
+ context.station.bufferMessage(unrelatedMessage)
+ const stationBuffer = context.station as unknown as {
+ bufferedMessageInFlight?: { message: string; retracted: boolean }
+ clearIntervalFlushMessageBuffer: () => void
+ messageQueue: string[]
+ sendMessageBuffer: (onComplete: () => void) => void
+ }
+ stationBuffer.clearIntervalFlushMessageBuffer = () => undefined
+ const stationBufferMethods = ChargingStation.prototype as unknown as {
+ removeBufferedMessage: (this: ChargingStation, message: string) => boolean
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ stationBuffer.sendMessageBuffer = stationBufferMethods.sendMessageBuffer
+ stationBufferMethods.sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [targetMessage])
+ assert.strictEqual(
+ stationBufferMethods.removeBufferedMessage.call(context.station, targetMessage),
+ true
+ )
+ assert.deepStrictEqual(stationBuffer.messageQueue, [unrelatedMessage])
+
+ sendCallbacks.shift()?.()
+ assert.deepStrictEqual(stationBuffer.messageQueue, [unrelatedMessage])
+ t.mock.timers.tick(60_000)
+ await flushMicrotasks()
+
+ assert.deepStrictEqual(wireMessages, [targetMessage, unrelatedMessage])
+ sendCallbacks.shift()?.()
+ assert.deepStrictEqual(stationBuffer.messageQueue, [])
+ })
+
+ await it('should release the CALL gate when a buffered replay send callback times out', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ const replayMessage = '[2,"replay-timeout","Heartbeat",{}]'
+ const wireMessages: string[] = []
+ let replaySendCallback: (error?: Error) => void = () => undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ if (wireMessages.length === 1 && callback != null) replaySendCallback = callback
+ else callback?.()
+ })
+ context.station.bufferMessage(replayMessage)
+ const stationBuffer = context.station as unknown as {
+ clearIntervalFlushMessageBuffer: () => void
+ messageQueue: string[]
+ }
+ stationBuffer.clearIntervalFlushMessageBuffer = () => undefined
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ sendMessageBuffer.call(context.station, () => undefined)
+ await flushMicrotasks()
+ assert.deepStrictEqual(wireMessages, [replayMessage])
+
+ const live = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, throwError: true }
+ )
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 1)
+
+ t.mock.timers.tick(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)
+ await flushMicrotasks()
+ assert.strictEqual(wireMessages.length, 2)
+ assert.deepStrictEqual(stationBuffer.messageQueue, [replayMessage])
+
+ const [, liveMessageId] = JSON.parse(wireMessages[1]) as [number, string]
+ context.station.requests.get(liveMessageId)?.[0]({ currentTime: new Date() }, {})
+ await live
+ replaySendCallback()
+ assert.deepStrictEqual(stationBuffer.messageQueue, [replayMessage])
+ assert.strictEqual(wireMessages.length, 2)
+ })
+
+ await it('should not resurrect a buffered request after cancellation', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ mock.method(wsConnection, 'send', () => undefined)
+
+ const pendingRequest = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ { responseTimeoutMs: 3_600_000, skipBufferingOnError: false, throwError: true }
+ )
+ await flushMicrotasks()
+ const rejectedRequest = assert.rejects(pendingRequest, /cancelled during WebSocket send/)
+
+ context.requestService.cancelPendingRequests(
+ context.station,
+ 'Request cancelled during WebSocket send'
+ )
+ await rejectedRequest
+ t.mock.timers.tick(OCPPConstants.OCPP_WEBSOCKET_TIMEOUT_MS)
+ await flushMicrotasks()
+
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.strictEqual(context.station.requests.size, 0)
+ assert.strictEqual(bufferedStation.messageQueue.length, 0)
+ })
+
+ await it('should cancel requests created by the stop sequence during deletion', async () => {
+ const context = createOCPP20RequestTestContext()
+ const pendingStopRequest = Promise.withResolvers<never>()
+ context.station.started = true
+ context.station.stop = (): Promise<void> => {
+ context.station.requests.set('stop-request', [
+ () => undefined,
+ error => {
+ pendingStopRequest.reject(error)
+ },
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ ])
+ return Promise.resolve()
+ }
+ const rejectedRequest = assert.rejects(
+ pendingStopRequest.promise,
+ /deleted while awaiting an OCPP response/
+ )
+
+ await context.station.delete(false)
+
+ await rejectedRequest
+ assert.strictEqual(context.station.requests.size, 0)
+ })
+
+ await it('should preserve buffered requests during a restart cancellation', () => {
+ const context = createOCPP20RequestTestContext()
+ const errorCallback = mock.fn()
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ context.station.requests.set('buffered', [
+ () => undefined,
+ errorCallback,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ ])
+ const transientErrorCallback = mock.fn()
+ context.station.requests.set('transient', [
+ () => undefined,
+ transientErrorCallback,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ ])
+ context.station.bufferMessage('[2,"buffered","Heartbeat",{}]')
+
+ context.requestService.cancelPendingRequests(context.station)
+
+ assert.strictEqual(context.station.requests.has('buffered'), true)
+ assert.strictEqual(bufferedStation.messageQueue.length, 1)
+ assert.strictEqual(errorCallback.mock.callCount(), 0)
+ assert.strictEqual(context.station.requests.has('transient'), false)
+ assert.strictEqual(transientErrorCallback.mock.callCount(), 1)
+ })
+
+ await it('should discard buffered frames when pending requests are cancelled', () => {
+ const context = createOCPP20RequestTestContext()
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ context.station.bufferMessage('[2,"buffered","Heartbeat",{}]')
+ assert.strictEqual(bufferedStation.messageQueue.length, 1)
+
+ context.requestService.cancelPendingRequests(context.station, undefined, true)
+
+ assert.strictEqual(bufferedStation.messageQueue.length, 0)
+ })
+
+ await it('should replay a response behind a blocked CALL while registration is pending', () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.isStopping = () => false
+ context.station.inAcceptedState = () => false
+ const wireMessages: string[] = []
+ let sendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ sendCallback = callback
+ })
+ const blockedCall = '[2,"call","Heartbeat",{}]'
+ const allowedResponse = '[3,"response",{}]'
+ context.station.bufferMessage(blockedCall)
+ context.station.bufferMessage(allowedResponse)
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ context.station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+
+ sendMessageBuffer.call(context.station, () => undefined)
+ assert.deepStrictEqual(wireMessages, [allowedResponse])
+ sendCallback?.()
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(bufferedStation.messageQueue, [blockedCall])
+ })
+
+ await it('should discard only a selected non-array frame behind a pending CALL', () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.isStopping = () => false
+ context.station.inAcceptedState = () => false
+ const wireMessages: string[] = []
+ let sendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ sendCallback = callback
+ })
+ const blockedCall = '[2,"call","Heartbeat",{}]'
+ const allowedResponse = '[3,"response",{}]'
+ context.station.bufferMessage(blockedCall)
+ context.station.bufferMessage('{}')
+ context.station.bufferMessage(allowedResponse)
+ const sendMessageBuffer = (
+ ChargingStation.prototype as unknown as {
+ sendMessageBuffer: (this: ChargingStation, onComplete: () => void) => void
+ }
+ ).sendMessageBuffer
+ ;(
+ context.station as unknown as { sendMessageBuffer: typeof sendMessageBuffer }
+ ).sendMessageBuffer = sendMessageBuffer
+
+ sendMessageBuffer.call(context.station, () => undefined)
+ assert.deepStrictEqual(wireMessages, [allowedResponse])
+ sendCallback?.()
+ const bufferedStation = context.station as unknown as { messageQueue: string[] }
+ assert.deepStrictEqual(bufferedStation.messageQueue, [blockedCall])
+ })
+
+ await it('should notify when a CALLRESULT arrives before response handling completes', async () => {
+ const context = createOCPP20RequestTestContext()
+ const wsConnection = context.station.wsConnection
+ assert.ok(wsConnection != null)
+ context.station.recordRequestStatistic = () => undefined
+ context.station.emitChargingStationEvent = () => undefined
+ mock.method(
+ wsConnection,
+ 'send',
+ (_data: unknown, callback?: (error?: Error) => void): void => {
+ callback?.()
+ }
+ )
+ let responseReceivedCount = 0
+
+ const requestPromise = context.requestService.requestHandler(
+ context.station,
+ OCPP20RequestCommand.HEARTBEAT,
+ {},
+ {
+ onResponseReceived: () => {
+ responseReceivedCount++
+ },
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ )
+ await flushMicrotasks()
+ const cachedRequest = [...context.station.requests.values()].at(0)
+ assert.ok(cachedRequest != null)
+ const [responseCallback] = cachedRequest
+ responseCallback({ currentTime: new Date() }, {})
+ await requestPromise
+
+ assert.strictEqual(responseReceivedCount, 1)
+ })
})
type TestableOCPP20ResponseService,
} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
import {
OCPP20AuthorizationStatusEnumType,
OCPP20TransactionEventEnumType,
const connectorStatus = station.getConnectorStatus(1)
if (connectorStatus != null) {
connectorStatus.transactionId = TEST_TRANSACTION_UUID
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
}
createSpy = mock.method(station, 'createCoherentSession', () => undefined)
const responseService = new OCPP20ResponseService()
assert.strictEqual(createSpy.mock.calls.length, 1, 'createCoherentSession must fire once')
assert.strictEqual(createSpy.mock.calls[0].arguments[0], TEST_TRANSACTION_UUID)
assert.strictEqual(createSpy.mock.calls[0].arguments[1], 1)
+ assert.strictEqual(createSpy.mock.calls[0].arguments[2], 1)
+ })
+
+ await it('should bind a Started fallback session to EVSE 2 when connector ids repeat', async () => {
+ const { station: evseStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ stationInfo: {
+ coherentMeterValues: true,
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const firstEvse = evseStation.getEvseStatus(1)
+ const secondEvse = evseStation.getEvseStatus(2)
+ assert.ok(firstEvse != null && secondEvse != null)
+ const firstConnector = firstEvse.connectors.get(1)
+ const secondConnector = secondEvse.connectors.get(2)
+ assert.ok(firstConnector != null && secondConnector != null)
+ secondEvse.connectors.delete(2)
+ secondEvse.connectors.set(1, secondConnector)
+ firstConnector.transactionId = '00000000-0000-4000-8000-000000000001'
+ firstConnector.transactionPending = true
+ secondConnector.transactionId = TEST_TRANSACTION_UUID
+ secondConnector.transactionStarted = false
+ secondConnector.transactionPending = true
+ const evseCreateSpy = mock.method(evseStation, 'createCoherentSession', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ const request = buildStartedRequest(TEST_TRANSACTION_UUID)
+ request.evse = { connectorId: 1, id: 2 }
+
+ await testable.handleResponseTransactionEvent(
+ evseStation,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ request
+ )
+
+ assert.strictEqual(firstConnector.transactionPending, true)
+ assert.strictEqual(secondConnector.transactionStarted, true)
+ assert.strictEqual(evseCreateSpy.mock.callCount(), 1)
+ assert.strictEqual(evseCreateSpy.mock.calls[0].arguments[0], TEST_TRANSACTION_UUID)
+ assert.strictEqual(evseCreateSpy.mock.calls[0].arguments[1], 1)
+ assert.strictEqual(evseCreateSpy.mock.calls[0].arguments[2], 2)
})
await it('should create a coherent session on Started with idTokenInfo omitted (implicit accept)', async () => {
assert.strictEqual(createSpy.mock.calls.length, 1)
})
+ await it('should defer restored session creation and timers until replay reconciliation', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionRestored = true
+ const startUpdatedSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'startUpdatedMeterValues',
+ () => undefined
+ )
+ const startEndedSpy = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildStartedRequest(TEST_TRANSACTION_UUID)
+ )
+
+ assert.strictEqual(createSpy.mock.callCount(), 0)
+ assert.strictEqual(startUpdatedSpy.mock.callCount(), 0)
+ assert.strictEqual(startEndedSpy.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ })
+
await it('should NOT create a coherent session on rejected idToken without force override', async () => {
const request = buildStartedRequest(TEST_TRANSACTION_UUID)
const response: OCPP20TransactionEventResponse = {
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
station = mockStation
- setupConnectorWithTransaction(station, 1, { transactionId: 100 })
+ setupConnectorWithTransaction(station, 1, { pending: true, transactionId: 100 })
const connectorStatus = station.getConnectorStatus(1)
if (connectorStatus != null) {
connectorStatus.transactionId = TEST_TRANSACTION_UUID
})
// 2.0-T2 — Force-tx on Invalid `Started`: deauth NOT called, connector
- // locked, MV update + ended pumps started.
- // TODO Phase 6 (golden set): add a fake-timer fence to verify the MV pump
- // actually emits a TransactionEvent(Updated) over the wire — the helper-
- // call assertion below stubs the pump and therefore does not catch a
- // wire-level regression where startUpdatedMeterValues is invoked but the
- // interval is bound to the wrong connector. Phase 6 closes this gap.
+ // locked, MV update + ended pumps started for the response EVSE.
await it('should not deauthorize on Invalid Started when the flag is true', async () => {
// Arrange
const mockDeauthTransaction = mock.method(
assert.strictEqual(connectorStatus.locked, true)
assert.strictEqual(mockStartUpdated.mock.calls.length, 1)
assert.strictEqual(mockStartEnded.mock.calls.length, 1)
+ assert.strictEqual(mockStartUpdated.mock.calls[0].arguments[3], 1)
+ assert.strictEqual(mockStartEnded.mock.calls[0].arguments[3], 1)
+ })
+
+ await it('should keep a historical Started response from reviving an ended transaction', async () => {
+ if (station.stationInfo != null) station.stationInfo.forceTransactionOnInvalidIdToken = false
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = false
+ connectorStatus.locked = false
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+ const deauthorize = mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () =>
+ Promise.resolve({} as OCPP20TransactionEventResponse)
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => {
+ /* noop */
+ })
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => {
+ /* noop */
+ })
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(deauthorize.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.locked, false)
})
// 2.0-T3 — Mid-transaction revocation (Updated) STILL aborts.
await it('should still de-authorize on Invalid Updated when the flag is true', async () => {
// Arrange
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarted = true
const mockDeauthTransaction = mock.method(
OCPP20ServiceUtils,
'requestDeauthorizeTransaction',
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
- setupConnectorWithTransaction(flagOffStation, 1, { transactionId: 100 })
+ setupConnectorWithTransaction(flagOffStation, 1, { pending: true, transactionId: 100 })
const flagOffConnector = flagOffStation.getConnectorStatus(1)
if (flagOffConnector != null) {
flagOffConnector.transactionId = TEST_TRANSACTION_UUID
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
- setupConnectorWithTransaction(cacheStation, 1, { transactionId: 100 })
+ setupConnectorWithTransaction(cacheStation, 1, { pending: true, transactionId: 100 })
const cacheConnector = cacheStation.getConnectorStatus(1)
if (cacheConnector != null) {
cacheConnector.transactionId = TEST_TRANSACTION_UUID
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type { ChargingStation } from '../../../../src/charging-station/index.js'
-import type { OCPP20TransactionEventResponse, UUIDv4 } from '../../../../src/types/index.js'
+import type {
+ ConnectorStatus,
+ OCPP20TransactionEventRequest,
+ OCPP20TransactionEventResponse,
+ QueuedTransactionEvent,
+ RequestParams,
+ UUIDv4,
+} from '../../../../src/types/index.js'
+import {
+ prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
+} from '../../../../src/charging-station/HelpersConnectorStatus.js'
import {
createTestableResponseService,
type TestableOCPP20ResponseService,
} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { boundTransactionEventQueue } from '../../../../src/charging-station/TransactionEventQueueUtils.js'
import {
+ ConnectorStatusEnum,
OCPP20AuthorizationStatusEnumType,
OCPP20MessageFormatEnumType,
+ OCPP20RequestCommand,
+ OCPP20TransactionEventEnumType,
OCPPVersion,
} from '../../../../src/types/index.js'
import { Constants } from '../../../../src/utils/index.js'
import {
+ flushMicrotasks,
setupConnectorWithTransaction,
standardCleanup,
} from '../../../helpers/TestLifecycleHelpers.js'
assert.strictEqual(mockDeauthTransaction.mock.calls.length, 0)
})
+ await it('should ignore an Accepted Started response while the transaction is ending', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
+ connectorStatus.locked = false
+ connectorStatus.transactionEnding = true
+ connectorStatus.transactionSeqNo = 1
+ const statusBefore = connectorStatus.status
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ const startedRequest = buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Started
+ )
+ startedRequest.seqNo = 0
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ startedRequest
+ )
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, statusBefore)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ })
+
+ await it('should not commit a delayed Occupied status after the transaction has ended', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
+ connectorStatus.locked = false
+ const occupiedResponse = Promise.withResolvers<Record<string, never>>()
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (() => occupiedResponse.promise) as typeof station.ocppRequestService.requestHandler
+ )
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(requestHandler.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ station.started = false
+ await testable.handleResponseTransactionEvent(
+ station,
+ {},
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Ended)
+ )
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
+
+ occupiedResponse.resolve({})
+ await flushMicrotasks()
+
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
+ })
+
+ await it('should skip de-authorization follow-up while the transaction is ending', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+ const deauthorize = mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () =>
+ Promise.resolve({} as OCPP20TransactionEventResponse)
+ )
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(deauthorize.mock.callCount(), 0)
+ })
+
+ await it('should ignore an Accepted Started response when a matching Ended event is queued', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
+ connectorStatus.locked = false
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+ const statusBefore = connectorStatus.status
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, statusBefore)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ })
+
+ await it('should route a replay response to its canonical EVSE when connector ids overlap', async () => {
+ const { station: multiEvseStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ stationInfo: {
+ ocppStrictCompliance: false,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const secondEvse = multiEvseStation.getEvseStatus(2)
+ const secondOwner = multiEvseStation.getConnectorStatus(2, 2)
+ assert.ok(secondEvse != null)
+ assert.ok(secondOwner != null)
+ secondEvse.connectors.delete(2)
+ secondEvse.connectors.set(1, secondOwner)
+ const wrongOwner = multiEvseStation.getConnectorStatus(1, 1)
+ const canonicalOwner = multiEvseStation.getConnectorStatus(1, 2)
+ assert.ok(wrongOwner != null)
+ assert.ok(canonicalOwner != null)
+ for (const connectorStatus of [wrongOwner, canonicalOwner]) {
+ connectorStatus.transactionId = TEST_TRANSACTION_UUID
+ connectorStatus.transactionPending = true
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ }
+ const startedRequest = buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Started
+ )
+ delete startedRequest.evse
+ canonicalOwner.transactionEventQueue = [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 2,
+ request: startedRequest,
+ seqNo: 0,
+ timestamp: startedRequest.timestamp,
+ },
+ ]
+ multiEvseStation.isWebSocketConnectionOpened = () => true
+ multiEvseStation.inAcceptedState = () => true
+ mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ mock.method(multiEvseStation.ocppRequestService, 'requestHandler', (async (
+ ...args: unknown[]
+ ) => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return {}
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ await testable.handleResponseTransactionEvent(
+ multiEvseStation,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ request
+ )
+ requestParams.onResponseReceived?.()
+ return {}
+ }) as typeof multiEvseStation.ocppRequestService.requestHandler)
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(multiEvseStation, 1, 2)
+
+ assert.strictEqual(canonicalOwner.transactionStarted, true)
+ assert.strictEqual(canonicalOwner.transactionPending, false)
+ assert.strictEqual(wrongOwner.transactionStarted, false)
+ assert.strictEqual(wrongOwner.transactionPending, true)
+ })
+
+ await it('should commit the exact restored queued Started event after transient owner state is cleared', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.locked = false
+ const startedRequest = buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Started
+ )
+ startedRequest.seqNo = 0
+ delete startedRequest.meterValue
+ connectorStatus.transactionEventQueue = [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: startedRequest,
+ seqNo: 0,
+ timestamp: startedRequest.timestamp,
+ },
+ ]
+ const persistedConnectorStatus = JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ const restoredConnectorStatus = preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(persistedConnectorStatus),
+ request =>
+ station.ocppRequestService.validateRequestPayload(
+ station,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ request,
+ { forceValidation: true }
+ ),
+ 1,
+ 1
+ )
+ evseStatus.connectors.set(1, restoredConnectorStatus)
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ restoredConnectorStatus.transactionStarting = false
+ station.isWebSocketConnectionOpened = () => true
+ station.inAcceptedState = () => true
+ mock.method(station.ocppRequestService, 'requestHandler', (async (...args: unknown[]) => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ request
+ )
+ requestParams.onResponseReceived?.()
+ return {}
+ }) as typeof station.ocppRequestService.requestHandler)
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, 1, 1)
+
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, true)
+ assert.strictEqual(restoredConnectorStatus.transactionPending, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, false)
+ assert.strictEqual(restoredConnectorStatus.locked, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should not commit a stale Started response after its restored queue entry was replaced', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarting = false
+ connectorStatus.transactionRestored = true
+ connectorStatus.locked = false
+ const staleRequest = buildTransactionEventRequest(
+ TEST_TRANSACTION_UUID,
+ OCPP20TransactionEventEnumType.Started
+ )
+ staleRequest.seqNo = 0
+ const replacementRequest = structuredClone(staleRequest)
+ connectorStatus.transactionEventQueue = [
+ { request: replacementRequest, seqNo: 0, timestamp: replacementRequest.timestamp },
+ ]
+ const statusBefore = connectorStatus.status
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ const createSession = mock.method(station, 'createCoherentSession', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ staleRequest
+ )
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, statusBefore)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].request, replacementRequest)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ assert.strictEqual(createSession.mock.callCount(), 0)
+ })
+
+ await it('should not resurrect a cleared transaction from a late Started response', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = false
+ connectorStatus.locked = false
+ delete connectorStatus.transactionId
+ const statusBefore = connectorStatus.status
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ const createSession = mock.method(station, 'createCoherentSession', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, statusBefore)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ assert.strictEqual(createSession.mock.callCount(), 0)
+ })
+
+ await it('should not let an old Started response mutate a replacement transaction', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const replacementTransactionId = '00000000-0000-0000-0000-000000000099'
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
+ connectorStatus.locked = false
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ const createSession = mock.method(station, 'createCoherentSession', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(requestHandler.mock.callCount(), 0)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ assert.strictEqual(createSession.mock.callCount(), 0)
+ })
+
+ await it('should process Started normally when only another transaction has Ended queued', async () => {
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionPending = true
+ connectorStatus.locked = false
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ '00000000-0000-0000-0000-000000000099',
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+ const requestHandler = mock.method(
+ station.ocppRequestService,
+ 'requestHandler',
+ (async () => await Promise.resolve({})) as typeof station.ocppRequestService.requestHandler
+ )
+ const startUpdated = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Accepted } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID, OCPP20TransactionEventEnumType.Started)
+ )
+
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionPending, false)
+ assert.strictEqual(connectorStatus.locked, true)
+ assert.strictEqual(requestHandler.mock.callCount(), 1)
+ assert.strictEqual(startUpdated.mock.callCount(), 1)
+ assert.strictEqual(startEnded.mock.callCount(), 1)
+ })
+
await it('should stop only the specific transaction when idTokenInfo status is Invalid', async () => {
// Arrange
const mockDeauthTransaction = mock.method(
assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[0], multiStation)
assert.strictEqual(mockDeauthTransaction.mock.calls[0].arguments[1], 1)
})
+
+ await it('should scope queued Ended events to the response transaction', async () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ {
+ request: buildTransactionEventRequest(
+ '00000000-0000-0000-0000-000000000099',
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+ const deauthorize = mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () =>
+ Promise.resolve({} as OCPP20TransactionEventResponse)
+ )
+
+ await testable.handleResponseTransactionEvent(
+ station,
+ { idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid } },
+ buildTransactionEventRequest(TEST_TRANSACTION_UUID)
+ )
+
+ assert.strictEqual(deauthorize.mock.callCount(), 1)
+ })
+
+ await it('should use cached Ended accounting for repeated responses on a 10k queue', async () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ let queuedRequestEventTypeReads = 0
+ connectorStatus.transactionEventQueue = Array.from(
+ { length: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH },
+ (_, seqNo): QueuedTransactionEvent => {
+ const isTargetEnd = seqNo === Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH - 1
+ const eventType = isTargetEnd
+ ? OCPP20TransactionEventEnumType.Ended
+ : OCPP20TransactionEventEnumType.Updated
+ const request = {
+ transactionInfo: {
+ transactionId: isTargetEnd ? TEST_TRANSACTION_UUID : 'q',
+ },
+ } as unknown as OCPP20TransactionEventRequest
+ Object.defineProperty(request, 'eventType', {
+ enumerable: true,
+ get: () => {
+ queuedRequestEventTypeReads++
+ return eventType
+ },
+ })
+ return { request, seqNo } as QueuedTransactionEvent
+ }
+ )
+ const boundedQueue = boundTransactionEventQueue(connectorStatus)
+ assert.strictEqual(boundedQueue.changed, false)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.length,
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH
+ )
+ assert.ok(boundedQueue.bytes <= Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ queuedRequestEventTypeReads = 0
+ const deauthorize = mock.method(OCPP20ServiceUtils, 'requestDeauthorizeTransaction', async () =>
+ Promise.resolve({} as OCPP20TransactionEventResponse)
+ )
+ const payload: OCPP20TransactionEventResponse = {
+ idTokenInfo: { status: OCPP20AuthorizationStatusEnumType.Invalid },
+ }
+ const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_UUID)
+
+ for (let responseIndex = 0; responseIndex < 32; responseIndex++) {
+ await testable.handleResponseTransactionEvent(station, payload, requestPayload)
+ }
+
+ assert.strictEqual(queuedRequestEventTypeReads, 0)
+ assert.strictEqual(deauthorize.mock.callCount(), 0)
+ })
})
--- /dev/null
+/**
+ * @file Tests for autonomous clock-aligned MeterValues (OCPP 2.0.1, #2011 Category 2F)
+ * @description Standalone clock-aligned `MeterValuesRequest` emission driven by
+ * `AlignedDataCtrlr` (Interval / Enabled / Measurands / SendDuringIdle) per
+ * J01.FR.14, J01.FR.20, J01.FR.21, and J01.FR.22.
+ */
+
+import type { Mock } from 'node:test'
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it, mock } from 'node:test'
+
+import type { CoherentSession } from '../../../../src/charging-station/meter-values/types.js'
+import type {
+ ChargingStationInfo,
+ ConnectorStatus,
+ EvseStatus,
+ OCPP20MeterValue,
+ OCPP20MeterValuesRequest,
+ OCPP20SampledValue,
+ OCPP20TransactionEventOptions,
+ OCPP20TransactionEventRequest,
+ QueuedTransactionEvent,
+ RequestParams,
+} from '../../../../src/types/index.js'
+import type { MockChargingStation } from '../../helpers/StationHelpers.js'
+
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
+import {
+ prepareConnectorStatus,
+ resetConnectorStatus,
+} from '../../../../src/charging-station/HelpersConnectorStatus.js'
+import {
+ buildConfigKey,
+ deleteConfigurationKey,
+ getConfigurationKey,
+} from '../../../../src/charging-station/index.js'
+import { computeCoherentSample } from '../../../../src/charging-station/meter-values/CoherentSampleComputer.js'
+import { recordTransactionIntervalConsumption } from '../../../../src/charging-station/meter-values/TransactionIntervalUtils.js'
+import { TransactionMeterValueDeliveryBarrier } from '../../../../src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.js'
+import {
+ createTestableIncomingRequestService,
+ type TestableOCPP20IncomingRequestService,
+} from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import {
+ buildClockAlignedConnectorMeterValue,
+ buildMeterValue,
+} from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ boundTransactionEventQueue,
+ shiftBoundedTransactionEvent,
+} from '../../../../src/charging-station/TransactionEventQueueUtils.js'
+import { OCPPError } from '../../../../src/exception/index.js'
+import {
+ AttributeEnumType,
+ ChargingStationEvents,
+ ConnectorStatusEnum,
+ CurrentType,
+ ErrorType,
+ MeterValuePhase,
+ OCPP16MeterValueLocation,
+ OCPP16MeterValueMeasurand,
+ OCPP16MeterValuePhase,
+ OCPP16MeterValueUnit,
+ OCPP20ComponentName,
+ OCPP20LocationEnumType,
+ OCPP20MeasurandEnumType,
+ OCPP20OptionalVariableName,
+ OCPP20ReadingContextEnumType,
+ OCPP20RequestCommand,
+ OCPP20RequiredVariableName,
+ OCPP20TransactionEventEnumType,
+ OCPP20TriggerReasonEnumType,
+ OCPP20UnitEnumType,
+ OCPP20VendorVariableName,
+ OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
+ ReasonCodeEnumType,
+ SetVariableStatusEnumType,
+ SigningMethodEnumType,
+ Voltage,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import {
+ setupConnectorWithTransaction,
+ standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_PUBLIC_KEY_HEX,
+} from '../../ChargingStationTestConstants.js'
+import {
+ cleanupChargingStation,
+ cleanupStationTemplates,
+ createStationFromTemplate,
+ writeStationTemplate,
+} from '../../helpers/StationHelpers.js'
+import { createMockChargingStation } from '../../helpers/StationHelpers.js'
+import { createOCPP20RequestTestContext, upsertConfigurationKey } from './OCPP20TestUtils.js'
+
+const ALIGNED_DATA_INTERVAL_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.AlignedDataInterval
+)
+const ALIGNED_ENABLED_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Enabled
+)
+const ALIGNED_MEASURANDS_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+)
+const SEND_DURING_IDLE_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SendDuringIdle
+)
+const TX_UPDATED_MEASURANDS_KEY = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+)
+const getPendingTransactionInterval = (
+ connectorStatus: ConnectorStatus,
+ measurandsKey: string
+): number => {
+ const baselineKey = measurandsKey
+ return Math.max(
+ 0,
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) -
+ (connectorStatus.transactionEnergyActiveImportIntervalBaselines?.[baselineKey] ?? 0)
+ )
+}
+
+const MESSAGE_TIMEOUT_KEY = buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageTimeout,
+ 'Default'
+)
+const MESSAGE_ATTEMPTS_KEY = buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageAttempts,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+)
+const SIGN_READINGS_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+)
+const SIGN_UPDATED_READINGS_KEY = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20VendorVariableName.SignUpdatedReadings
+)
+const PUBLIC_KEY_MODE_KEY = buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue
+)
+const FISCAL_PUBLIC_KEY = buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey')
+const FISCAL_SIGNING_METHOD = buildConfigKey(OCPP20ComponentName.FiscalMetering, 'SigningMethod')
+
+interface AlignedStation {
+ mockStation: MockChargingStation
+ requestHandlerMock: RequestHandlerSpy
+}
+
+/** Node:test spy shape for the mocked `requestHandler`. */
+type RequestHandlerSpy = Mock<(...args: unknown[]) => Promise<unknown>>
+
+// eslint-disable-next-line @typescript-eslint/no-empty-function -- inert timer-tick spy target
+const noop = (): void => {}
+
+/** Waits until fire-and-forget request continuations have settled. */
+const flushPendingPromises = async (): Promise<void> => {
+ await new Promise(resolve => setImmediate(resolve))
+}
+
+/**
+ * Create a mock OCPP 2.0.1 station with a mocked request handler capturing
+ * outgoing MeterValues requests.
+ * @param overrides - Connector/EVSE counts (defaults: two single-connector EVSEs).
+ * @param overrides.connectorsCount - Total number of connectors.
+ * @param overrides.evsesCount - Number of EVSEs the connectors are spread over.
+ * @returns The mock station with seeded energy registers and its handler spy.
+ */
+function createAlignedStation (
+ overrides: {
+ connectorsCount?: number
+ evsesCount?: number
+ } = {}
+): AlignedStation {
+ const connectorsCount = overrides.connectorsCount ?? 2
+ const evsesCount = overrides.evsesCount ?? 2
+ const requestHandlerMock: RequestHandlerSpy = mock.fn(async () => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount,
+ evseConfiguration: { evsesCount },
+ ocppRequestService: {
+ requestHandler: requestHandlerMock,
+ },
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ })
+ const mockStation = station as MockChargingStation
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.meteringPerTransaction = false
+ }
+ // Minimal energy template so the measurand builders can produce samples.
+ // Seed the main meter too: J01.FR.14 requires evseId=0 to participate.
+ const evseIds = [0, ...Array.from({ length: evsesCount }, (_, i) => i + 1)]
+ for (const evseId of evseIds) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ if (evseStatus != null) {
+ evseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as EvseStatus['MeterValues']
+ }
+ }
+ const seedRegister = (connectorId: number, value: number): void => {
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ if (connectorStatus != null) {
+ // Mock factory reads the transaction-scoped field; real class reads the
+ // connector-scoped one when meteringPerTransaction is false — set both.
+ connectorStatus.energyActiveImportRegisterValue = value
+ connectorStatus.transactionEnergyActiveImportRegisterValue = value
+ }
+ }
+ seedRegister(1, 54321)
+ if (connectorsCount >= 2) {
+ seedRegister(2, 54322)
+ }
+ return { mockStation, requestHandlerMock }
+}
+
+/**
+ * Extracts the energy-register sample from an emitted MeterValues payload.
+ * @param payload - Captured MeterValues request payload.
+ * @returns The Energy.Active.Import.Register sampled value, if present.
+ */
+function findEnergySample (
+ /** Captured MeterValues request payload. */
+ payload: OCPP20MeterValuesRequest
+): undefined | { context?: string; measurand?: string; value: unknown } {
+ return payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+}
+
+/**
+ * Maps captured request-handler calls to their MeterValues payloads.
+ * @param requestHandlerMock - The mocked request handler spy.
+ * @returns The emitted MeterValues request payloads, in call order.
+ */
+function sentPayloads (requestHandlerMock: RequestHandlerSpy): OCPP20MeterValuesRequest[] {
+ return requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.METER_VALUES)
+ .map(call => call.arguments[2] as OCPP20MeterValuesRequest)
+}
+
+/**
+ * Maps captured request-handler calls to TransactionEvent options.
+ * @param requestHandlerMock - The mocked request handler spy.
+ * @returns The emitted TransactionEvent options, in call order.
+ */
+function sentTransactionEvents (
+ requestHandlerMock: RequestHandlerSpy
+): OCPP20TransactionEventRequest[] {
+ return requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(call => call.arguments[2] as OCPP20TransactionEventRequest)
+}
+
+await describe('J01 - Autonomous clock-aligned MeterValues (#2011 Category 2F)', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await describe('emitClockAlignedMeterValues (per-tick sweep)', async () => {
+ let alignedStation: AlignedStation
+
+ beforeEach(() => {
+ alignedStation = createAlignedStation()
+ })
+
+ await it('should emit one aggregated SAMPLE.CLOCK MeterValuesRequest per idle EVSE when enabled (J01.FR.14)', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, MESSAGE_TIMEOUT_KEY, '7')
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ assert.ok(
+ requestHandlerMock.mock.calls.every(call => {
+ if (call.arguments[1] !== OCPP20RequestCommand.METER_VALUES) return true
+ const requestParams = call.arguments[3] as RequestParams | undefined
+ return (
+ requestParams?.responseTimeoutMs === 7000 &&
+ requestParams.skipBufferingOnError === true &&
+ requestParams.throwError === true
+ )
+ })
+ )
+ const payloads = sentPayloads(requestHandlerMock)
+ assert.deepStrictEqual(
+ payloads.map(payload => payload.evseId).sort((a, b) => a - b),
+ [0, 1, 2]
+ )
+ for (const payload of payloads) {
+ assert.ok(Array.isArray(payload.meterValue) && payload.meterValue.length > 0)
+ for (const meterValue of payload.meterValue) {
+ for (const sampledValue of meterValue.sampledValue) {
+ assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.SAMPLE_CLOCK)
+ }
+ }
+ const energySample = findEnergySample(payload)
+ assert.ok(energySample != null)
+ if (payload.evseId !== 0) assert.ok(Number(energySample.value) > 0)
+ }
+ })
+
+ await it('should aggregate every connector of one EVSE into a single request', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ assert.ok(payload != null)
+ assert.strictEqual(payload.meterValue.length, 1)
+ assert.strictEqual(findEnergySample(payload)?.value, 54321 + 54322)
+ const contexts = payload.meterValue.flatMap(meterValue =>
+ meterValue.sampledValue.map(sampledValue => sampledValue.context)
+ )
+ assert.ok(contexts.every(context => context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK))
+ })
+
+ await it('should preserve heterogeneous connector-local templates in an EVSE aggregate', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = []
+ connector1.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.VOLTAGE}`
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ assert.ok(payload != null)
+ assert.strictEqual(findEnergySample(payload)?.value, 54321 + 54322)
+ assert.ok(
+ payload.meterValue[0].sampledValue.some(
+ ({ measurand, value }) => measurand === OCPP20MeasurandEnumType.VOLTAGE && value === 230
+ )
+ )
+ })
+
+ await it('should preserve identity-distinct energy register families in an EVSE aggregate', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = []
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.MeterValues = [
+ {
+ customData: { vendorId: 'sensor-a' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ customData: { vendorId: 'sensor-b' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ assert.ok(payload != null)
+ const energySamples = payload.meterValue[0].sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.deepStrictEqual(
+ energySamples.map(sample => [sample.customData?.vendorId, sample.value]),
+ [
+ ['sensor-a', 100],
+ ['sensor-b', 200],
+ ]
+ )
+ })
+
+ await it('should choose the lowest connector id for colliding connector-local templates', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = []
+ connector1.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '210',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '240',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY, OCPP20MeasurandEnumType.VOLTAGE)
+ const connectorsById = new Map([
+ [1, connector1],
+ [2, connector2],
+ ])
+ const observedVoltages: (number | undefined)[] = []
+
+ for (const connectorOrder of [
+ [1, 2],
+ [2, 1],
+ ]) {
+ evseStatus.connectors.clear()
+ for (const connectorId of connectorOrder) {
+ const connectorStatus = connectorsById.get(connectorId)
+ assert.ok(connectorStatus != null)
+ evseStatus.connectors.set(connectorId, connectorStatus)
+ }
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ observedVoltages.push(
+ payload?.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.VOLTAGE
+ )?.value
+ )
+ requestHandlerMock.mock.resetCalls()
+ }
+
+ assert.deepStrictEqual(observedVoltages, [210, 210])
+ })
+
+ await it('should include idle sibling connectors in the station aggregate', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER, unit: 'varh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ evseStatus.MeterValues = []
+ connector1.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit: 'varh',
+ value: '1000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit: 'varh',
+ value: '2000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000010',
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const reactiveEnergySample = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(reactiveEnergySample?.value, 3000)
+ assert.strictEqual(
+ sentPayloads(requestHandlerMock).some(({ evseId }) => evseId === 1),
+ false
+ )
+ assert.strictEqual(sentTransactionEvents(requestHandlerMock).length, 1)
+ })
+
+ await it('should normalize compatible power units before station aggregation', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 2,
+ })
+ for (const [evseId, unit, value] of [
+ [0, 'W', '0'],
+ [1, 'W', '1000'],
+ [2, 'kW', '1'],
+ ] as const) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit,
+ value,
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ }
+ for (const evseId of [1, 2]) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ const connectorStatus = [...evseStatus.connectors.values()][0]
+ connectorStatus.transactionId = `tx-${evseId.toString()}`
+ connectorStatus.transactionStarted = true
+ }
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(payload != null)
+ const powerSample = payload.meterValue[0].sampledValue.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ assert.ok(powerSample != null)
+ assert.strictEqual(powerSample.unitOfMeasure?.unit, 'W')
+ assert.strictEqual(powerSample.value, 2000)
+ })
+
+ await it('should normalize mixed physical locations to station Inlet without changing EVSE payloads', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 2,
+ })
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evse1 = mockStation.getEvseStatus(1)
+ const evse2 = mockStation.getEvseStatus(2)
+ assert.ok(stationEvse != null)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ stationEvse.MeterValues = [
+ {
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ evse1.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '9000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ evse2.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '2000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-mixed-location-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-mixed-location-2' })
+
+ const stationPower = (): OCPP20SampledValue | undefined =>
+ sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(({ sampledValue }) => sampledValue)
+ .find(({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(stationPower()?.location, OCPP20LocationEnumType.Inlet)
+ assert.strictEqual(stationPower()?.value, 3000)
+ const evse1Locations = sentTransactionEvents(requestHandlerMock)
+ .find(({ evse }) => evse?.id === 1)
+ ?.meterValue?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ .map(({ location }) => location)
+ assert.deepStrictEqual(evse1Locations, [
+ OCPP20LocationEnumType.Inlet,
+ OCPP20LocationEnumType.Outlet,
+ ])
+
+ requestHandlerMock.mock.resetCalls()
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(stationPower()?.location, OCPP20LocationEnumType.Inlet)
+ assert.strictEqual(stationPower()?.value, 3750)
+ })
+
+ await it('should match DC current, reactive power, and voltage samples by their explicit location', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const stationEvse = mockStation.getEvseStatus(0)
+ const physicalEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(physicalEvse != null)
+ const sourcePairs = [
+ [OCPP20MeasurandEnumType.CURRENT_IMPORT, 'A', 11, 22],
+ [OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT, 'var', 100, 200],
+ [OCPP20MeasurandEnumType.VOLTAGE, 'V', 230, 400],
+ ] as const
+ physicalEvse.MeterValues = sourcePairs.flatMap(
+ ([measurand, unit, inletValue, outletValue]) => [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand,
+ unit,
+ value: inletValue.toString(),
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand,
+ unit,
+ value: outletValue.toString(),
+ },
+ ]
+ ) as unknown as NonNullable<EvseStatus['MeterValues']>
+ stationEvse.MeterValues = sourcePairs.map(([measurand, unit]) => ({
+ location: OCPP20LocationEnumType.Outlet,
+ measurand,
+ unit,
+ })) as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ sourcePairs.map(([measurand]) => measurand).join(',')
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-dc-location' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const stationSamples = stationPayload.meterValue.flatMap(({ sampledValue }) => sampledValue)
+ for (const [measurand, , , outletValue] of sourcePairs) {
+ const sample = stationSamples.find(candidate => candidate.measurand === measurand)
+ assert.ok(sample != null)
+ assert.strictEqual(sample.location, OCPP20LocationEnumType.Outlet)
+ assert.strictEqual(sample.value, outletValue)
+ }
+ })
+
+ await it('should convert DC active export and omits unsupported Outlet families at EVSE 0', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ const stationEvse = mockStation.getEvseStatus(0)
+ const physicalEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(physicalEvse != null)
+ const templates = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_EXPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT,
+ unit: 'var',
+ value: '200',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_APPARENT_IMPORT,
+ unit: 'VAh',
+ value: '300',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ unit: 'A',
+ value: '4',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '400',
+ },
+ ]
+ physicalEvse.MeterValues = templates as unknown as NonNullable<EvseStatus['MeterValues']>
+ stationEvse.MeterValues = templates.map(template => ({
+ ...template,
+ location: OCPP20LocationEnumType.Inlet,
+ value: undefined,
+ })) as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ templates.map(({ measurand }) => measurand).join(',')
+ )
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const stationSamples = stationPayload.meterValue.flatMap(({ sampledValue }) => sampledValue)
+ const exportPower = stationSamples.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_EXPORT
+ )
+ assert.ok(exportPower != null)
+ assert.strictEqual(exportPower.location, OCPP20LocationEnumType.Inlet)
+ assert.strictEqual(exportPower.value, 800)
+ const unsupportedMeasurands = new Set([
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.ENERGY_APPARENT_IMPORT,
+ OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ])
+ assert.ok(
+ stationSamples.every(
+ ({ measurand }) => measurand == null || !unsupportedMeasurands.has(measurand)
+ )
+ )
+ })
+
+ await it('should deduplicate equivalent units from one physical meter before aggregation', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = []
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'kW',
+ value: '1',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ const stationMeter = mockStation.getEvseStatus(0)
+ assert.ok(stationMeter != null)
+ stationMeter.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '0',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-deduplicate' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(payload != null)
+ const powerSample = payload.meterValue[0].sampledValue.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ assert.ok(powerSample != null)
+ assert.strictEqual(powerSample.unitOfMeasure?.unit, 'W')
+ assert.strictEqual(powerSample.value, 1000)
+ })
+
+ await it('should emit EVSE state of charge in the transaction event', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const stationMeter = mockStation.getEvseStatus(0)
+ assert.ok(evseStatus != null)
+ assert.ok(stationMeter != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.STATE_OF_CHARGE,
+ unit: 'Percent',
+ value: '50',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ stationMeter.MeterValues = []
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-soc' })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.STATE_OF_CHARGE
+ )
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.ok(
+ sentTransactionEvents(requestHandlerMock).some(event =>
+ event.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.STATE_OF_CHARGE
+ )
+ )
+ )
+ )
+ })
+
+ await it('should normalize reactive power units before station aggregation', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 2,
+ })
+ for (const [evseId, unit, value] of [
+ [0, 'var', '0'],
+ [1, 'var', '1000'],
+ [2, 'kvar', '1'],
+ ] as const) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT,
+ unit,
+ value,
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ }
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(payload != null)
+ const reactivePowerSample = payload.meterValue[0].sampledValue.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.POWER_REACTIVE_IMPORT
+ )
+ assert.ok(reactivePowerSample != null)
+ assert.strictEqual(reactivePowerSample.unitOfMeasure?.unit, 'var')
+ assert.strictEqual(reactivePowerSample.value, 2000)
+ })
+
+ await it('should normalize reactive energy units into the aggregate output unit', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = []
+ for (const [connectorId, unit, value] of [
+ [1, 'kvarh', 1],
+ [2, 'varh', 1000],
+ ] as const) {
+ const connectorStatus = mockStation.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit,
+ value: value.toString(),
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ }
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ assert.ok(payload != null)
+ const reactiveEnergySample = payload.meterValue[0].sampledValue.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ assert.ok(reactiveEnergySample != null)
+ assert.strictEqual(reactiveEnergySample.unitOfMeasure?.unit, 'kvarh')
+ assert.strictEqual(reactiveEnergySample.value, 2)
+ })
+
+ await it('should stop ALL emissions while a transaction is ongoing and SendDuringIdle=true (J01.FR.20 station scope)', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-1' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should suppress station-scoped emission while a Started event is in flight', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStarting = true
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should not emit restored transaction data ahead of its durable replay backlog', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000012',
+ })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionRestored = true
+ connectorStatus.transactionEventQueue = [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: new Date(0),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000012' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: new Date(0),
+ },
+ ]
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.deepStrictEqual(sentTransactionEvents(requestHandlerMock), [])
+ assert.strictEqual(
+ sentPayloads(requestHandlerMock).some(({ evseId }) => evseId === 1),
+ false
+ )
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
+ })
+
+ await it('should suppress only the targeted EVSE output while retaining its EVSE 0 contribution', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ for (const evseId of [0, 1, 2]) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = []
+ for (const connectorStatus of evseStatus.connectors.values()) {
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit: 'varh',
+ value: '1000',
+ },
+ ] as unknown as NonNullable<ConnectorStatus['MeterValues']>
+ }
+ }
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-1' })
+ const saveSpy = mock.method(mockStation, 'saveOcppConfiguration')
+ const [setResult] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'true',
+ component: { evse: { id: 1 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+ assert.strictEqual(setResult.attributeStatus, SetVariableStatusEnumType.Accepted)
+ assert.strictEqual(
+ getConfigurationKey(mockStation, `${SEND_DURING_IDLE_KEY}.EVSE.1`)?.value,
+ 'true'
+ )
+ assert.strictEqual(saveSpy.mock.callCount(), 1)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.evseId),
+ [0, 2]
+ )
+ assert.strictEqual(sentTransactionEvents(requestHandlerMock).length, 0)
+ const evsePayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 2)
+ assert.ok(evsePayload != null)
+ assert.strictEqual(
+ evsePayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )?.value,
+ 1000
+ )
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const registerSample = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(registerSample?.value, 2000)
+ })
+
+ await it('should carry interval energy across a suppressed EVSE aligned sample', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ const [suppressionResult] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'true',
+ component: { evse: { id: 1 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+ assert.strictEqual(suppressionResult.attributeStatus, SetVariableStatusEnumType.Accepted)
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-suppressed' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connectorStatus.publicKeySentInTransaction = false
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(30_000))
+
+ assert.strictEqual(sentTransactionEvents(requestHandlerMock).length, 0)
+ assert.strictEqual(getPendingTransactionInterval(connectorStatus, ALIGNED_MEASURANDS_KEY), 60)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+
+ const [emissionResult] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'false',
+ component: { evse: { id: 1 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+ assert.strictEqual(emissionResult.attributeStatus, SetVariableStatusEnumType.Accepted)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const emittedInterval = sentTransactionEvents(
+ requestHandlerMock
+ )[0]?.meterValue?.[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.strictEqual(emittedInterval?.value, 120)
+ assert.strictEqual(getPendingTransactionInterval(connectorStatus, ALIGNED_MEASURANDS_KEY), 0)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ const stationIntervals = sentPayloads(requestHandlerMock)
+ .filter(payload => payload.evseId === 0)
+ .map(
+ payload =>
+ payload.meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ )
+ assert.deepStrictEqual(stationIntervals, [60, 60])
+ })
+
+ await it('should keep aligned and periodic transaction interval cadences independent', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-independent-cadences' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ const buildInterval = (
+ measurandsKey: string,
+ context: OCPP20ReadingContextEnumType,
+ timestamp: Date
+ ): number | undefined =>
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-independent-cadences',
+ 60_000,
+ measurandsKey,
+ context,
+ timestamp,
+ 60_000
+ ).sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+
+ assert.strictEqual(
+ buildInterval(
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000)
+ ),
+ 60
+ )
+ assert.strictEqual(
+ buildInterval(
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ new Date(60_000)
+ ),
+ 60
+ )
+ assert.strictEqual(
+ buildInterval(
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ new Date(120_000)
+ ),
+ 60
+ )
+ assert.strictEqual(
+ buildInterval(
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(120_000)
+ ),
+ 60
+ )
+ })
+
+ for (const meteringPerTransaction of [false, true]) {
+ await it(`preserves pre-boundary station interval energy with meteringPerTransaction=${String(meteringPerTransaction)}`, async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = false
+ mockStation.stationInfo.meteringPerTransaction = meteringPerTransaction
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ const transactionId = `tx-station-cadence-${String(meteringPerTransaction)}`
+ const baselineTimestamp = Date.now()
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(
+ baselineTimestamp
+ )
+ connectorStatus.transactionStart = new Date(baselineTimestamp)
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 0,
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [ALIGNED_MEASURANDS_KEY]: 0,
+ }
+ evseStatus.energyActiveImportIntervalBaseline = 0
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(baselineTimestamp)
+ evseStatus.energyActiveImportRegisterValue = 0
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ transactionId,
+ 30_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(baselineTimestamp + 30_000),
+ 60_000
+ )
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(
+ mockStation,
+ new Date(baselineTimestamp + 60_000)
+ )
+
+ const transactionInterval = sentTransactionEvents(requestHandlerMock)
+ .at(-1)
+ ?.meterValue?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ const stationInterval = sentPayloads(requestHandlerMock)
+ .filter(({ evseId }) => evseId === 0)
+ .at(-1)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.ok(transactionInterval != null && transactionInterval > 0)
+ assert.strictEqual(stationInterval, transactionInterval)
+ })
+ }
+
+ await it('should not advance a shared physical baseline when the built meter value has no interval sample', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.VOLTAGE},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ connectorStatus.energyActiveImportRegisterValue = 115
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 100,
+ }
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 100
+ )
+ })
+
+ await it('should not recount shared EVSE energy across active and idle transitions', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ const activeAt = new Date(60_000)
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-active-idle-baseline' })
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 0,
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = activeAt
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, activeAt)
+ resetConnectorStatus(connectorStatus)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'tx-idle-active-baseline',
+ })
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(180_000)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(180_000))
+
+ const stationIntervals = sentPayloads(requestHandlerMock)
+ .filter(({ evseId }) => evseId === 0)
+ .map(
+ payload =>
+ payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ )
+ assert.deepStrictEqual(stationIntervals, [40, 0, 0])
+ })
+
+ await it('should migrate one legacy shared EVSE baseline across all physical connectors', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.energyActiveImportIntervalBaseline = 80
+ connector1.energyActiveImportRegisterValue = 40
+ connector2.energyActiveImportRegisterValue = 60
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const stationIntervals = sentPayloads(requestHandlerMock)
+ .filter(({ evseId }) => evseId === 0)
+ .map(
+ payload =>
+ payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ )
+ assert.deepStrictEqual(stationIntervals, [20, 0])
+ assert.strictEqual(
+ connector1.energyActiveImportIntervalBaselines?.[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 40
+ )
+ assert.strictEqual(
+ connector2.energyActiveImportIntervalBaselines?.[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 60
+ )
+ assert.strictEqual(evseStatus.energyActiveImportIntervalBaseline, 100)
+ })
+
+ await it('should aggregate idle shared EVSE interval energy and advances every physical baseline', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ connector1.energyActiveImportRegisterValue = 10
+ connector2.energyActiveImportRegisterValue = 30
+ connector1.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 0,
+ }
+ connector2.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 0,
+ }
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const stationInterval = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(stationInterval, 40)
+ assert.strictEqual(
+ connector1.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 10
+ )
+ assert.strictEqual(
+ connector2.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 30
+ )
+ assert.strictEqual(evseStatus.energyActiveImportIntervalBaseline, 40)
+ })
+
+ await it('should isolate idle EVSE and station interval rollback', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ const stationBaselineKey = `station:${ALIGNED_MEASURANDS_KEY}`
+ connectorStatus.energyActiveImportRegisterValue = 10
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [stationBaselineKey]: 5,
+ }
+ let failEvseRequest = false
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20MeterValuesRequest
+ const requestParams = args[3] as RequestParams | undefined
+ if (
+ args[1] === OCPP20RequestCommand.METER_VALUES &&
+ request.evseId === 1 &&
+ failEvseRequest
+ ) {
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'EVSE MeterValues request rejected',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ )
+ }
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(0))
+ connectorStatus.energyActiveImportRegisterValue = 40
+ failEvseRequest = true
+ requestHandlerMock.mock.resetCalls()
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[stationBaselineKey],
+ 40
+ )
+ assert.strictEqual(evseStatus.energyActiveImportIntervalBaseline, 10)
+ const firstStationInterval = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(firstStationInterval, 30)
+
+ failEvseRequest = false
+ requestHandlerMock.mock.resetCalls()
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const secondIntervals = new Map(
+ sentPayloads(requestHandlerMock).map(payload => [
+ payload.evseId,
+ payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ ])
+ )
+ assert.strictEqual(secondIntervals.get(0), 0)
+ assert.strictEqual(secondIntervals.get(1), 30)
+ assert.strictEqual(evseStatus.energyActiveImportIntervalBaseline, 40)
+ })
+
+ await it('should restore shared connector baselines when only the station request fails', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-evse-restore' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 10
+ connector2.energyActiveImportRegisterValue = 30
+ connector1.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ connector2.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(60_000)
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20MeterValuesRequest
+ if (args[1] === OCPP20RequestCommand.METER_VALUES && request.evseId === 0) {
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Station MeterValues request rejected',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ )
+ }
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.ok(
+ sentTransactionEvents(requestHandlerMock).length > 0,
+ 'Expected the EVSE transaction event to succeed independently'
+ )
+ assert.strictEqual(
+ connector1.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 5
+ )
+ assert.strictEqual(
+ connector2.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 5
+ )
+ })
+
+ for (const confirmedCallError of [false, true]) {
+ await it(`restores a station interval baseline after ${confirmedCallError ? 'CALLERROR' : 'pre-send failure'}`, async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams | undefined
+ if (confirmedCallError) {
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected MeterValues',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ requestParams?.onMessageSent?.()
+ requestParams?.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'send failed before transport',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ )
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 5
+ )
+ })
+ }
+
+ await it('should persist a restored baseline after an in-flight provisional snapshot', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const baselineKey = `station:${ALIGNED_MEASURANDS_KEY}`
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.energyActiveImportIntervalBaselines = { [baselineKey]: 5 }
+ const firstSave = Promise.withResolvers<undefined>()
+ const snapshots: number[] = []
+ let persistedBaseline = 5
+ const stationInternals = mockStation as unknown as {
+ pendingConfigurationSave: Promise<void>
+ saveConfiguration: (onError?: (error: Error) => void) => void
+ transactionEventQueueSaveDirty: boolean
+ transactionEventQueueSaveImmediate: boolean
+ }
+ stationInternals.pendingConfigurationSave = Promise.resolve()
+ stationInternals.transactionEventQueueSaveDirty = false
+ stationInternals.transactionEventQueueSaveImmediate = false
+ stationInternals.saveConfiguration = () => {
+ const snapshot = connectorStatus.energyActiveImportIntervalBaselines?.[baselineKey] ?? 0
+ snapshots.push(snapshot)
+ stationInternals.pendingConfigurationSave =
+ snapshots.length === 1
+ ? firstSave.promise.then(() => {
+ persistedBaseline = snapshot
+ return undefined
+ })
+ : Promise.resolve().then(() => {
+ persistedBaseline = snapshot
+ return undefined
+ })
+ }
+ mockStation.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(mockStation)
+ mockStation.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(mockStation)
+ requestHandlerMock.mock.mockImplementation((): Promise<never> => {
+ mockStation.saveTransactionEventQueues()
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'send failed before transport',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ )
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.deepStrictEqual(snapshots, [40])
+ assert.strictEqual(connectorStatus.energyActiveImportIntervalBaselines[baselineKey], 5)
+ firstSave.resolve(undefined)
+ await mockStation.persistTransactionEventQueues()
+ assert.deepStrictEqual(snapshots, [40, 5])
+ assert.strictEqual(persistedBaseline, 5)
+ })
+
+ await it('should not restore a station interval baseline after an ambiguous sent failure', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'response timed out',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ )
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 40
+ )
+ })
+
+ await it('should not restore a station interval baseline after an ambiguous transport callback before send confirmation', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ let attempts = 0
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<unknown> => {
+ const requestParams = args[3] as RequestParams | undefined
+ if (attempts++ === 0) {
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'ambiguous transport failure',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ requestParams?.onTransportError?.(transportError, true)
+ return Promise.reject(transportError)
+ }
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 40
+ )
+
+ connectorStatus.energyActiveImportRegisterValue = 50
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const stationIntervals = sentPayloads(requestHandlerMock)
+ .filter(({ evseId }) => evseId === 0)
+ .map(
+ payload =>
+ payload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ )
+ assert.deepStrictEqual(stationIntervals, [35, 10])
+ })
+
+ await it('should restore a graceful pre-send failure classified as definitely unsent', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const baselineKey = `station:${ALIGNED_MEASURANDS_KEY}`
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.energyActiveImportIntervalBaselines = { [baselineKey]: 5 }
+ let stopping = false
+ mock.method(mockStation, 'isStopping', () => stopping)
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20MeterValuesRequest
+ const requestParams = args[3] as RequestParams | undefined
+ if (request.evseId === 0) {
+ stopping = true
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'send failed before transport',
+ OCPP20RequestCommand.METER_VALUES
+ )
+ requestParams?.onTransportError?.(transportError, false)
+ return Promise.reject(transportError)
+ }
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(connectorStatus.energyActiveImportIntervalBaselines[baselineKey], 5)
+ })
+
+ await it('should not restore an in-flight aligned baseline when graceful stop cancels its pending send', async () => {
+ const context = createOCPP20RequestTestContext()
+ const station = context.station
+ const wsConnection = station.wsConnection
+ assert.ok(wsConnection != null)
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.meteringPerTransaction = false
+ station.recordRequestStatistic = () => undefined
+ station.emitChargingStationEvent = () => undefined
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(station, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(station, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ station,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const stationEvseStatus = station.getEvseStatus(0)
+ const evseStatus = station.getEvseStatus(1)
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(stationEvseStatus != null)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ stationEvseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '0',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ connectorStatus.energyActiveImportRegisterValue = 40
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 5,
+ }
+ let online = true
+ station.isWebSocketConnectionOpened = () => online
+ let lateSendCallback: ((error?: Error) => void) | undefined
+ mock.method(wsConnection, 'send', (_data: unknown, callback?: (error?: Error) => void) => {
+ lateSendCallback = callback
+ })
+ let alignedDelivery: Promise<void> | undefined
+ const testableStation = station as unknown as {
+ closeWSConnection: (options?: unknown) => void
+ configurationFileHash: string
+ ocppIncomingRequestService: { stop: () => void }
+ performStop: () => Promise<void>
+ saveConfiguration: () => void
+ sharedLRUCache: { deleteChargingStationConfiguration: (configurationHash: string) => void }
+ stopMessageSequence: () => Promise<void>
+ }
+ const stationMethods = ChargingStation.prototype as unknown as {
+ performStop: (this: ChargingStation) => Promise<void>
+ }
+ testableStation.configurationFileHash = 'aligned-stop-test'
+ testableStation.performStop = stationMethods.performStop
+ testableStation.saveConfiguration = () => undefined
+ testableStation.sharedLRUCache = { deleteChargingStationConfiguration: () => undefined }
+ testableStation.stopMessageSequence = async () => {
+ alignedDelivery = OCPP20ServiceUtils.emitClockAlignedMeterValues(station, new Date(60_000))
+ await flushPendingPromises()
+ assert.ok(lateSendCallback != null)
+ }
+ testableStation.closeWSConnection = () => {
+ online = false
+ }
+ mock.method(testableStation.ocppIncomingRequestService, 'stop', () => undefined)
+
+ await testableStation.performStop()
+ assert.ok(alignedDelivery != null)
+ await alignedDelivery
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 40
+ )
+
+ lateSendCallback?.()
+ await flushPendingPromises()
+ assert.strictEqual(
+ connectorStatus.energyActiveImportIntervalBaselines[`station:${ALIGNED_MEASURANDS_KEY}`],
+ 40
+ )
+ assert.strictEqual(station.requests.size, 0)
+ })
+
+ await it('should preserve physical interval energy when a transaction ends between aligned slots', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-between-slots' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 15
+ connectorStatus.energyActiveImportIntervalBaselines = {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 10,
+ }
+ evseStatus.energyActiveImportRegisterValue = 15
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(120_000)
+
+ resetConnectorStatus(connectorStatus)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const stationInterval = sentPayloads(requestHandlerMock)
+ .filter(({ evseId }) => evseId === 0)
+ .at(-1)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(stationInterval, 5)
+ assert.deepStrictEqual(connectorStatus.energyActiveImportIntervalBaselines, {
+ [`station:${ALIGNED_MEASURANDS_KEY}`]: 15,
+ })
+ })
+
+ await it('should retain only the latest aligned boundary while one request per EVSE is stalled', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ const firstRequests = Promise.withResolvers<undefined>()
+ let requestsBlocked = true
+ requestHandlerMock.mock.mockImplementation(async (): Promise<unknown> => {
+ if (requestsBlocked) await firstRequests.promise
+ return {}
+ })
+
+ const firstSweep = OCPP20ServiceUtils.emitClockAlignedMeterValues(
+ mockStation,
+ new Date(60_000)
+ )
+ const firstRequestCount = requestHandlerMock.mock.callCount()
+ assert.ok(firstRequestCount > 0)
+ const replacementSweeps: Promise<void>[] = []
+ for (let boundary = 2; boundary <= 3601; boundary++) {
+ replacementSweeps.push(
+ OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(boundary * 60_000))
+ )
+ }
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), firstRequestCount)
+ await Promise.all(replacementSweeps)
+ requestsBlocked = false
+ firstRequests.resolve(undefined)
+ await firstSweep
+ assert.strictEqual(requestHandlerMock.mock.callCount(), firstRequestCount * 2)
+ for (const payload of sentPayloads(requestHandlerMock).slice(firstRequestCount)) {
+ assert.deepStrictEqual(
+ payload.meterValue.map(meterValue => meterValue.timestamp.getTime()),
+ [3601 * 60_000]
+ )
+ }
+ })
+
+ await it('should start a new drain when a request arrives before the prior finalizer', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const firstDelivery = Promise.withResolvers<unknown>()
+ requestHandlerMock.mock.mockImplementationOnce(() => firstDelivery.promise)
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+
+ const first = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000)
+ )
+ const second = firstDelivery.promise.then(() =>
+ OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(mockStation, 1, request(120_000))
+ )
+ firstDelivery.resolve({})
+
+ await first
+ await second
+ await flushPendingPromises()
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.meterValue[0].timestamp.getTime()),
+ [60_000, 120_000]
+ )
+ })
+
+ await it('should settle a cancelled pending trigger exactly once and releases its barrier', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-cancel-pending' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const firstRequest = Promise.withResolvers<unknown>()
+ requestHandlerMock.mock.mockImplementationOnce(() => firstRequest.promise)
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+ const inFlight = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000)
+ )
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ const delivery = TransactionMeterValueDeliveryBarrier.begin(
+ connectorStatus,
+ 'tx-cancel-pending'
+ )
+ assert.ok(delivery != null)
+ let restoreCount = 0
+ const settlements: boolean[] = []
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(120_000),
+ undefined,
+ [{ key: connectorStatus, restore: () => restoreCount++ }],
+ true,
+ [],
+ definitivelyRejected => {
+ settlements.push(definitivelyRejected)
+ delivery.settle(definitivelyRejected)
+ }
+ )
+
+ OCPP20ServiceUtils.cancelClockAlignedMeterValuesRequests(mockStation)
+ OCPP20ServiceUtils.cancelClockAlignedMeterValuesRequests(mockStation)
+
+ assert.deepStrictEqual(settlements, [true])
+ assert.strictEqual(restoreCount, 1)
+ const successor = TransactionMeterValueDeliveryBarrier.begin(
+ connectorStatus,
+ 'tx-cancel-pending'
+ )
+ assert.ok(successor != null)
+ assert.strictEqual(successor.waitForTurn(), undefined)
+ successor.settle()
+
+ firstRequest.resolve({})
+ await inFlight
+ await flushPendingPromises()
+ assert.deepStrictEqual(settlements, [true])
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should send only triggered aligned work while registration is pending', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ mockStation.inAcceptedState = () => false
+ mockStation.inPendingState = () => true
+ mockStation.isWebSocketConnectionOpened = () => true
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000),
+ undefined,
+ [],
+ true
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(mockStation, 1, request(120_000))
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const requestParams = requestHandlerMock.mock.calls[0].arguments[3] as RequestParams
+ assert.strictEqual(requestParams.triggerMessage, true)
+ })
+
+ await it('should preserve a reserved trigger and restore a later autonomous candidate', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const releaseFirst = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async () => {
+ await releaseFirst.promise
+ return {}
+ })
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+ const first = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000)
+ )
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(120_000),
+ undefined,
+ [],
+ true
+ )
+ let restored = 0
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(180_000),
+ undefined,
+ [{ consumed: 7, key: {}, restore: consumed => (restored += consumed) }]
+ )
+
+ assert.strictEqual(restored, 7)
+ releaseFirst.resolve(undefined)
+ await first
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ const requestParams = requestHandlerMock.mock.calls[1].arguments[3] as RequestParams
+ assert.strictEqual(requestParams.triggerMessage, true)
+ assert.strictEqual(
+ sentPayloads(requestHandlerMock)[1].meterValue[0].timestamp.getTime(),
+ 120_000
+ )
+ })
+
+ await it('should reject an atomic trigger reservation behind autonomous pending work', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const releaseFirst = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async () => {
+ await releaseFirst.promise
+ return {}
+ })
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+ const first = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000)
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(mockStation, 1, request(120_000))
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ false
+ )
+ releaseFirst.resolve(undefined)
+ await first
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ const requestParams = requestHandlerMock.mock.calls[1].arguments[3] as RequestParams
+ assert.strictEqual(requestParams.triggerMessage, false)
+ assert.strictEqual(
+ sentPayloads(requestHandlerMock)[1].meterValue[0].timestamp.getTime(),
+ 120_000
+ )
+ })
+
+ await it('should retry a failed reserved trigger exactly and restore later autonomous work', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const releaseFailure = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async () => {
+ await releaseFailure.promise
+ throw new Error('definitely unsent')
+ })
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ const first = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000),
+ undefined,
+ [],
+ true
+ )
+ let restored = 0
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(120_000),
+ undefined,
+ [{ consumed: 7, key: {}, restore: consumed => (restored += consumed) }]
+ )
+ releaseFailure.resolve(undefined)
+ await first
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ const retriedParams = requestHandlerMock.mock.calls[1].arguments[3] as RequestParams
+ assert.strictEqual(retriedParams.triggerMessage, true)
+ assert.strictEqual(restored, 7)
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock)[1].meterValue.map(value => value.timestamp.getTime()),
+ [60_000]
+ )
+ })
+
+ await it('should reject later signed evidence rather than coalescing exact requests', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const releaseFirst = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async () => {
+ await releaseFirst.promise
+ return {}
+ })
+ const request = (timestamp: number, signedMeterData: string): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData,
+ signingMethod: '',
+ },
+ value: timestamp,
+ },
+ ],
+ timestamp: new Date(timestamp),
+ },
+ ],
+ })
+
+ const first = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000, 'first')
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(120_000, 'preserved')
+ )
+ let restored = 0
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(180_000, 'latest'),
+ undefined,
+ [{ consumed: 7, key: {}, restore: consumed => (restored += consumed) }]
+ )
+ assert.strictEqual(restored, 7)
+ releaseFirst.resolve(undefined)
+ await first
+
+ const coalescedPayload = sentPayloads(requestHandlerMock)[1]
+ assert.deepStrictEqual(
+ coalescedPayload.meterValue.flatMap(meterValue =>
+ meterValue.sampledValue.map(sample => ({
+ signedMeterData: sample.signedMeterValue?.signedMeterData,
+ timestamp: meterValue.timestamp.getTime(),
+ }))
+ ),
+ [{ signedMeterData: 'preserved', timestamp: 120_000 }]
+ )
+ })
+
+ for (const exactKind of ['signed', 'trigger'] as const) {
+ await it(`should reject a trigger reservation behind an in-flight ${exactKind} request`, async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const firstResponse = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async (): Promise<unknown> => {
+ await firstResponse.promise
+ return {}
+ })
+ const request: OCPP20MeterValuesRequest = {
+ evseId: 1,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context:
+ exactKind === 'trigger'
+ ? OCPP20ReadingContextEnumType.TRIGGER
+ : OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ ...(exactKind === 'signed' && {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ }),
+ value: 1,
+ },
+ ],
+ timestamp: new Date(1_000),
+ },
+ ],
+ }
+ if (exactKind === 'trigger') {
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ }
+ const firstDrain = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request,
+ undefined,
+ [],
+ exactKind === 'trigger'
+ )
+ await flushPendingPromises()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ false
+ )
+ firstResponse.resolve(undefined)
+ await firstDrain
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+ }
+
+ await it('should preserve an accepted trigger behind a definitely-unsent unsigned request', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const releaseFailure = Promise.withResolvers<undefined>()
+ requestHandlerMock.mock.mockImplementationOnce(async () => {
+ await releaseFailure.promise
+ throw new Error('definitely unsent')
+ })
+ const request = (timestamp: number): OCPP20MeterValuesRequest => ({
+ evseId: 1,
+ meterValue: [{ sampledValue: [], timestamp: new Date(timestamp) }],
+ })
+ let restored = 0
+ const firstDrain = OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(60_000),
+ undefined,
+ [{ consumed: 7, key: {}, restore: consumed => (restored += consumed) }]
+ )
+ await flushPendingPromises()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(
+ OCPP20ServiceUtils.reserveTriggeredMeterValuesRequests(mockStation, [1]),
+ true
+ )
+ await OCPP20ServiceUtils.sendClockAlignedMeterValuesRequest(
+ mockStation,
+ 1,
+ request(120_000),
+ undefined,
+ [],
+ true
+ )
+
+ releaseFailure.resolve(undefined)
+ await firstDrain
+
+ assert.strictEqual(restored, 7)
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.meterValue[0].timestamp.getTime()),
+ [60_000, 120_000]
+ )
+ assert.deepStrictEqual(
+ requestHandlerMock.mock.calls.map(
+ call => (call.arguments[3] as RequestParams).triggerMessage
+ ),
+ [false, true]
+ )
+ })
+
+ await it('should preserve interval energy while aligned requests are coalesced', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL},${OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_INTERVAL}`
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ customData: { channel: 'active', vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ {
+ customData: { channel: 'active', vendorId: 'test' },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'kWh',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_INTERVAL,
+ unit: 'varh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ customData: { channel: 'active', vendorId: 'test' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ customData: { channel: 'active', vendorId: 'test' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'kWh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_INTERVAL,
+ unit: 'varh',
+ value: '5',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coalesced' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const firstRequest = Promise.withResolvers<undefined>()
+ let meterValuesBlocked = true
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP20RequestCommand.METER_VALUES && meterValuesBlocked) {
+ await firstRequest.promise
+ }
+ return {}
+ })
+
+ const firstSweep = OCPP20ServiceUtils.emitClockAlignedMeterValues(
+ mockStation,
+ new Date(60_000)
+ )
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(180_000))
+ meterValuesBlocked = false
+ firstRequest.resolve(undefined)
+ await firstSweep
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(240_000))
+
+ const stationPayloads = sentPayloads(requestHandlerMock).filter(
+ payload => payload.evseId === 0
+ )
+ assert.strictEqual(stationPayloads.length, 3)
+ const intervalValues = stationPayloads
+ .slice(1)
+ .map(payload =>
+ payload.meterValue[0].sampledValue
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .map(sample => [sample.unitOfMeasure?.unit, sample.value])
+ )
+ assert.deepStrictEqual(intervalValues, [
+ [
+ ['Wh', 40],
+ ['kWh', 0.04],
+ ],
+ [
+ ['Wh', 20],
+ ['kWh', 0.02],
+ ],
+ ])
+ assert.deepStrictEqual(
+ stationPayloads
+ .slice(1)
+ .map(
+ payload =>
+ payload.meterValue[0].sampledValue.find(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_INTERVAL
+ )?.value
+ ),
+ [10, 5]
+ )
+ assert.deepStrictEqual(
+ stationPayloads.slice(1).map(payload => payload.meterValue[0].timestamp.getTime()),
+ [180_000, 240_000]
+ )
+ })
+
+ await it('should retry failed aligned interval energy on the next boundary', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-meter-values-retry' })
+ let failed = false
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20MeterValuesRequest
+ if (args[1] === OCPP20RequestCommand.METER_VALUES && request.evseId === 0 && !failed) {
+ failed = true
+ return Promise.reject(new Error('CALLERROR'))
+ }
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const stationPayloads = sentPayloads(requestHandlerMock).filter(
+ payload => payload.evseId === 0
+ )
+ assert.strictEqual(stationPayloads.length, 2)
+ const firstInterval = stationPayloads[0].meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ const retriedInterval = stationPayloads[1].meterValue[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.ok(firstInterval != null)
+ assert.strictEqual(retriedInterval, firstInterval * 2)
+ })
+
+ await it('should keep an absent EVSE SendDuringIdle override linked to the station value', () => {
+ const { mockStation } = alignedStation
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ const manager = OCPP20VariableManager.getInstance()
+ const request = {
+ component: { evse: { id: 1 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ }
+
+ assert.strictEqual(manager.getVariables(mockStation, [request])[0].attributeValue, 'true')
+ assert.strictEqual(
+ getConfigurationKey(mockStation, `${SEND_DURING_IDLE_KEY}.EVSE.1`),
+ undefined
+ )
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ assert.strictEqual(manager.getVariables(mockStation, [request])[0].attributeValue, 'false')
+ })
+
+ await it('should reject connector-tier SendDuringIdle overrides', () => {
+ const { mockStation } = alignedStation
+ const [result] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeValue: 'true',
+ component: {
+ evse: { connectorId: 1, id: 1 },
+ name: OCPP20ComponentName.AlignedDataCtrlr,
+ },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+
+ assert.strictEqual(result.attributeStatus, SetVariableStatusEnumType.UnknownVariable)
+ })
+
+ await it('should reject EVSE 0 for EVSE-scoped variables', () => {
+ const { mockStation } = alignedStation
+ const [result] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeValue: 'true',
+ component: { evse: { id: 0 }, name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20OptionalVariableName.SendDuringIdle },
+ },
+ ])
+
+ assert.strictEqual(result.attributeStatus, SetVariableStatusEnumType.UnknownComponent)
+ assert.strictEqual(
+ getConfigurationKey(mockStation, `${SEND_DURING_IDLE_KEY}.EVSE.0`),
+ undefined
+ )
+ })
+
+ await it('should reject EVSE qualifiers on station-scoped aligned variables', () => {
+ const { mockStation } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'false')
+ const [result] = OCPP20VariableManager.getInstance().setVariables(mockStation, [
+ {
+ attributeValue: 'true',
+ component: {
+ evse: { id: 1 },
+ name: OCPP20ComponentName.AlignedDataCtrlr,
+ },
+ variable: { name: OCPP20RequiredVariableName.Enabled },
+ },
+ ])
+
+ assert.strictEqual(result.attributeStatus, SetVariableStatusEnumType.UnknownVariable)
+ assert.strictEqual(getConfigurationKey(mockStation, ALIGNED_ENABLED_KEY)?.value, 'false')
+ })
+
+ await it('should emit for idle EVSEs with SendDuringIdle=true when no transaction is ongoing', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ })
+
+ await it('should keep emitting for an in-transaction EVSE when SendDuringIdle=false', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-1' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.evseId),
+ [0, 2]
+ )
+ const transactionEvents = sentTransactionEvents(requestHandlerMock)
+ assert.strictEqual(transactionEvents.length, 1)
+ assert.strictEqual(transactionEvents[0].evse?.id, 1)
+ assert.strictEqual(transactionEvents[0].transactionInfo.transactionId, 'tx-1')
+ assert.strictEqual(transactionEvents[0].eventType, OCPP20TransactionEventEnumType.Updated)
+ assert.strictEqual(
+ transactionEvents[0].triggerReason,
+ OCPP20TriggerReasonEnumType.MeterValueClock
+ )
+ assert.strictEqual(
+ transactionEvents[0].meterValue?.[0].sampledValue[0].context,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ const transactionEventCall = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.ok(transactionEventCall != null)
+ const requestParams = transactionEventCall.arguments[3] as RequestParams
+ assert.strictEqual(requestParams.skipBufferingOnError, true)
+ assert.strictEqual(requestParams.throwError, true)
+ })
+
+ await it('should treat a pending transaction as idle until Started is accepted', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, { pending: true, transactionId: 'tx-pending' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.evseId),
+ [0, 1, 2]
+ )
+ })
+
+ await it('should not emit Updated after Ended delivery has started', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-ending' })
+ let releaseEndedRequest: () => void = noop
+ const endedRequestBlocked = new Promise<void>(resolve => {
+ releaseEndedRequest = resolve
+ })
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ const options = args[2] as OCPP20TransactionEventOptions | undefined
+ if (
+ args[1] === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ options?.eventType === OCPP20TransactionEventEnumType.Ended
+ ) {
+ await endedRequestBlocked
+ }
+ return undefined
+ })
+
+ const stopPromise = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ await flushPendingPromises()
+ const secondStopPromise = OCPP20ServiceUtils.requestStopTransaction(mockStation, 1, 1)
+ assert.strictEqual(secondStopPromise, stopPromise)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+ const eventTypesWhileEnding = sentTransactionEvents(requestHandlerMock).map(
+ event => event.eventType
+ )
+ releaseEndedRequest()
+ await Promise.all([stopPromise, secondStopPromise])
+
+ assert.deepStrictEqual(eventTypesWhileEnding, [OCPP20TransactionEventEnumType.Ended])
+ })
+
+ await it('should not emit Updated after an Ended event has been persisted', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000009',
+ })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 1,
+ timestamp: new Date('2026-09-01T12:00:00.000Z'),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000009' },
+ triggerReason: OCPP20TriggerReasonEnumType.EVCommunicationLost,
+ },
+ seqNo: 1,
+ timestamp: new Date('2026-09-01T12:00:00.000Z'),
+ },
+ ]
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.deepStrictEqual(sentTransactionEvents(requestHandlerMock), [])
+ })
+
+ await it('should isolate a connector build failure from the remaining EVSEs', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const failedEvseStatus = mockStation.getEvseStatus(1)
+ const failedConnectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(failedEvseStatus != null)
+ assert.ok(failedConnectorStatus != null)
+ failedConnectorStatus.energyActiveImportRegisterValue = 115
+ failedEvseStatus.energyActiveImportIntervalBaseline = 95
+ const stationBaselineKey = `station:${ALIGNED_MEASURANDS_KEY}`
+ failedConnectorStatus.energyActiveImportIntervalBaselines = {
+ [stationBaselineKey]: 100,
+ }
+ const getConnectorStatus = mockStation.getConnectorStatus.bind(mockStation)
+ mock.method(mockStation, 'getConnectorStatus', (connectorId: number, evseId?: number) => {
+ if (evseId === 1) throw new Error('connector build failed')
+ return getConnectorStatus(connectorId, evseId)
+ })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.deepStrictEqual(
+ sentPayloads(requestHandlerMock).map(payload => payload.evseId),
+ [0, 2]
+ )
+ assert.strictEqual(failedEvseStatus.energyActiveImportIntervalBaseline, 95)
+ assert.strictEqual(
+ failedConnectorStatus.energyActiveImportIntervalBaselines[stationBaselineKey],
+ 100
+ )
+ })
+
+ await it('should use the transactional Sample.Clock pipeline and aligned signing for an active connector', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-1' })
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ if (mockStation.stationInfo != null) mockStation.stationInfo.meteringPerTransaction = true
+ const activeConnectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(activeConnectorStatus != null)
+ activeConnectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+
+ const slotTimestamp = new Date('2026-08-28T15:00:00.000Z')
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, slotTimestamp)
+
+ const transactionEvent = sentTransactionEvents(requestHandlerMock)[0]
+ assert.strictEqual(transactionEvent.timestamp, slotTimestamp)
+ assert.strictEqual(transactionEvent.meterValue?.[0]?.timestamp, slotTimestamp)
+ const energySample = transactionEvent.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER)
+ assert.ok(energySample?.signedMeterValue != null)
+ assert.strictEqual(energySample.context, OCPP20ReadingContextEnumType.SAMPLE_CLOCK)
+ assert.strictEqual(energySample.value, 1234)
+ })
+
+ await it('should not sign active aligned samples when SignUpdatedReadings is disabled', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'false')
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-sign-standard' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const samples = sentTransactionEvents(requestHandlerMock).flatMap(transactionEvent =>
+ (transactionEvent.meterValue ?? []).flatMap(meterValue => meterValue.sampledValue)
+ )
+ assert.ok(samples.length > 0)
+ assert.ok(samples.every(sample => sample.signedMeterValue == null))
+ })
+
+ await it('should not sign active aligned samples when standard SignReadings is disabled', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-sign-vendor' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const samples = sentTransactionEvents(requestHandlerMock).flatMap(transactionEvent =>
+ (transactionEvent.meterValue ?? []).flatMap(meterValue => meterValue.sampledValue)
+ )
+ assert.ok(samples.length > 0)
+ assert.ok(samples.every(sample => sample.signedMeterValue == null))
+ })
+
+ await it('should sign idle aligned samples when SignReadings is enabled', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const samples = sentPayloads(requestHandlerMock).flatMap(payload =>
+ payload.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ )
+ assert.ok(samples.length > 0)
+ assert.ok(samples.every(sample => sample.signedMeterValue != null))
+ })
+
+ await it('should finalize signing state and timestamp on a coherent aligned sample', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [OCPP20LocationEnumType.Inlet, OCPP20LocationEnumType.Outlet].map(
+ location => ({
+ location,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ })
+ ) as unknown as NonNullable<EvseStatus['MeterValues']>
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent' })
+ if (mockStation.stationInfo != null) mockStation.stationInfo.meteringPerTransaction = true
+ const coherentConnectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(coherentConnectorStatus != null)
+ coherentConnectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'test',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ mockStation.__injectCoherentSession('tx-coherent', session)
+ assert.strictEqual(mockStation.getCoherentSession('tx-coherent'), session)
+
+ const alignedTimestamp = new Date(60_000)
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, alignedTimestamp)
+ await flushPendingPromises()
+
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ const transactionEvent = sentTransactionEvents(requestHandlerMock)[0]
+ const meterValues = transactionEvent.meterValue ?? []
+ const energySamples = meterValues
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(energySamples.length, 2)
+ const transactionRegister = Number(
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0).toFixed(2)
+ )
+ assert.deepStrictEqual(
+ energySamples.map(sample => sample.value),
+ [transactionRegister, transactionRegister]
+ )
+ assert.ok(
+ energySamples.every(
+ sample => sample.value !== connectorStatus.energyActiveImportRegisterValue
+ )
+ )
+ assert.ok(energySamples.every(sample => sample.signedMeterValue != null))
+ assert.strictEqual(
+ energySamples.filter(sample => (sample.signedMeterValue?.publicKey.length ?? 0) > 0).length,
+ 1
+ )
+ const energySample = energySamples[0]
+ assert.ok(energySample.signedMeterValue != null)
+ const timestamp = meterValues[0].timestamp
+ assert.strictEqual(timestamp instanceof Date, true)
+ assert.strictEqual(timestamp.getTime(), alignedTimestamp.getTime())
+ const signedMeterData = Buffer.from(
+ energySample.signedMeterValue.signedMeterData,
+ 'base64'
+ ).toString('utf8')
+ assert.ok(signedMeterData.includes(`"TM":"${timestamp.toISOString()}"`))
+ })
+
+ await it('should retain OncePerTransaction public-key state when transport buffers the event', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-retry' })
+ if (mockStation.stationInfo != null) mockStation.stationInfo.meteringPerTransaction = true
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ let online = true
+ mockStation.isWebSocketConnectionOpened = () => online
+ let attempts = 0
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ attempts++
+ const requestParams = args[3] as RequestParams | undefined
+ if (attempts === 1) {
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport failed',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams?.onTransportError?.(transportError, false)
+ online = false
+ return Promise.reject(transportError)
+ }
+ requestParams?.onMessageSent?.()
+ return Promise.resolve({})
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ const bufferedEvent = connectorStatus.transactionEventQueue?.[0].request
+ assert.ok(bufferedEvent != null)
+ const bufferedEnergySample = bufferedEvent.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER)
+ assert.ok((bufferedEnergySample?.signedMeterValue?.publicKey.length ?? 0) > 0)
+ const transactionEventCall = requestHandlerMock.mock.calls.find(
+ call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.ok(transactionEventCall != null)
+ const requestParams = transactionEventCall.arguments[3] as RequestParams
+ assert.strictEqual(requestParams.skipBufferingOnError, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ })
+
+ await it('should retry and disposes a rejected live interval update without losing its energy', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const transactionId = 'tx-live-rejected-interval'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ upsertConfigurationKey(
+ mockStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2'
+ )
+ upsertConfigurationKey(
+ mockStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0'
+ )
+ let attempts = 0
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ attempts++
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'delivery failed',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams?.onError?.(callError, true)
+ return Promise.reject(callError)
+ })
+
+ const meterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(60_000),
+ }
+ recordTransactionIntervalConsumption(
+ meterValue,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ ),
+ 10
+ )
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId,
+ { evseId: 1, meterValue: [meterValue] }
+ ),
+ /delivery failed/
+ )
+
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(attempts, 2)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.strictEqual(
+ Object.values(connectorStatus.transactionEnergyActiveImportIntervalCarry ?? {}).reduce(
+ (total, energyWh) => total + energyWh,
+ 0
+ ),
+ 10
+ )
+ })
+
+ await it('should release a builder-owned key after final CALLERROR', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ upsertConfigurationKey(mockStation, MESSAGE_ATTEMPTS_KEY, '1')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-final-callerror' })
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ let reject = true
+ requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ if (!reject) return Promise.resolve({})
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected TransactionEvent',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams?.onError?.(callError, true)
+ return Promise.reject(callError)
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ reject = false
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const transactionEvents = sentTransactionEvents(requestHandlerMock)
+ const publicKeyCount = (event: OCPP20TransactionEventRequest): number =>
+ event.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => (sample.signedMeterValue?.publicKey.length ?? 0) > 0).length ?? 0
+ assert.deepStrictEqual(transactionEvents.map(publicKeyCount), [1, 1])
+ })
+
+ await it('should advance coherent state once across interleaved aligned samples', async () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ const { mockStation: controlStation } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-state' })
+ setupConnectorWithTransaction(controlStation, 1, { transactionId: 'tx-coherent-state' })
+ const createSession = (): CoherentSession => ({
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'state-test',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent-state',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ })
+ const session = createSession()
+ const controlSession = createSession()
+ mockStation.__injectCoherentSession('tx-coherent-state', session)
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ const controlConnectorStatus = controlStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(controlConnectorStatus != null)
+ const firstOptions = { intervalMs: 60_000, nowMs: 60_000, rootSeed: 42 }
+ assert.deepStrictEqual(
+ computeCoherentSample(mockStation, connectorStatus, session, firstOptions),
+ computeCoherentSample(controlStation, controlConnectorStatus, controlSession, firstOptions)
+ )
+ const socBefore = session.socPercent
+ const registerBefore = connectorStatus.energyActiveImportRegisterValue
+ const transactionRegisterBefore = connectorStatus.transactionEnergyActiveImportRegisterValue
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(90_000))
+
+ // Guard against a vacuous pass: the sweep must actually emit and advance
+ // the active coherent session to the aligned observation time.
+ assert.ok(
+ sentTransactionEvents(requestHandlerMock).some(
+ event => (event.transactionInfo.transactionId as string) === 'tx-coherent-state'
+ )
+ )
+ assert.ok(session.socPercent > socBefore)
+ assert.ok((connectorStatus.energyActiveImportRegisterValue ?? 0) > (registerBefore ?? 0))
+ assert.ok(
+ (connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) >
+ (transactionRegisterBefore ?? 0)
+ )
+ const stationConnectorStatus = mockStation.getConnectorStatus(0, 0)
+ assert.ok(stationConnectorStatus != null)
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ assert.strictEqual(
+ Number(findEnergySample(stationPayload)?.value),
+ Math.round((stationConnectorStatus.energyActiveImportRegisterValue ?? 0) * 100) / 100
+ )
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+ computeCoherentSample(controlStation, controlConnectorStatus, controlSession, {
+ intervalMs: 60_000,
+ nowMs: 120_000,
+ rootSeed: 42,
+ })
+ assert.ok(Math.abs(session.socPercent - controlSession.socPercent) < Number.EPSILON * 32)
+ })
+ await it('should prorate fixed energy across interleaved aligned and periodic samples', async () => {
+ mock.timers.enable({ apis: ['Date'], now: 60_000 })
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const transactionId = 'tx-fixed-energy'
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = false
+ mockStation.stationInfo.meteringPerTransaction = true
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '90')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionStart = new Date(0)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(30_000))
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 110)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 30_000
+ )
+ const alignedEvent = sentTransactionEvents(requestHandlerMock)[0]
+ assert.strictEqual(
+ alignedEvent.meterValue?.[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 110
+ )
+
+ const periodicMeterValue = buildMeterValue(
+ mockStation,
+ transactionId,
+ 30_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ false,
+ { connectorId: 1, energyNominalInterval: 30_000, evseId: 1 }
+ )
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 120)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 60_000
+ )
+ assert.strictEqual(
+ periodicMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 120
+ )
+ })
+
+ await it('should advance the default aligned energy measurand when its configuration key is absent', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = 'tx-default-energy'
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = true
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ deleteConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY, { save: false })
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionStart = new Date(0)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(30_000))
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 130)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 30_000
+ )
+ })
+
+ await it('should advance random aligned energy by timestamp without double-counting at t60', async () => {
+ mock.timers.enable({ apis: ['Date'], now: 60_000 })
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = 'tx-random-energy'
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ evseStatus.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionStart = new Date(0)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(30_000))
+
+ const afterAligned = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ assert.ok(afterAligned >= 100 && afterAligned <= 130)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 30_000
+ )
+
+ buildMeterValue(
+ mockStation,
+ transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ )
+
+ const afterPeriodic = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+ assert.ok(afterPeriodic >= afterAligned && afterPeriodic <= afterAligned + 30)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 60_000
+ )
+ })
+
+ await it('should not advance energy when aligned measurands exclude energy', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY, OCPP20MeasurandEnumType.VOLTAGE)
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-no-energy' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 54321
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(30_000))
+
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 54321)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 1234)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 0
+ )
+ })
+ await it('should preserve coherent energy when aligned payloads exclude energy', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY, OCPP20MeasurandEnumType.VOLTAGE)
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-no-energy' })
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'coherent-no-energy',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent-no-energy',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(session.transactionId, session)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const event = sentTransactionEvents(requestHandlerMock)[0]
+ assert.strictEqual(
+ event.meterValue?.[0].sampledValue.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ ),
+ false
+ )
+ assert.ok((connectorStatus.energyActiveImportRegisterValue ?? 0) > 100)
+ assert.ok((connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0) > 10)
+ assert.ok(session.socPercent > 30)
+ })
+
+ await it('should not advance a restored transaction before its baseline is reconciled', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const alignedAt = new Date('2026-09-01T12:01:00.000Z')
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = true
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000012',
+ })
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionStart = new Date(alignedAt.getTime() - 30_000)
+ prepareConnectorStatus(connectorStatus)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, alignedAt)
+
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 100)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 100)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ })
+
+ await it('should integrate only elapsed energy after restoring the sampling baseline', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ const firstAlignedAt = new Date('2026-09-01T12:01:00.000Z')
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = true
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.VOLTAGE
+ )
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000011',
+ })
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionStart = new Date(firstAlignedAt.getTime() - 30_000)
+ connectorStatus.transactionRestored = true
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(
+ firstAlignedAt.getTime() - 3_600_000
+ )
+ mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ mock.timers.enable({ apis: ['Date'], now: firstAlignedAt.getTime() })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, firstAlignedAt)
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(mockStation)
+ assert.strictEqual(
+ evseStatus.energyActiveImportRegisterLastUpdatedAt.getTime(),
+ firstAlignedAt.getTime()
+ )
+ mock.timers.tick(30_000)
+ buildMeterValue(
+ mockStation,
+ '00000000-0000-4000-8000-000000000011',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY
+ )
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 130)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(
+ mockStation,
+ new Date(firstAlignedAt.getTime() + 60_000)
+ )
+
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 160)
+ assert.ok(connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt != null)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ firstAlignedAt.getTime() + 60_000
+ )
+ const alignedEvents = sentTransactionEvents(requestHandlerMock)
+ assert.strictEqual(alignedEvents.length, 1)
+ assert.deepStrictEqual(
+ alignedEvents.map(
+ event =>
+ event.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value
+ ),
+ [160]
+ )
+ })
+
+ await it('should sanitize malformed restored energy before coherent reconciliation', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = '00000000-0000-4000-8000-000000000013'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'restored-energy',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(transactionId, session)
+ mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+
+ for (const malformedEnergy of [Number.NaN, Number.POSITIVE_INFINITY, -1]) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = malformedEnergy
+ connectorStatus.transactionRestored = true
+ session.socPercent = 30
+
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(mockStation)
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 0)
+ assert.strictEqual(session.socPercent, 30)
+ assert.strictEqual(Number.isFinite(session.socPercent), true)
+ }
+ })
+
+ await it('should emit nothing when AlignedDataInterval=0 (spec §2.2)', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '0')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should emit nothing by default (AlignedDataCtrlr.Enabled=false)', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should emit nothing while the WebSocket connection is closed', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ mockStation.wsConnection = null
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should serialize a multi-EVSE aligned sweep with one common timestamp', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 2 })
+ const { requestService } = createOCPP20RequestTestContext()
+ mockStation.ocppRequestService = requestService
+ mockStation.recordRequestStatistic = () => undefined
+ mockStation.emitChargingStationEvent = () => undefined
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'true')
+ const wsConnection = mockStation.wsConnection
+ assert.ok(wsConnection != null)
+ const wireMessages: string[] = []
+ mock.method(wsConnection, 'send', (data: unknown, callback?: (error?: Error) => void) => {
+ wireMessages.push(String(data))
+ callback?.()
+ })
+ const timestamp = new Date('2026-09-10T12:00:00.000Z')
+
+ const sweep = OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, timestamp)
+ await Promise.resolve()
+ await Promise.resolve()
+ assert.strictEqual(wireMessages.length, 1)
+
+ for (let messageIndex = 0; messageIndex < 3; messageIndex++) {
+ assert.strictEqual(wireMessages.length, messageIndex + 1)
+ const [, messageId] = JSON.parse(wireMessages[messageIndex]) as [number, string]
+ mockStation.requests.get(messageId)?.[0]({}, {})
+ await Promise.resolve()
+ await Promise.resolve()
+ }
+ await sweep
+
+ const requests = wireMessages.map(message => {
+ const [, , command, payload] = JSON.parse(message) as [
+ number,
+ string,
+ OCPP20RequestCommand,
+ { evseId: number; meterValue: { timestamp: string }[] }
+ ]
+ assert.strictEqual(command, OCPP20RequestCommand.METER_VALUES)
+ return payload
+ })
+ assert.deepStrictEqual(
+ requests.map(request => request.evseId),
+ [0, 1, 2]
+ )
+ const timestamps = requests.flatMap(request =>
+ request.meterValue.map(meterValue => meterValue.timestamp)
+ )
+ assert.ok(timestamps.length > 0)
+ assert.deepStrictEqual(new Set(timestamps), new Set([timestamp.toISOString()]))
+ })
+
+ await it('should queue active clock-aligned events while the WebSocket is closed', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-offline-clock' })
+ mockStation.wsConnection = null
+ const timestamp = new Date('2026-08-28T18:00:00.000Z')
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, timestamp)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ const queue = mockStation.getConnectorStatus(1, 1)?.transactionEventQueue
+ assert.strictEqual(queue?.length, 1)
+ assert.strictEqual(
+ queue[0].request.triggerReason,
+ OCPP20TriggerReasonEnumType.MeterValueClock
+ )
+ assert.strictEqual(queue[0].request.timestamp.getTime(), timestamp.getTime())
+ })
+
+ await it('should queue active aligned events until registration is accepted', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-handshake' })
+ mock.method(mockStation, 'inAcceptedState', () => false)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ const queue = mockStation.getConnectorStatus(1, 1)?.transactionEventQueue
+ assert.strictEqual(queue?.length, 1)
+ assert.strictEqual(queue[0].request.transactionInfo.transactionId, 'tx-handshake')
+ assert.strictEqual(queue[0].request.offline, undefined)
+ })
+
+ await it('should reject a protected Ended event that cannot fit the byte cap', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = '00000000-0000-4000-8000-000000000101'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.publicKeySentInTransaction = true
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ) => void
+ }
+ const request: OCPP20TransactionEventRequest = {
+ customData: {
+ payload: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ vendorId: 'test',
+ },
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: TEST_PUBLIC_KEY_HEX,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(0),
+ },
+ ],
+ seqNo: 1,
+ timestamp: new Date(1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ assert.ok(
+ Buffer.byteLength(
+ JSON.stringify({ request, seqNo: request.seqNo, timestamp: new Date() }),
+ 'utf8'
+ ) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+
+ assert.throws(() => {
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, request)
+ }, /capacity exhausted/)
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should compact older Ended meter data before touching a protected Ended event', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ) => void
+ }
+ const oldTransactionId = '00000000-0000-4000-8000-000000000106'
+ const newTransactionId = '00000000-0000-4000-8000-000000000107'
+ const largeEndedRequest = (
+ transactionId: OCPP20TransactionEventRequest['transactionInfo']['transactionId'],
+ seqNo: number
+ ): OCPP20TransactionEventRequest => ({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: Array.from({ length: 240 }, (_, index) => ({
+ sampledValue: [
+ {
+ ...(index === 0 || index === 239
+ ? {
+ context:
+ index === 0
+ ? OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ : OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ }
+ : { measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT }),
+ customData: { payload: 'x'.repeat(2400), vendorId: 'test' },
+ value: index,
+ },
+ ],
+ timestamp: new Date(seqNo * 1000 + index),
+ })),
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ const oldRequest = largeEndedRequest(oldTransactionId, 1)
+ const newRequest = largeEndedRequest(newTransactionId, 2)
+ const queuedBytes = (request: OCPP20TransactionEventRequest): number =>
+ Buffer.byteLength(
+ JSON.stringify({ request, seqNo: request.seqNo, timestamp: new Date(0) }),
+ 'utf8'
+ )
+ assert.ok(queuedBytes(oldRequest) < Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.ok(queuedBytes(newRequest) < Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES)
+ assert.ok(
+ queuedBytes(oldRequest) + queuedBytes(newRequest) + 3 >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, oldRequest)
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, newRequest)
+
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 2)
+ const [compactedEvent, retainedEvent] = connectorStatus.transactionEventQueue
+ assert.strictEqual(compactedEvent.request.transactionInfo.transactionId, oldTransactionId)
+ assert.strictEqual(compactedEvent.request.meterValue?.length, 4)
+ assert.strictEqual(retainedEvent.request.transactionInfo.transactionId, newTransactionId)
+ assert.strictEqual(retainedEvent.request.meterValue?.length, 240)
+ assert.strictEqual(
+ retainedEvent.request.meterValue[0].sampledValue[0].customData?.payload,
+ 'x'.repeat(2400)
+ )
+ assert.strictEqual(
+ retainedEvent.request.meterValue[120].sampledValue[0].measurand,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ assert.strictEqual(
+ retainedEvent.request.meterValue[120].sampledValue[0].customData?.payload,
+ 'x'.repeat(2400)
+ )
+ assert.strictEqual(retainedEvent.request.meterValue.at(-1)?.sampledValue[0].value, 239)
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(connectorStatus.transactionEventQueue), 'utf8') <=
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ })
+
+ await it('should preserve every identity endpoint while pruning lifecycle intermediates', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = '00000000-0000-4000-8000-000000000105'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ) => void
+ }
+ const billingMeterValue = (timestamp: number, value: number): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ context:
+ timestamp === 0
+ ? OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ : OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value,
+ },
+ ],
+ timestamp: new Date(timestamp),
+ })
+ const intermediateMeterValue = (timestamp: number): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ value: 1,
+ },
+ ],
+ timestamp: new Date(timestamp),
+ })
+ const request: OCPP20TransactionEventRequest = {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: [
+ billingMeterValue(0, 10),
+ ...Array.from({ length: 12_000 }, (_, index) => intermediateMeterValue(index + 1)),
+ billingMeterValue(12_001, 20),
+ ],
+ seqNo: 4,
+ timestamp: new Date(4000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ }
+ assert.ok(
+ Buffer.byteLength(
+ JSON.stringify({ request, seqNo: request.seqNo, timestamp: new Date(0) })
+ ) > Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, request)
+
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.[0].request.meterValue?.map(meterValue => ({
+ timestamp: meterValue.timestamp.getTime(),
+ value: meterValue.sampledValue[0].value,
+ })),
+ [
+ { timestamp: 0, value: 10 },
+ { timestamp: 1, value: 1 },
+ { timestamp: 12_000, value: 1 },
+ { timestamp: 12_001, value: 20 },
+ ]
+ )
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(connectorStatus.transactionEventQueue), 'utf8') <=
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ })
+
+ await it('should retain newest updates and lifecycle events with amortized saturated accounting', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = '00000000-0000-4000-8000-000000000099'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = Array.from(
+ { length: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH },
+ (_, seqNo) => ({
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ },
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ })
+ )
+ const saveSpy = mock.method(mockStation, 'saveTransactionEventQueues')
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest,
+ markOffline: boolean
+ ) => void
+ }
+
+ enqueueTransactionEvent.enqueueTransactionEvent(
+ mockStation,
+ connectorStatus,
+ {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH,
+ timestamp: new Date(Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ },
+ true
+ )
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.some(
+ ({ seqNo }) => seqNo === Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH
+ ),
+ true
+ )
+
+ const stringifySpy = mock.method(JSON, 'stringify')
+ for (
+ let seqNo = Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1;
+ seqNo <= Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 100;
+ seqNo++
+ ) {
+ enqueueTransactionEvent.enqueueTransactionEvent(
+ mockStation,
+ connectorStatus,
+ {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ },
+ true
+ )
+ }
+ assert.ok(stringifySpy.mock.callCount() <= 100)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.some(
+ ({ seqNo }) => seqNo === Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 100
+ ),
+ true
+ )
+ stringifySpy.mock.restore()
+
+ enqueueTransactionEvent.enqueueTransactionEvent(
+ mockStation,
+ connectorStatus,
+ {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 101,
+ timestamp: new Date((Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 101) * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ },
+ true
+ )
+
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.at(-1)?.request.eventType,
+ OCPP20TransactionEventEnumType.Ended
+ )
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(connectorStatus.transactionEventQueue), 'utf8') <=
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue.some(({ seqNo }) => seqNo === 0),
+ true
+ )
+ assert.strictEqual(saveSpy.mock.callCount(), 102)
+ })
+ await it('should keep cached bytes exact across compaction, drain, and enqueue mutations', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const transactionId = '00000000-0000-4000-8000-000000000104'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ) => void
+ }
+ const request = (seqNo: number): OCPP20TransactionEventRequest => ({
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, request(0))
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, request(1))
+ assert.strictEqual(shiftBoundedTransactionEvent(connectorStatus)?.seqNo, 0)
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, request(2))
+
+ const bounded = boundTransactionEventQueue(connectorStatus)
+ assert.strictEqual(
+ bounded.bytes,
+ Buffer.byteLength(JSON.stringify(connectorStatus.transactionEventQueue), 'utf8')
+ )
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ seqNo }) => seqNo),
+ [1, 2]
+ )
+ })
+
+ await it('should preserve a historical transaction public key when byte normalization evicts its event', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const activeTransactionId = '00000000-0000-4000-8000-000000000100'
+ const historicalTransactionId = '00000000-0000-4000-8000-000000000099'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: activeTransactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const signedMeterValue = (publicKey: string): OCPP20MeterValue[] => [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ },
+ ]
+ connectorStatus.transactionEventQueue = Array.from(
+ { length: 4 },
+ (_, seqNo): QueuedTransactionEvent => ({
+ request: {
+ customData: { payload: 'x'.repeat(300_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ ...(seqNo < 2 && {
+ meterValue: signedMeterValue(seqNo === 0 ? 'historical-public-key' : ''),
+ }),
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ transactionInfo: {
+ transactionId: seqNo < 2 ? historicalTransactionId : activeTransactionId,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ },
+ seqNo,
+ timestamp: new Date(seqNo * 1000),
+ })
+ )
+ const enqueueTransactionEvent = OCPP20ServiceUtils as unknown as {
+ enqueueTransactionEvent: (
+ station: MockChargingStation,
+ status: ConnectorStatus,
+ request: OCPP20TransactionEventRequest
+ ) => void
+ }
+
+ enqueueTransactionEvent.enqueueTransactionEvent(mockStation, connectorStatus, {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH,
+ timestamp: new Date(),
+ transactionInfo: { transactionId: activeTransactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ })
+
+ const historicalReplacement = connectorStatus.transactionEventQueue.find(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === historicalTransactionId
+ )
+ assert.strictEqual(
+ historicalReplacement?.request.meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ 'historical-public-key'
+ )
+ })
+
+ await it('should keep a locally started offline transaction active for aligned ticks', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ mockStation.started = true
+ mockStation.wsConnection = null
+
+ await OCPP20ServiceUtils.startTransactionOnConnector(mockStation, 1, 'OFFLINE-TAG')
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ const queue = connectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.deepStrictEqual(
+ queue.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Updated]
+ )
+ assert.deepStrictEqual(
+ queue.map(event => event.seqNo),
+ [0, 1]
+ )
+ })
+
+ await it('should queue the public key only once across offline aligned ticks', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-offline-signing' })
+ mockStation.wsConnection = null
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ const queue = mockStation.getConnectorStatus(1, 1)?.transactionEventQueue
+ assert.strictEqual(queue?.length, 2)
+ const publicKeyCount = queue
+ .flatMap(event => event.request.meterValue ?? [])
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => (sample.signedMeterValue?.publicKey.length ?? 0) > 0).length
+ assert.strictEqual(publicKeyCount, 1)
+ })
+ await it('should queue a clock-aligned event when the connection closes during a sweep', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-race' })
+ let connectionChecks = 0
+ mock.method(mockStation, 'isWebSocketConnectionOpened', () => connectionChecks++ === 0)
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(mockStation.getConnectorStatus(1, 1)?.transactionEventQueue?.length, 1)
+ })
+
+ await it('should never mutates connector energy bookkeeping nor public-key flag on idle ticks', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 54321
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 54321
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.ok(requestHandlerMock.mock.callCount() > 0)
+ // Idle readings are unsigned on the wire (fallback B):
+ const samples = sentPayloads(requestHandlerMock).flatMap(payload =>
+ payload.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ )
+ for (const sampledValue of samples) {
+ assert.strictEqual(
+ (sampledValue as { signedMeterValue?: unknown }).signedMeterValue,
+ undefined
+ )
+ }
+ // ...and never flip the one-time public-key flag consumed by the next
+ // transaction's first signed value:
+ for (const connectorId of [1, 2]) {
+ const status = mockStation.getConnectorStatus(connectorId)
+ assert.ok(status != null)
+ assert.notStrictEqual(status.publicKeySentInTransaction, true)
+ }
+ // Energy register untouched by the emission sweep:
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 54321)
+ })
+
+ await it('should default omitted EVSE 0 electrical locations to Inlet and preserves explicit locations', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ const stationEvse = mockStation.getEvseStatus(0)
+ const stationConnectorStatus = mockStation.getConnectorStatus(0, 0)
+ assert.ok(stationEvse != null)
+ assert.ok(stationConnectorStatus != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ {
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ stationConnectorStatus.energyActiveImportRegisterValue = 7
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payloads = sentPayloads(requestHandlerMock)
+ assert.strictEqual(payloads.length, 3)
+ const stationPayload = payloads.find(payload => payload.evseId === 0)
+ assert.ok(stationPayload != null)
+ const energySamples = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.deepStrictEqual(
+ energySamples.map(sample => [sample.location, sample.value]),
+ [
+ [OCPP20LocationEnumType.Inlet, 7],
+ [OCPP20LocationEnumType.Outlet, 7],
+ ]
+ )
+ assert.strictEqual(stationConnectorStatus.energyActiveImportRegisterValue, 7)
+ })
+
+ await it('should project DC output power to each physical meter side exactly once', async () => {
+ const collectStationPower = async (location: OCPP20LocationEnumType): Promise<number> => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '800',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: `tx-station-power-${location}`,
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const powerSample = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.ok(powerSample != null)
+ return powerSample.value
+ }
+
+ assert.strictEqual(await collectStationPower(OCPP20LocationEnumType.Inlet), 1000)
+ assert.strictEqual(await collectStationPower(OCPP20LocationEnumType.Outlet), 1000)
+ })
+
+ await it('should project a DC station Outlet template from the normalized Inlet aggregate', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null && sourceEvse != null)
+ stationEvse.MeterValues = [
+ {
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '800',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: 'tx-station-outlet-power',
+ })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const powerSample = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.strictEqual(powerSample?.location, OCPP20LocationEnumType.Outlet)
+ assert.strictEqual(powerSample.value, 800)
+ })
+
+ await it('should convert DC Outlet interval energy once before station Inlet aggregation', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ mockStation.stationInfo.customValueLimitationMeterValues = true
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 48_000)
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '800',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-station-energy' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const alignedTimestamp = new Date()
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(
+ alignedTimestamp.getTime() - 60_000
+ )
+ sourceEvse.energyActiveImportRegisterLastUpdatedAt = new Date(
+ alignedTimestamp.getTime() - 60_000
+ )
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, alignedTimestamp)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const stationEnergy = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ assert.strictEqual(stationEnergy?.location, OCPP20LocationEnumType.Inlet)
+ assert.strictEqual(stationEnergy.value, 1000)
+ const sourceEnergy = sentTransactionEvents(requestHandlerMock)
+ .find(({ evse }) => evse?.id === 1)
+ ?.meterValue?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ assert.strictEqual(sourceEnergy?.location, OCPP20LocationEnumType.Outlet)
+ assert.strictEqual(sourceEnergy.value, 800)
+ })
+
+ await it('should not relabel Outlet DC current or voltage as station Inlet values', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ [
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].join(',')
+ )
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].map(measurand => ({
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand,
+ })) as NonNullable<EvseStatus['MeterValues']>
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+ unit: OCPP16MeterValueUnit.WATT,
+ value: '800',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.CURRENT_IMPORT,
+ unit: OCPP16MeterValueUnit.AMP,
+ value: '2',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.VOLTAGE,
+ unit: OCPP16MeterValueUnit.VOLT,
+ value: '400',
+ },
+ ]
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-dc-location' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ assert.deepStrictEqual(
+ stationPayload.meterValue[0].sampledValue.map(({ location, measurand, value }) => [
+ measurand,
+ location,
+ value,
+ ]),
+ [[OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, OCPP20LocationEnumType.Inlet, 1000]]
+ )
+ const evseEvent = sentTransactionEvents(requestHandlerMock).find(({ evse }) => evse?.id === 1)
+ assert.ok(evseEvent != null)
+ assert.deepStrictEqual(
+ evseEvent.meterValue?.[0].sampledValue.map(({ location, measurand, value }) => [
+ measurand,
+ location,
+ value,
+ ]),
+ [
+ [OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, OCPP20LocationEnumType.Outlet, 800],
+ [OCPP20MeasurandEnumType.CURRENT_IMPORT, OCPP20LocationEnumType.Outlet, 2],
+ [OCPP20MeasurandEnumType.VOLTAGE, OCPP20LocationEnumType.Outlet, 400],
+ ]
+ )
+ })
+
+ await it('should not relabel Inlet DC current or voltage as station Outlet values', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.CURRENT_IMPORT},${OCPP20MeasurandEnumType.VOLTAGE}`
+ )
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].map(measurand => ({
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand,
+ phase: MeterValuePhase.L1_N,
+ })) as NonNullable<EvseStatus['MeterValues']>
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.CURRENT_IMPORT,
+ phase: OCPP16MeterValuePhase.L1,
+ unit: OCPP16MeterValueUnit.AMP,
+ value: '2',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP16MeterValueLocation.INLET,
+ measurand: OCPP16MeterValueMeasurand.VOLTAGE,
+ phase: OCPP16MeterValuePhase.L1,
+ unit: OCPP16MeterValueUnit.VOLT,
+ value: '400',
+ },
+ ]
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-dc-reverse-location' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ assert.strictEqual(
+ sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0),
+ undefined
+ )
+ })
+
+ await it('should derive aggregate power from phase-only baseline samples', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, unit: 'W' },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase,
+ unit: 'W',
+ value: '1000',
+ })) as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-phased-power' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const power = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.strictEqual(power?.value, 3000)
+ })
+
+ await it('should not promote an incomplete phase set to aggregate power', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '9000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.L1_N,
+ unit: 'W',
+ value: '1000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-partial-phase' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const power = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.strictEqual(power?.value, 9000)
+ })
+
+ await it('should sample an EVSE-level meter template once across multiple connectors', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit: 'varh',
+ value: '1000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const payload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 1)
+ const reactiveEnergy = payload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(reactiveEnergy?.value, 1000)
+ })
+
+ await it('should average phase currents for a phase-less aggregate sample', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.CURRENT_IMPORT
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT, unit: 'A' },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase,
+ unit: 'A',
+ value: '5',
+ })) as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-current' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const current = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.CURRENT_IMPORT)
+ assert.strictEqual(current?.value, 5)
+ })
+
+ await it('should synthesize a station current aggregate after merging phases from different EVSEs', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 3,
+ evsesCount: 3,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ const stationEvse = mockStation.getEvseStatus(0)
+ assert.ok(stationEvse != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT, unit: 'A' },
+ ] as unknown as EvseStatus['MeterValues']
+ const phaseValues = [
+ [MeterValuePhase.L1_N, 10],
+ [MeterValuePhase.L2_N, 20],
+ [MeterValuePhase.L3_N, 30],
+ ] as const
+ for (const [index, [phase, value]] of phaseValues.entries()) {
+ const evseId = index + 1
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase,
+ unit: 'A',
+ value: value.toString(),
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, evseId, {
+ transactionId: `tx-cross-evse-current-${evseId.toString()}`,
+ })
+ }
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.CURRENT_IMPORT
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const current = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.CURRENT_IMPORT && sample.phase == null
+ )
+ assert.strictEqual(current?.value, 20)
+ })
+
+ await it('should count each physical line once per meter when phase aliases coexist', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ const measurands = [
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ ] as const
+ stationEvse.MeterValues = measurands.map(measurand => ({
+ measurand,
+ })) as EvseStatus['MeterValues']
+ const phaseValues = [
+ [MeterValuePhase.L1, 10],
+ [MeterValuePhase.L1_N, 20],
+ [MeterValuePhase.L2, 30],
+ [MeterValuePhase.L3, 40],
+ ] as const
+ sourceEvse.MeterValues = measurands.flatMap(measurand =>
+ phaseValues.map(([phase, value]) => ({
+ fluctuationPercent: 0,
+ measurand,
+ phase,
+ unit: measurand === OCPP20MeasurandEnumType.CURRENT_IMPORT ? 'A' : 'W',
+ value: value.toString(),
+ }))
+ ) as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-phase-aliases' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(30_000)
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY, measurands.join(','))
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const aggregates = new Map(
+ stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => sample.phase == null)
+ .map(sample => [sample.measurand, sample.value])
+ )
+ assert.strictEqual(aggregates.get(OCPP20MeasurandEnumType.CURRENT_IMPORT), 26.67)
+ assert.strictEqual(aggregates.get(OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT), 80)
+ })
+
+ await it('should combine aggregate and phase-only power from different EVSEs', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const aggregateEvse = mockStation.getEvseStatus(1)
+ const phasedEvse = mockStation.getEvseStatus(2)
+ assert.ok(stationEvse != null)
+ assert.ok(aggregateEvse != null)
+ assert.ok(phasedEvse != null)
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, unit: 'W' },
+ ] as unknown as EvseStatus['MeterValues']
+ aggregateEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '3000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ phasedEvse.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase,
+ unit: 'W',
+ value: '1000',
+ })) as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-aggregate-power' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-phased-power' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const power = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.strictEqual(power?.value, 6000)
+ })
+
+ await it('should preserve customData that distinguishes aggregate sampled values', () => {
+ const { mockStation, requestHandlerMock } = alignedStation
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ for (const evseId of [0, 1]) {
+ const evseStatus = mockStation.getEvseStatus(evseId)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ customData: { vendorId: 'sensor-a' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ customData: { vendorId: 'sensor-b' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '2000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ }
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-custom-data' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ const powerSamples = stationPayload?.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.deepStrictEqual(
+ powerSamples?.map(sample => [sample.customData?.vendorId, sample.value]),
+ [
+ ['sensor-a', 1000],
+ ['sensor-b', 2000],
+ ]
+ )
+ })
+ await it('should aggregate recursively reordered customData as one identity', () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(stationEvse != null)
+ assert.ok(sourceEvse != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ stationEvse.MeterValues = [
+ {
+ customData: { details: { a: 1, b: 2 }, vendorId: 'acme' },
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ sourceEvse.MeterValues = []
+ connector1.MeterValues = [
+ {
+ customData: { details: { a: 1, b: 2 }, vendorId: 'acme' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ connector2.MeterValues = [
+ {
+ customData: (() => {
+ const details: Record<string, number> = {}
+ details.b = 2
+ details.a = 1
+ const customData: Record<string, unknown> = { vendorId: 'acme' }
+ customData.details = details
+ return customData
+ })(),
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '2000',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-custom-order-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-custom-order-2' })
+
+ void OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation)
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const powerSamples = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.strictEqual(powerSamples.length, 1)
+ assert.strictEqual(powerSamples[0].value, 3000)
+ assert.deepStrictEqual(powerSamples[0].customData, {
+ details: { a: 1, b: 2 },
+ vendorId: 'acme',
+ })
+ })
+ })
+
+ await describe('buildClockAlignedConnectorMeterValue (transaction-less builder)', async () => {
+ await it('should build a SAMPLE.CLOCK meter value from a directly identified idle connector', () => {
+ const { mockStation } = createAlignedStation()
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ ''
+ )
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 54321
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 54321
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.ok(meterValue.sampledValue.length > 0)
+ for (const sampledValue of meterValue.sampledValue) {
+ assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.SAMPLE_CLOCK)
+ }
+ const energySample = meterValue.sampledValue.find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.ok(energySample != null)
+ assert.ok(energySample.value > 0)
+ })
+
+ await it('should project an idle DC register snapshot to each configured meter side', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [OCPP20LocationEnumType.Inlet, OCPP20LocationEnumType.Outlet].map(
+ location => ({
+ location,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ })
+ ) as unknown as EvseStatus['MeterValues']
+ connectorStatus.energyActiveImportRegisterValue = 800
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.location, sample.value]),
+ [
+ [OCPP20LocationEnumType.Inlet, 1000],
+ [OCPP20LocationEnumType.Outlet, 800],
+ ]
+ )
+ })
+
+ await it('should advance the EVSE 0 main register when physical energy is committed', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ }
+ const mainConnector = mockStation.getEvseStatus(0)?.connectors.get(0)
+ const sourceEvse = mockStation.getEvseStatus(1)
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(mainConnector != null)
+ assert.ok(sourceEvse != null)
+ assert.ok(connectorStatus != null)
+ sourceEvse.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '800',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '800',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-main-energy' })
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ const measurandsKey = getConfigurationKey(mockStation, ALIGNED_MEASURANDS_KEY)
+ assert.ok(measurandsKey != null)
+ const connectorBefore = connectorStatus.energyActiveImportRegisterValue ?? 0
+ const mainBefore = mainConnector.energyActiveImportRegisterValue ?? 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(
+ Date.now() - 3_600_000
+ )
+ const meterValue = buildMeterValue(
+ mockStation,
+ 'tx-main-energy',
+ 3_600_000,
+ ALIGNED_MEASURANDS_KEY
+ )
+
+ assert.strictEqual(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ (connectorBefore + 800) / 0.8
+ )
+ assert.strictEqual(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 1000
+ )
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, connectorBefore + 800)
+ assert.strictEqual(mainConnector.energyActiveImportRegisterValue, mainBefore + 1000)
+ })
+
+ await it('should emit a physically coherent idle snapshot', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ unit: 'A',
+ value: '16',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.STATE_OF_CHARGE,
+ unit: 'Percent',
+ value: '80',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ [
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.STATE_OF_CHARGE,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ ].join(',')
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ const samplesByMeasurand = new Map(
+ meterValue.sampledValue.map(sample => [sample.measurand, sample] as const)
+ )
+ assert.strictEqual(
+ samplesByMeasurand.get(OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)?.value,
+ 0
+ )
+ assert.strictEqual(samplesByMeasurand.get(OCPP20MeasurandEnumType.CURRENT_IMPORT)?.value, 0)
+ assert.strictEqual(samplesByMeasurand.has(OCPP20MeasurandEnumType.STATE_OF_CHARGE), false)
+ assert.strictEqual(samplesByMeasurand.get(OCPP20MeasurandEnumType.VOLTAGE)?.value, 230)
+ assert.strictEqual(
+ samplesByMeasurand.get(OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER)?.value,
+ 54321
+ )
+ })
+
+ await it('should read the persistent connector register while idle with meteringPerTransaction enabled', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.meteringPerTransaction = true
+ }
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 54321
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 7
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ const energySample = meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.ok(energySample != null)
+ assert.strictEqual(energySample.value, 54321)
+ })
+
+ await it('should use the station cumulative register during a physical transaction', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 1,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ if (mockStation.stationInfo != null) mockStation.stationInfo.meteringPerTransaction = true
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-station-register' })
+ const stationConnectorStatus = mockStation.getConnectorStatus(0, 0)
+ assert.ok(stationConnectorStatus != null)
+ stationConnectorStatus.energyActiveImportRegisterValue = 7777
+ stationConnectorStatus.transactionEnergyActiveImportRegisterValue = 0
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ assert.strictEqual(Number(findEnergySample(stationPayload)?.value), 7777)
+ })
+
+ await it('should keep an EVSE cumulative register aggregated during a transaction', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, {
+ transactionId: '00000000-0000-4000-8000-000000000123',
+ })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const transactionEvent = sentTransactionEvents(requestHandlerMock).find(
+ event => event.transactionInfo.transactionId === '00000000-0000-4000-8000-000000000123'
+ )
+ assert.ok(transactionEvent != null)
+ const energySample = transactionEvent.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER)
+ assert.strictEqual(energySample?.value, 300)
+ })
+
+ await it('should serialize the final shared EVSE register for every active transaction', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.customValueLimitationMeterValues = false
+ mockStation.stationInfo.meteringPerTransaction = false
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-energy-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-shared-energy-2' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const events = sentTransactionEvents(requestHandlerMock).filter(event =>
+ event.transactionInfo.transactionId.startsWith('tx-shared-energy-')
+ )
+ assert.strictEqual(events.length, 2)
+ for (const event of events) {
+ assert.strictEqual(
+ event.meterValue?.[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 320
+ )
+ }
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 320
+ )
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 60_000
+ )
+
+ connector1.transactionEnding = true
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(90_000))
+
+ const replacementOwnerEvent = sentTransactionEvents(requestHandlerMock).at(-1)
+ assert.ok(replacementOwnerEvent != null)
+ assert.strictEqual(replacementOwnerEvent.transactionInfo.transactionId, 'tx-shared-energy-2')
+ assert.strictEqual(
+ replacementOwnerEvent.meterValue?.[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 330
+ )
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 330
+ )
+ })
+
+ await it('should use the EVSE clock when a newer transaction owns a shared observation', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-new-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-existing-peer' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(90_000)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(60_000)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(60_000)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-new-owner',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(120_000),
+ 60_000
+ )
+
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 360
+ )
+ assert.strictEqual(connector1.transactionEnergyActiveImportRegisterValue, 30)
+ assert.strictEqual(connector2.transactionEnergyActiveImportRegisterValue, 60)
+ })
+
+ await it('should not integrate an idle gap before a new shared transaction', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-after-idle' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionStart = new Date(90_000)
+ delete connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(60_000)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-after-idle',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(120_000),
+ 60_000
+ )
+
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 130)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 30)
+ })
+
+ await it('should keep shared EVSE transaction registers monotonic across periodic builders', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-periodic-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-shared-periodic-2' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const timestamp = new Date(60_000)
+
+ const firstObservation = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ 'tx-shared-periodic-2',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ timestamp
+ )
+ const matchingObservation = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-shared-periodic-1',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ timestamp
+ )
+ const repeatedObservation = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ 'tx-shared-periodic-2',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ timestamp
+ )
+
+ assert.strictEqual(
+ firstObservation.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 320
+ )
+ assert.strictEqual(
+ matchingObservation.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 320
+ )
+ assert.strictEqual(
+ repeatedObservation.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )?.value,
+ 320
+ )
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 320
+ )
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ 60_000
+ )
+ })
+
+ await it('should settle elapsed energy without consuming signing delivery state', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ upsertConfigurationKey(mockStation, SIGN_READINGS_KEY, 'true')
+ upsertConfigurationKey(mockStation, SIGN_UPDATED_READINGS_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ PUBLIC_KEY_MODE_KEY,
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ upsertConfigurationKey(mockStation, FISCAL_PUBLIC_KEY, TEST_PUBLIC_KEY_HEX)
+ upsertConfigurationKey(
+ mockStation,
+ FISCAL_SIGNING_METHOD,
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-settlement' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connectorStatus.publicKeySentInTransaction = false
+
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-settlement',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(30_000),
+ 60_000,
+ true
+ )
+
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 30)
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 30
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ assert.ok(meterValue.sampledValue.every(sample => sample.signedMeterValue == null))
+
+ const deliveredMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-settlement',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+ assert.strictEqual(
+ deliveredMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 60
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 0
+ )
+ })
+
+ await it('should retain the first legacy interval omitted by a register-only sample', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '60',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-legacy-carry' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-legacy-carry',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(30_000),
+ 60_000
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 30
+ )
+
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const deliveredMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-legacy-carry',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+ assert.strictEqual(
+ deliveredMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 60
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 0
+ )
+ })
+
+ await it('should derive missing interval templates per sampled-value identity', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ customData: { channel: 'explicit', vendorId: 'test' },
+ fluctuationPercent: 0,
+ format: 'Raw',
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '60',
+ },
+ {
+ customData: { channel: 'fallback', vendorId: 'test' },
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'kWh',
+ value: '60',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-interval-identities' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connectorStatus.transactionStart = new Date(0)
+
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-interval-identities',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+
+ const intervalSamples = meterValue.sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(intervalSamples.map(sample => sample.customData?.channel).sort(), [
+ 'explicit',
+ 'fallback',
+ ])
+ assert.deepStrictEqual(intervalSamples.map(sample => sample.unitOfMeasure?.unit).sort(), [
+ 'Wh',
+ 'kWh',
+ ])
+
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-interval-identities',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-interval-identities',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(session.transactionId, session)
+ const coherentMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(120_000),
+ 60_000
+ )
+ assert.deepStrictEqual(
+ coherentMeterValue.sampledValue
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .map(sample => sample.customData?.channel)
+ .sort(),
+ ['explicit', 'fallback']
+ )
+ })
+
+ await it('should match interval fallbacks by the emitted reading context', () => {
+ const buildIntervalCount = (
+ context: OCPP20ReadingContextEnumType | undefined,
+ coherent: boolean
+ ): number => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_BEGIN,
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const transactionId = coherent ? 'tx-context-coherent' : 'tx-context-legacy'
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionStart = new Date(0)
+ if (coherent) {
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: transactionId,
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(transactionId, session)
+ }
+ return OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ context,
+ new Date(60_000),
+ 60_000
+ ).sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ).length
+ }
+
+ assert.strictEqual(buildIntervalCount(OCPP20ReadingContextEnumType.SAMPLE_PERIODIC, false), 1)
+ assert.strictEqual(buildIntervalCount(undefined, false), 2)
+ assert.strictEqual(buildIntervalCount(OCPP20ReadingContextEnumType.SAMPLE_PERIODIC, true), 1)
+ assert.strictEqual(buildIntervalCount(undefined, true), 2)
+ })
+
+ await it('should carry a coherent settlement interval into the next delivered sample', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-settlement' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-coherent-settlement',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent-settlement',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(session.transactionId, session)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(30_000),
+ 60_000,
+ true
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 50
+ )
+
+ const deliveredMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+ assert.strictEqual(
+ deliveredMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 100
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 0
+ )
+ })
+
+ await it('should retain the first coherent interval omitted by a register-only sample', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-carry' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-coherent-carry',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent-carry',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(session.transactionId, session)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(30_000),
+ 60_000
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 50
+ )
+
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ const deliveredMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+ assert.strictEqual(
+ deliveredMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 100
+ )
+ assert.strictEqual(
+ getPendingTransactionInterval(connectorStatus, TX_UPDATED_MEASURANDS_KEY),
+ 0
+ )
+ })
+
+ await it('should account and emits phase-only transaction interval templates', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unit: 'Wh',
+ value: '90',
+ })) as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-phase-interval' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-phase-interval',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+
+ const intervals = meterValue.sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervals.map(sample => [sample.phase, sample.value]),
+ [
+ [MeterValuePhase.L1_N, 30],
+ [MeterValuePhase.L2_N, 30],
+ [MeterValuePhase.L3_N, 30],
+ ]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 90)
+ })
+
+ await it('should let the first legacy observation own one shared EVSE register update', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-legacy-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-legacy-non-owner' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ 'tx-legacy-non-owner',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000)
+ )
+
+ assert.strictEqual(connector1.energyActiveImportRegisterValue, 100)
+ assert.ok((connector2.energyActiveImportRegisterValue ?? 0) > 200)
+ })
+
+ await it('should sum coherent intervals once while deduplicating legacy shared-meter copies', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 3,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-mixed-coherent' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-mixed-legacy-owner' })
+ setupConnectorWithTransaction(mockStation, 3, { transactionId: 'tx-mixed-legacy-peer' })
+ const coherentSession: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-mixed-coherent',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-mixed-coherent',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(coherentSession.transactionId, coherentSession)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ const connector3 = mockStation.getConnectorStatus(3, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ assert.ok(connector3 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector3.energyActiveImportRegisterValue = 300
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector3.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ const physicalRegisterBefore =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) +
+ (connector3.energyActiveImportRegisterValue ?? 0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(evseStatus.energyActiveImportRegisterLastUpdatedAt.getTime(), 60_000)
+ assert.ok(coherentSession.socPercent > 30)
+ const sharedRegisters = sentTransactionEvents(requestHandlerMock).flatMap(event => {
+ return (
+ event.meterValue?.flatMap(meterValue => {
+ return meterValue.sampledValue
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ .map(sampledValue => sampledValue.value)
+ }) ?? []
+ )
+ })
+ const transactionInterval = (transactionId: string): number | undefined =>
+ sentTransactionEvents(requestHandlerMock)
+ .find(event => event.transactionInfo.transactionId === transactionId)
+ ?.meterValue?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(transactionInterval(coherentSession.transactionId.toString()), 100)
+ assert.strictEqual(transactionInterval('tx-mixed-legacy-owner'), 20)
+ assert.strictEqual(transactionInterval('tx-mixed-legacy-peer'), 20)
+ assert.ok(sharedRegisters.length >= 3)
+ assert.ok(sharedRegisters.every(register => register === sharedRegisters[0]))
+ const physicalRegisterAfterFirst =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) +
+ (connector3.energyActiveImportRegisterValue ?? 0)
+ assert.strictEqual(physicalRegisterAfterFirst - physicalRegisterBefore, 120)
+ const stationInterval = (): number | undefined =>
+ sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(stationInterval(), 120)
+
+ requestHandlerMock.mock.resetCalls()
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ assert.strictEqual(stationInterval(), 120)
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) +
+ (connector3.energyActiveImportRegisterValue ?? 0) -
+ physicalRegisterAfterFirst,
+ 120
+ )
+ })
+
+ await it('should defer a legacy peer interval precomputed by a coherent owner', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-legacy-peer' })
+ const coherentSession: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-coherent-owner',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-coherent-owner',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(coherentSession.transactionId, coherentSession)
+ const owner = mockStation.getConnectorStatus(1, 1)
+ const peer = mockStation.getConnectorStatus(2, 1)
+ assert.ok(owner != null)
+ assert.ok(peer != null)
+ owner.energyActiveImportRegisterValue = 100
+ peer.energyActiveImportRegisterValue = 200
+ owner.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ peer.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ peer.transactionEnergyActiveImportRegisterValue = 5
+ peer.transactionEnergyActiveImportIntervalBaselines = {
+ [TX_UPDATED_MEASURANDS_KEY]: 0,
+ }
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ coherentSession.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+
+ assert.strictEqual(peer.transactionEnergyActiveImportRegisterValue, 15)
+ assert.strictEqual(getPendingTransactionInterval(peer, TX_UPDATED_MEASURANDS_KEY), 15)
+ const peerMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ 'tx-legacy-peer',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000),
+ 60_000
+ )
+ assert.strictEqual(
+ peerMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 15
+ )
+ assert.strictEqual(getPendingTransactionInterval(peer, TX_UPDATED_MEASURANDS_KEY), 0)
+ })
+
+ await it('should commit a targeted legacy terminal observation in a mixed shared EVSE', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 3, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-mixed-coherent' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-mixed-legacy-owner' })
+ setupConnectorWithTransaction(mockStation, 3, { transactionId: 'tx-mixed-legacy-target' })
+ const coherentSession: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'tx-mixed-coherent',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 6000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-mixed-coherent',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession(coherentSession.transactionId, coherentSession)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ const connector3 = mockStation.getConnectorStatus(3, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ assert.ok(connector3 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 100
+ connector3.energyActiveImportRegisterValue = 100
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector3.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(30_000)
+
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 3,
+ 1,
+ 'tx-mixed-legacy-target',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.TRANSACTION_END,
+ new Date(60_000),
+ 60_000
+ )
+
+ const register = meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.deepStrictEqual(
+ [
+ register?.value,
+ connector1.energyActiveImportRegisterValue,
+ connector2.energyActiveImportRegisterValue,
+ connector3.energyActiveImportRegisterValue,
+ ],
+ [410, 100, 100, 210]
+ )
+ assert.strictEqual(connector2.transactionEnergyActiveImportRegisterValue, 10)
+ assert.strictEqual(connector3.transactionEnergyActiveImportRegisterValue, 5)
+ assert.ok(coherentSession.socPercent > 30)
+ })
+
+ await it('should aggregate every per-transaction legacy interval at the station meter', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ const intervalTemplate = {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ }
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL, unit: 'Wh' },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [intervalTemplate] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-interval-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-interval-2' })
+ for (const connectorId of [1, 2]) {
+ const connectorStatus = mockStation.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ }
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const stationInterval = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ const transactionIntervals = sentTransactionEvents(requestHandlerMock).map(event => {
+ return (
+ event.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value ?? 0
+ )
+ })
+ assert.strictEqual(transactionIntervals.length, 2)
+ assert.strictEqual(
+ stationInterval,
+ transactionIntervals.reduce((total, intervalValue) => total + intervalValue, 0)
+ )
+ })
+
+ await it('should advance every legacy transaction while committing one aligned shared register', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 5,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-legacy-aligned-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-legacy-aligned-2' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterValue,
+ connector1.transactionEnergyActiveImportRegisterValue
+ )
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(120_000))
+
+ assert.ok((connector1.transactionEnergyActiveImportRegisterValue ?? 0) > 0)
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterValue,
+ connector1.transactionEnergyActiveImportRegisterValue
+ )
+ assert.ok(
+ Math.abs(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) -
+ (300 + (connector1.transactionEnergyActiveImportRegisterValue ?? 0))
+ ) < 1e-9
+ )
+ })
+
+ await it('should prorate a shared owner interval to its active transaction window', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-late-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-early-peer' })
+ const owner = mockStation.getConnectorStatus(1, 1)
+ const peer = mockStation.getConnectorStatus(2, 1)
+ assert.ok(owner != null)
+ assert.ok(peer != null)
+ owner.transactionStart = new Date(30_000)
+ owner.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(30_000)
+ peer.transactionStart = new Date(0)
+ peer.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ const meterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-late-owner',
+ 60_000,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ new Date(60_000),
+ 60_000
+ )
+
+ assert.strictEqual(
+ meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value,
+ 5
+ )
+ assert.strictEqual(owner.transactionEnergyActiveImportRegisterValue, 5)
+ })
+
+ await it('should use the full shared observation in the station interval aggregate', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ mockStation.stationInfo.meteringPerTransaction = false
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = ['sensor-a', 'sensor-b'].map(vendorId => ({
+ customData: { vendorId },
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ })) as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ...['sensor-a', 'sensor-b'].map(vendorId => ({
+ customData: { vendorId },
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ })),
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-late-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-full-peer' })
+ const owner = mockStation.getConnectorStatus(1, 1)
+ const peer = mockStation.getConnectorStatus(2, 1)
+ assert.ok(owner != null)
+ assert.ok(peer != null)
+ owner.transactionStart = new Date(30_000)
+ owner.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(30_000)
+ peer.transactionStart = new Date(0)
+ peer.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const transactionIntervals = sentTransactionEvents(requestHandlerMock).map(
+ event =>
+ event.meterValue?.[0].sampledValue.find(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL &&
+ sample.customData?.vendorId === 'sensor-a'
+ )?.value
+ )
+ const stationIntervals = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .map(sample => [sample.customData?.vendorId, sample.value])
+ assert.deepStrictEqual(transactionIntervals, [12.5, 25])
+ assert.deepStrictEqual(stationIntervals, [
+ ['sensor-a', 25],
+ ['sensor-b', 25],
+ ])
+ })
+
+ await it('should preserve interval energy in every aligned shared transaction payload', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ mockStation.stationInfo.customValueLimitationMeterValues = true
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedInterval
+ ),
+ '60'
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-interval-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-shared-interval-2' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ evseStatus.energyActiveImportRegisterLastUpdatedAt = new Date(0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const events = sentTransactionEvents(requestHandlerMock)
+ const intervalValues = events.map(event => {
+ return event.meterValue?.[0].sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ })
+ assert.ok(
+ events.every(event =>
+ event.meterValue?.[0].sampledValue.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ )
+ )
+ const intervalValue = intervalValues[0]
+ assert.ok(intervalValue != null && intervalValue > 0)
+ assert.deepStrictEqual(intervalValues, [intervalValue, intervalValue])
+ assert.deepStrictEqual(
+ [
+ connector1.transactionEnergyActiveImportRegisterValue,
+ connector2.transactionEnergyActiveImportRegisterValue,
+ ],
+ [intervalValue, intervalValue]
+ )
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 300 + intervalValue
+ )
+ })
+
+ await it('should not re-integrate a shared legacy interval after a paused peer terminal sample', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-owner' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-shared-ending' })
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+ connector1.transactionEnergyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ connector1.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connector1.transactionRestored = true
+ connector2.transactionRestored = true
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ 'tx-shared-ending',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.TRANSACTION_END,
+ new Date(30_000)
+ )
+ const firstSharedObservationWh = connector1.transactionEnergyActiveImportRegisterValue ?? 0
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 300 + firstSharedObservationWh
+ )
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterValue,
+ firstSharedObservationWh
+ )
+ connector2.transactionStarted = false
+ delete connector2.transactionId
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ 'tx-shared-owner',
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000)
+ )
+
+ assert.strictEqual(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0),
+ 300 + (connector1.transactionEnergyActiveImportRegisterValue ?? 0)
+ )
+ assert.strictEqual(
+ connector2.transactionEnergyActiveImportRegisterValue,
+ firstSharedObservationWh
+ )
+ })
+
+ await it('should sum first aligned intervals for all coherent sessions on a shared EVSE', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ mock.method(mockStation, 'getConnectorMaximumAvailablePower', () => 3600)
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ const sharedMeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ stationEvse.MeterValues = structuredClone(sharedMeterValues)
+ evseStatus.MeterValues = sharedMeterValues
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(mockStation, SEND_DURING_IDLE_KEY, 'false')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-first-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-coherent-first-2' })
+ const createSession = (connectorId: number, transactionId: string): CoherentSession => ({
+ connectorId,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: transactionId,
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 3600,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ })
+ const session1 = createSession(1, 'tx-coherent-first-1')
+ const session2 = createSession(2, 'tx-coherent-first-2')
+ mockStation.__injectCoherentSession(session1.transactionId, session1)
+ mockStation.__injectCoherentSession(session2.transactionId, session2)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 0
+ connector1.transactionEnergyActiveImportRegisterValue = 0
+ connector2.energyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ delete connector1.energyActiveImportIntervalBaselines
+ delete connector1.transactionEnergyActiveImportIntervalBaselines
+ delete connector2.energyActiveImportIntervalBaselines
+ delete connector2.transactionEnergyActiveImportIntervalBaselines
+ delete evseStatus.energyActiveImportIntervalBaseline
+ const physicalRegisterBefore =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0)
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const stationPayload = sentPayloads(requestHandlerMock).find(payload => payload.evseId === 0)
+ assert.ok(stationPayload != null)
+ const stationInterval = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.strictEqual(stationInterval?.value, 120)
+ const physicalRegisterAfter =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0)
+ assert.strictEqual(physicalRegisterAfter - physicalRegisterBefore, 120)
+ })
+
+ await it('should preserve peer energy when the shared coherent owner is full', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ stationEvse.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL},${OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-aligned-shared-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-aligned-shared-2' })
+ const createSession = (connectorId: number, transactionId: string): CoherentSession => ({
+ connectorId,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: transactionId,
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ })
+ const session1 = createSession(1, 'tx-aligned-shared-1')
+ const session2 = createSession(2, 'tx-aligned-shared-2')
+ session1.socPercent = 100
+ mockStation.__injectCoherentSession(session1.transactionId, session1)
+ mockStation.__injectCoherentSession(session2.transactionId, session2)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ assert.strictEqual(session1.socPercent, 100)
+ assert.ok(session2.socPercent > 30)
+ assert.ok((connector1.energyActiveImportRegisterValue ?? 0) > 100)
+ assert.strictEqual(connector2.energyActiveImportRegisterValue, 200)
+ assert.ok(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) >
+ 300
+ )
+ const stationRegister = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER)
+ assert.ok(stationRegister != null && stationRegister.value > 0)
+ const stationInterval = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL)
+ assert.ok(stationInterval != null && stationInterval.value > 0)
+ const stationPower = sentPayloads(requestHandlerMock)
+ .find(({ evseId }) => evseId === 0)
+ ?.meterValue.flatMap(meterValue => meterValue.sampledValue)
+ .find(sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT)
+ assert.ok(stationPower != null && stationPower.value > 0)
+ })
+
+ await it('should advance every coherent session while committing one shared EVSE register', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '10',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ `${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER},${OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL}`
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-coherent-shared-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-coherent-shared-2' })
+ const createSession = (connectorId: number, transactionId: string): CoherentSession => ({
+ connectorId,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: transactionId,
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ })
+ const session1 = createSession(1, 'tx-coherent-shared-1')
+ const session2 = createSession(2, 'tx-coherent-shared-2')
+ mockStation.__injectCoherentSession(session1.transactionId, session1)
+ mockStation.__injectCoherentSession(session2.transactionId, session2)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.energyActiveImportRegisterValue = 100
+ connector2.energyActiveImportRegisterValue = 200
+
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ session2.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000)
+ )
+ assert.ok(session2.socPercent > 30)
+ const sharedRegisterAfterNonOwnerTick =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0)
+ assert.ok(sharedRegisterAfterNonOwnerTick > 660)
+ assert.ok(sharedRegisterAfterNonOwnerTick < 670)
+ const peerMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session1.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(60_000)
+ )
+ assert.ok(
+ (peerMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value ?? 0) > 0
+ )
+ const sharedRegisterAfterFirstTick =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0)
+ assert.ok(sharedRegisterAfterFirstTick > 660)
+ assert.ok(sharedRegisterAfterFirstTick < 670)
+
+ const connector1SocBeforeTerminal = session1.socPercent
+ connector2.transactionEnding = true
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 2,
+ 1,
+ session2.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.TRANSACTION_END,
+ new Date(120_000)
+ )
+ const sharedRegisterAfterNonOwnerEnd =
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0)
+ assert.ok(sharedRegisterAfterNonOwnerEnd > 1025)
+ assert.ok(sharedRegisterAfterNonOwnerEnd < 1040)
+ assert.ok(session1.socPercent > connector1SocBeforeTerminal)
+ assert.ok((connector1.transactionEnergyActiveImportRegisterValue ?? 0) > 0)
+ assert.ok((connector2.transactionEnergyActiveImportRegisterValue ?? 0) > 0)
+
+ connector2.transactionStarted = false
+ delete connector2.transactionId
+ mockStation.destroyCoherentSession(session2.transactionId)
+ const connector1SocBeforeOwnership = session1.socPercent
+ OCPP20ServiceUtils.buildTransactionMeterValue(
+ mockStation,
+ 1,
+ 1,
+ session1.transactionId,
+ 60_000,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(180_000)
+ )
+ assert.ok(session1.socPercent > connector1SocBeforeOwnership)
+ assert.ok(
+ (connector1.energyActiveImportRegisterValue ?? 0) +
+ (connector2.energyActiveImportRegisterValue ?? 0) >
+ sharedRegisterAfterNonOwnerEnd
+ )
+ })
+
+ await it('should count a shared EVSE register once in the station aggregate', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 1,
+ })
+ upsertConfigurationKey(mockStation, ALIGNED_DATA_INTERVAL_KEY, '60')
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ const stationEvse = mockStation.getEvseStatus(0)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(stationEvse != null)
+ assert.ok(evseStatus != null)
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = false
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER,
+ unit: 'varh',
+ value: '1000',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ stationEvse.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER, unit: 'varh' },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-shared-register-1' })
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-shared-register-2' })
+
+ await OCPP20ServiceUtils.emitClockAlignedMeterValues(mockStation, new Date(60_000))
+
+ const transactionRegisterValues = sentTransactionEvents(requestHandlerMock).map(
+ event =>
+ event.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )?.value
+ )
+ assert.deepStrictEqual(transactionRegisterValues, [1000, 1000])
+ const stationPayload = sentPayloads(requestHandlerMock).find(({ evseId }) => evseId === 0)
+ assert.ok(stationPayload != null)
+ const stationRegister = stationPayload.meterValue
+ .flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_REACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(stationRegister?.value, 1000)
+ })
+
+ await it('should emit every configured location variant for an aligned measurand', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ {
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => sample.location),
+ [OCPP20LocationEnumType.Inlet, OCPP20LocationEnumType.Outlet]
+ )
+ })
+
+ await it('should project an active DC inlet interval exactly once', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.conversionEfficiency = 0.8
+ mockStation.stationInfo.currentOutType = CurrentType.DC
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ value: '80',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-dc-inlet-interval' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ {
+ advanceEnergy: true,
+ connectorId: 1,
+ energyElapsedInterval: 60_000,
+ energyNominalInterval: 60_000,
+ evseId: 1,
+ timestamp: new Date(60_000),
+ transactionId: 'tx-dc-inlet-interval',
+ },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.strictEqual(meterValue.sampledValue[0]?.value, 100)
+ })
+
+ await it('should honor each location variant configured value', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ mock.method(mockStation, 'getNumberOfPhases', () => 1)
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '180',
+ },
+ {
+ fluctuationPercent: 0,
+ location: OCPP20LocationEnumType.Outlet,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '240',
+ },
+ {
+ location: OCPP20LocationEnumType.Body,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ },
+ { measurand: OCPP20MeasurandEnumType.VOLTAGE },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(mockStation, measurandsKey, OCPP20MeasurandEnumType.VOLTAGE)
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ const variants = meterValue.sampledValue.map(
+ sample => [sample.location, sample.value] as const
+ )
+ assert.deepStrictEqual(variants.slice(0, 2), [
+ [OCPP20LocationEnumType.Inlet, 180],
+ [OCPP20LocationEnumType.Outlet, 240],
+ ])
+ assert.deepStrictEqual(
+ variants.slice(2).map(([location]) => location),
+ [OCPP20LocationEnumType.Body, OCPP20LocationEnumType.Outlet]
+ )
+ assert.ok(variants.slice(2).every(([, value]) => value > 200 && value < 260))
+ })
+
+ await it('should emit accepted fixed-value aligned measurands', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.FREQUENCY,
+ unit: 'Hz',
+ value: '50',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(mockStation, measurandsKey, OCPP20MeasurandEnumType.FREQUENCY)
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.measurand, sample.value]),
+ [[OCPP20MeasurandEnumType.FREQUENCY, 50]]
+ )
+ })
+
+ await it('should distribute a station aggregate interval across phase-only templates', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ const phaseTemplates = [MeterValuePhase.L1_N, MeterValuePhase.L2_N, MeterValuePhase.L3_N].map(
+ phase => ({
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unit: 'Wh',
+ })
+ ) as unknown as EvseStatus['MeterValues']
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ {
+ connectorId: 0,
+ evseId: 0,
+ sampledValueBaseline: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: 'Wh' },
+ value: 90,
+ },
+ ],
+ sampledValueTemplates: phaseTemplates,
+ timestamp: new Date(60_000),
+ },
+ 60_000,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.phase, sample.value]),
+ [
+ [MeterValuePhase.L1_N, 30],
+ [MeterValuePhase.L2_N, 30],
+ [MeterValuePhase.L3_N, 30],
+ ]
+ )
+ })
+
+ await it('should match equivalent line-phase labels before interval aggregation', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ const sourcePhases = [MeterValuePhase.L1, MeterValuePhase.L2, MeterValuePhase.L3]
+ const targetPhases = [MeterValuePhase.L1_N, MeterValuePhase.L2_N, MeterValuePhase.L3_N]
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ {
+ connectorId: 0,
+ evseId: 0,
+ sampledValueBaseline: sourcePhases.map((phase, index) => ({
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unitOfMeasure: { unit: 'Wh' },
+ value: (index + 1) * 10,
+ })),
+ sampledValueTemplates: targetPhases.map(phase => ({
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unit: 'Wh',
+ })) as unknown as EvseStatus['MeterValues'],
+ timestamp: new Date(60_000),
+ },
+ 60_000,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.phase, sample.value]),
+ [
+ [MeterValuePhase.L1_N, 10],
+ [MeterValuePhase.L2_N, 20],
+ [MeterValuePhase.L3_N, 30],
+ ]
+ )
+ })
+
+ await it('should emit zero phase-only interval samples for an idle snapshot', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unit: 'Wh',
+ value: '90',
+ })) as unknown as EvseStatus['MeterValues']
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.phase, sample.value]),
+ [
+ [MeterValuePhase.L1_N, 0],
+ [MeterValuePhase.L2_N, 0],
+ [MeterValuePhase.L3_N, 0],
+ ]
+ )
+ })
+
+ await it('should account phase-only interval templates in an advancing aligned snapshot', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ assert.ok(mockStation.stationInfo != null)
+ mockStation.stationInfo.meteringPerTransaction = true
+ mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ ].map(phase => ({
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase,
+ unit: 'Wh',
+ value: '90',
+ })) as unknown as EvseStatus['MeterValues']
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-aligned-phase-interval' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ {
+ advanceEnergy: true,
+ connectorId: 1,
+ energyElapsedInterval: 60_000,
+ energyNominalInterval: 60_000,
+ evseId: 1,
+ timestamp: new Date(60_000),
+ transactionId: 'tx-aligned-phase-interval',
+ },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ const intervals = meterValue.sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervals.map(sample => [sample.phase, sample.value]),
+ [
+ [MeterValuePhase.L1_N, 30],
+ [MeterValuePhase.L2_N, 30],
+ [MeterValuePhase.L3_N, 30],
+ ]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 90)
+ })
+
+ await it('should emit phase-only current and power templates for an active snapshot', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.numberOfPhases = 3
+ }
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.L1,
+ unit: 'A',
+ value: '5',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.L1_N,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-phase-only' })
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ [OCPP20MeasurandEnumType.CURRENT_IMPORT, OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT].join(
+ ','
+ )
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1, transactionId: 'tx-phase-only' },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.measurand, sample.phase, sample.value]),
+ [
+ [OCPP20MeasurandEnumType.CURRENT_IMPORT, MeterValuePhase.L1, 5],
+ [OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, MeterValuePhase.L1_N, 1000],
+ ]
+ )
+ })
+
+ await it('should suppress phased register templates when RegisterValuesWithoutPhases=true', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = ['L1-N', 'L2-N', 'L3-N'].map(phase => ({
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase,
+ unit: 'Wh',
+ })) as unknown as EvseStatus['MeterValues']
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'true'
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.strictEqual(meterValue.sampledValue.length, 1)
+ assert.strictEqual(meterValue.sampledValue[0].phase, undefined)
+ assert.strictEqual(meterValue.sampledValue[0].value, 54321)
+ })
+
+ await it('should keep distinct customData register families when phases are suppressed', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = ['sensor-a', 'sensor-b'].flatMap(vendorId =>
+ ['L1-N', 'L2-N', 'L3-N'].map(phase => ({
+ customData: { vendorId },
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase,
+ unit: 'Wh',
+ }))
+ ) as unknown as EvseStatus['MeterValues']
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'true'
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.customData?.vendorId, sample.phase]),
+ [
+ ['sensor-a', undefined],
+ ['sensor-b', undefined],
+ ]
+ )
+ })
+
+ await it('should use connector-local templates when EVSE templates are empty', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = []
+ const connector1 = mockStation.getConnectorStatus(1)
+ const connector2 = mockStation.getConnectorStatus(2)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ connector1.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '2000',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-2' })
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 2, evseId: 1, transactionId: 'tx-2' },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.ok(meterValue.sampledValue[0].value > 1900)
+ assert.ok(meterValue.sampledValue[0].value < 2100)
+ })
+
+ await it('should use the energy-owning connector template independent of connector order', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 1 })
+ const evseStatus = mockStation.getEvseStatus(1)
+ const connector1 = mockStation.getConnectorStatus(1, 1)
+ const connector2 = mockStation.getConnectorStatus(2, 1)
+ assert.ok(evseStatus != null)
+ assert.ok(connector1 != null)
+ assert.ok(connector2 != null)
+ evseStatus.MeterValues = []
+ connector1.MeterValues = [
+ {
+ customData: { vendorId: 'connector-1' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '100',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ customData: { vendorId: 'connector-2' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '200',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ setupConnectorWithTransaction(mockStation, 2, { transactionId: 'tx-2' })
+ upsertConfigurationKey(
+ mockStation,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ const baselineEnergy = 54_322
+ const previousEnergyUpdate = new Date('2026-01-01T00:00:00.000Z')
+ const timestamp = new Date('2026-01-01T00:01:00.000Z')
+ const connectorsById = new Map([
+ [1, connector1],
+ [2, connector2],
+ ])
+
+ for (const connectorOrder of [
+ [1, 2],
+ [2, 1],
+ ]) {
+ evseStatus.connectors.clear()
+ for (const connectorId of connectorOrder) {
+ const connectorStatus = connectorsById.get(connectorId)
+ assert.ok(connectorStatus != null)
+ evseStatus.connectors.set(connectorId, connectorStatus)
+ }
+ connector2.energyActiveImportRegisterValue = baselineEnergy
+ connector2.transactionEnergyActiveImportRegisterValue = 0
+ connector2.transactionEnergyActiveImportRegisterLastUpdatedAt = previousEnergyUpdate
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ {
+ advanceEnergy: true,
+ connectorId: 2,
+ evseId: 1,
+ timestamp,
+ transactionId: 'tx-2',
+ },
+ 60_000,
+ ALIGNED_MEASURANDS_KEY,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ const energySample = meterValue.sampledValue.find(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+
+ assert.strictEqual(energySample?.customData?.vendorId, 'connector-2')
+ assert.strictEqual(energySample.value, baselineEnergy + 200)
+ }
+ })
+
+ await it('should keep duplicate connector ids scoped to their EVSE', async () => {
+ const { mockStation, requestHandlerMock } = createAlignedStation({
+ connectorsCount: 2,
+ evsesCount: 2,
+ })
+ const evse1 = mockStation.getEvseStatus(1)
+ const evse2 = mockStation.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const connector1Entry = [...evse1.connectors.entries()][0]
+ const connector2Entry = [...evse2.connectors.entries()][0]
+ const connector1 = connector1Entry[1]
+ const connector2 = connector2Entry[1]
+ connector2.transactionStarted = true
+ connector2.transactionId = 'tx-evse-2'
+ evse1.connectors.clear()
+ evse2.connectors.clear()
+ evse1.connectors.set(1, connector1)
+ evse2.connectors.set(1, connector2)
+ evse1.MeterValues = []
+ evse2.MeterValues = []
+ connector1.energyActiveImportRegisterValue = 11111
+ connector2.energyActiveImportRegisterValue = 22222
+ connector1.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '1000',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '210',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L1_N,
+ unit: 'V',
+ value: '211',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ connector2.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ unit: 'W',
+ value: '2000',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L1_N,
+ unit: 'V',
+ value: '240',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ [
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].join(',')
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 2, transactionId: 'tx-evse-2' },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ const powerSample = meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ const energySample = meterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.ok(powerSample != null)
+ assert.ok(powerSample.value > 1900 && powerSample.value < 2100)
+ assert.strictEqual(energySample?.value, 22222)
+ const phaseVoltageSample = meterValue.sampledValue.find(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.VOLTAGE &&
+ sample.phase === MeterValuePhase.L1_N
+ )
+ assert.strictEqual(phaseVoltageSample?.value, 240)
+ if (mockStation.stationInfo != null) mockStation.stationInfo.meteringPerTransaction = true
+ connector1.transactionEnergyActiveImportRegisterValue = 111
+ connector2.transactionEnergyActiveImportRegisterValue = 22222
+ const transactionalMeterValue = buildMeterValue(
+ mockStation,
+ 'tx-evse-2',
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ )
+ const transactionalEnergy = transactionalMeterValue.sampledValue.find(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.ok(Number(transactionalEnergy?.value) >= 22222)
+ assert.strictEqual(connector1.transactionEnergyActiveImportRegisterValue, 111)
+ assert.strictEqual(connector1.energyActiveImportRegisterValue, 11111)
+ mock.method(mockStation, 'isWebSocketConnectionOpened', () => false)
+ requestHandlerMock.mock.resetCalls()
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ 1,
+ 'tx-evse-2',
+ { evseId: 2, meterValue: [meterValue] }
+ )
+ assert.strictEqual(connector1.transactionEventQueue?.length ?? 0, 0)
+ assert.strictEqual(connector2.transactionEventQueue?.length, 1)
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 1, 2)
+
+ assert.strictEqual(connector2.transactionEventQueue.length, 0)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(
+ requestHandlerMock.mock.calls[0].arguments[1],
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const replayParams = requestHandlerMock.mock.calls[0].arguments[3] as RequestParams
+ assert.strictEqual(replayParams.responseTimeoutMs, 30_000)
+ })
+
+ await it('should clean up the EVSE-qualified connector after replaying an Ended event', async () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 2 })
+ const evse1 = mockStation.getEvseStatus(1)
+ const evse2 = mockStation.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const connector1 = [...evse1.connectors.values()][0]
+ const connector2 = [...evse2.connectors.values()][0]
+ evse1.connectors.clear()
+ evse2.connectors.clear()
+ evse1.connectors.set(1, connector1)
+ evse2.connectors.set(1, connector2)
+ connector1.status = ConnectorStatusEnum.Faulted
+ connector2.status = ConnectorStatusEnum.Occupied
+ connector2.transactionStarted = true
+ connector2.transactionId = '00000000-0000-4000-8000-000000000001'
+ connector2.transactionEventQueue = [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 1,
+ timestamp: new Date(),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000001' },
+ triggerReason: OCPP20TriggerReasonEnumType.RemoteStop,
+ },
+ seqNo: 1,
+ timestamp: new Date(),
+ },
+ ]
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, 1, 2)
+
+ assert.strictEqual(connector1.status, ConnectorStatusEnum.Faulted)
+ assert.strictEqual(connector2.status, ConnectorStatusEnum.Available)
+ assert.strictEqual(connector2.transactionStarted, false)
+ })
+
+ await it('should bind transaction timers to the EVSE-qualified connector', () => {
+ mock.timers.enable({ apis: ['setInterval'] })
+ const { mockStation } = createAlignedStation({ connectorsCount: 2, evsesCount: 2 })
+ const evse1 = mockStation.getEvseStatus(1)
+ const evse2 = mockStation.getEvseStatus(2)
+ assert.ok(evse1 != null)
+ assert.ok(evse2 != null)
+ const connector1 = [...evse1.connectors.values()][0]
+ const connector2 = [...evse2.connectors.values()][0]
+ evse1.connectors.clear()
+ evse2.connectors.clear()
+ evse1.connectors.set(1, connector1)
+ evse2.connectors.set(1, connector2)
+ connector2.transactionStarted = true
+ connector2.transactionId = '00000000-0000-4000-8000-000000000002'
+
+ OCPP20ServiceUtils.startUpdatedMeterValues(mockStation, 1, 1000, 2)
+ OCPP20ServiceUtils.startEndedMeterValues(mockStation, 1, 1000, 2)
+
+ assert.strictEqual(connector1.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connector1.transactionEndedMeterValuesSetInterval, undefined)
+ assert.notStrictEqual(connector2.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.notStrictEqual(connector2.transactionEndedMeterValuesSetInterval, undefined)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(mockStation, 1, 2)
+ OCPP20ServiceUtils.stopEndedMeterValues(mockStation, 1, 2)
+ })
+
+ await it('should deduplicate register families by effective OCPP 2.0 identity during phase suppression', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ const baseTemplate = {
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L1_N,
+ unit: 'Wh',
+ }
+ evseStatus.MeterValues = [
+ baseTemplate,
+ { ...baseTemplate, context: 'Sample.Periodic' },
+ { ...baseTemplate, format: 'SignedData' },
+ { ...baseTemplate, location: OCPP20LocationEnumType.Outlet },
+ { ...baseTemplate, unit: 'kWh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'true'
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.strictEqual(meterValue.sampledValue.length, 3)
+ assert.ok(meterValue.sampledValue.every(sample => sample.phase == null))
+ assert.ok(
+ meterValue.sampledValue.every(
+ sample => sample.context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ )
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.location, sample.unitOfMeasure?.unit]),
+ [
+ [OCPP20LocationEnumType.Inlet, 'Wh'],
+ [OCPP20LocationEnumType.Outlet, 'Wh'],
+ [OCPP20LocationEnumType.Inlet, 'kWh'],
+ ]
+ )
+ })
+
+ await it('should skip unsupported phases and emits physical neutral and line voltages', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ if (mockStation.stationInfo != null) mockStation.stationInfo.numberOfPhases = 3
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L1_L2,
+ unit: 'Wh',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.N,
+ unit: 'A',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.L1_L2,
+ unit: 'A',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.N,
+ unit: 'V',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L1_L2,
+ unit: 'V',
+ value: '230',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.N,
+ unit: 'W',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.L1_L2,
+ unit: 'W',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: 'bogus',
+ unit: 'V',
+ value: '230',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.STATE_OF_CHARGE,
+ phase: MeterValuePhase.L1,
+ unit: 'Percent',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ [
+ OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ OCPP20MeasurandEnumType.STATE_OF_CHARGE,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].join(',')
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'true'
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+
+ assert.deepStrictEqual(
+ meterValue.sampledValue.map(sample => [sample.measurand, sample.phase, sample.value]),
+ [
+ [OCPP20MeasurandEnumType.CURRENT_IMPORT, MeterValuePhase.N, 0],
+ [OCPP20MeasurandEnumType.VOLTAGE, MeterValuePhase.N, 0],
+ [OCPP20MeasurandEnumType.VOLTAGE, MeterValuePhase.L1_L2, 230],
+ [OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, undefined, 54321],
+ ]
+ )
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.phaseLineToLineVoltageMeterValues = true
+ }
+ const autoPhaseMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ const autoPhaseLineToLine = autoPhaseMeterValue.sampledValue.find(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.VOLTAGE &&
+ sample.phase === MeterValuePhase.L1_L2
+ )
+ assert.strictEqual(autoPhaseLineToLine?.value, 230)
+ mock.method(mockStation, 'getNumberOfPhases', () => 1)
+ const singlePhaseMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ assert.strictEqual(
+ singlePhaseMeterValue.sampledValue.some(sample => sample.phase === MeterValuePhase.L1_L2),
+ false
+ )
+ upsertConfigurationKey(
+ mockStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'false'
+ )
+ const aggregateEnergyTemplate = {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ }
+ evseStatus.MeterValues = [
+ aggregateEnergyTemplate,
+ { ...aggregateEnergyTemplate, phase: MeterValuePhase.L2_N },
+ {
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.L3,
+ unit: 'A',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.L2_N,
+ unit: 'W',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L2_N,
+ unit: 'V',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const invalidSinglePhaseMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ assert.deepStrictEqual(
+ invalidSinglePhaseMeterValue.sampledValue.map(sample => sample.phase),
+ [undefined]
+ )
+
+ if (mockStation.stationInfo != null) mockStation.stationInfo.currentOutType = CurrentType.DC
+ evseStatus.MeterValues = [
+ aggregateEnergyTemplate,
+ { ...aggregateEnergyTemplate, phase: MeterValuePhase.L1_N },
+ {
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.L1,
+ unit: 'A',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ phase: MeterValuePhase.L1_N,
+ unit: 'W',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L1_N,
+ unit: 'V',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.CURRENT_IMPORT,
+ phase: MeterValuePhase.N,
+ unit: 'A',
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.N,
+ unit: 'V',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const dcMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ assert.deepStrictEqual(
+ dcMeterValue.sampledValue.map(sample => sample.phase),
+ [undefined]
+ )
+ })
+
+ await it('should preserve automatic voltage phases and main-voltage suppression', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ mock.method(mockStation, 'getNumberOfPhases', () => 3)
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.mainVoltageMeterValues = false
+ mockStation.stationInfo.phaseLineToLineVoltageMeterValues = true
+ }
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, unit: 'Wh' },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ [
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ OCPP20MeasurandEnumType.VOLTAGE,
+ ].join(',')
+ )
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ const voltageSamples = meterValue.sampledValue.filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.VOLTAGE
+ )
+
+ assert.deepStrictEqual(
+ voltageSamples.map(sample => sample.phase),
+ [
+ MeterValuePhase.L1_N,
+ MeterValuePhase.L2_N,
+ MeterValuePhase.L3_N,
+ MeterValuePhase.L1_L2,
+ MeterValuePhase.L2_L3,
+ MeterValuePhase.L3_L1,
+ ]
+ )
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-auto-voltage' })
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 3,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'auto-voltage',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: 0,
+ socPercent: 30,
+ transactionId: 'tx-auto-voltage',
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ mockStation.__injectCoherentSession('tx-auto-voltage', session)
+ const coherentMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1, transactionId: 'tx-auto-voltage' },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ assert.deepStrictEqual(
+ coherentMeterValue.sampledValue
+ .filter(sample => sample.measurand === OCPP20MeasurandEnumType.VOLTAGE)
+ .map(sample => sample.phase)
+ .sort(),
+ voltageSamples.map(sample => sample.phase).sort()
+ )
+
+ upsertConfigurationKey(
+ mockStation,
+ measurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ const energyOnlyMeterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ assert.strictEqual(
+ energyOnlyMeterValue.sampledValue.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.VOLTAGE
+ ),
+ false
+ )
+ })
+ await it('should not suppress automatic voltage phases across customData identities', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ mock.method(mockStation, 'getNumberOfPhases', () => 3)
+ if (mockStation.stationInfo != null) {
+ mockStation.stationInfo.currentOutType = CurrentType.AC
+ mockStation.stationInfo.mainVoltageMeterValues = false
+ }
+ const evseStatus = mockStation.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ customData: { vendorId: 'sensor-a' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ {
+ customData: { vendorId: 'sensor-b' },
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ phase: MeterValuePhase.L1_N,
+ unit: 'V',
+ value: '231',
+ },
+ ] as unknown as NonNullable<EvseStatus['MeterValues']>
+ const measurandsKey = buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ )
+ upsertConfigurationKey(mockStation, measurandsKey, OCPP20MeasurandEnumType.VOLTAGE)
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ mockStation,
+ { connectorId: 1, evseId: 1 },
+ 60_000,
+ measurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ )
+ const l1Voltages = meterValue.sampledValue.filter(
+ sample =>
+ sample.measurand === OCPP20MeasurandEnumType.VOLTAGE &&
+ sample.phase === MeterValuePhase.L1_N
+ )
+
+ assert.deepStrictEqual(
+ l1Voltages.map(sample => [sample.customData?.vendorId, sample.value]),
+ [
+ ['sensor-b', 231],
+ ['sensor-a', 230],
+ ]
+ )
+ })
+ })
+
+ await it('should defer transaction timer restarts while restored replay is pending', () => {
+ const { mockStation } = createAlignedStation({ connectorsCount: 1, evsesCount: 1 })
+ setupConnectorWithTransaction(mockStation, 1, { transactionId: 'tx-restored-interval' })
+ const connectorStatus = mockStation.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionRestored = true
+ const stopSpy = mock.method(OCPP20ServiceUtils, 'stopUpdatedMeterValues', () => undefined)
+ const startSpy = mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+
+ ChargingStation.prototype.restartTransactionMeterValues.call(mockStation, 'updated', 45_000)
+
+ assert.strictEqual(stopSpy.mock.callCount(), 0)
+ assert.strictEqual(startSpy.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ })
+
+ await describe('AlignedDataInterval SetVariables reaction', async () => {
+ let incomingRequestService: OCPP20IncomingRequestService
+ let testableService: TestableOCPP20IncomingRequestService
+ let restartSpy: Mock<() => void>
+ let restartTransactionSpy: Mock<(kind: 'ended' | 'updated', interval: number) => void>
+ let stopSpy: Mock<() => void>
+ let mockStation: MockChargingStation
+
+ beforeEach(() => {
+ ;({ mockStation } = createAlignedStation())
+ restartSpy = mock.fn(noop)
+ restartTransactionSpy = mock.fn()
+ stopSpy = mock.fn(noop)
+ Object.assign(mockStation, {
+ restartAlignedMeterValues: restartSpy,
+ restartTransactionMeterValues: restartTransactionSpy,
+ stopAlignedMeterValues: stopSpy,
+ })
+ incomingRequestService = new OCPP20IncomingRequestService()
+ testableService = createTestableIncomingRequestService(incomingRequestService)
+ })
+
+ await it('should restart the aligned timer when AlignedDataInterval is set via SetVariables', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '120',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.AlignedDataInterval },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ response.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(restartSpy.mock.callCount(), 1)
+ })
+
+ await it('should restart active transaction timers when their intervals change', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '45',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '75',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxEndedInterval },
+ },
+ ],
+ })
+
+ assert.deepStrictEqual(
+ response.setVariableResult.map(result => result.attributeStatus),
+ [SetVariableStatusEnumType.Accepted, SetVariableStatusEnumType.Accepted]
+ )
+ assert.deepStrictEqual(restartTransactionSpy.mock.calls[0].arguments, [
+ 'updated',
+ 45_000,
+ 30_000,
+ ])
+ assert.deepStrictEqual(restartTransactionSpy.mock.calls[1].arguments, [
+ 'ended',
+ 75_000,
+ undefined,
+ ])
+ })
+
+ await it('should restart the aligned timer for case-insensitive SetVariables names', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '120',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: 'interval' },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ response.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(restartSpy.mock.callCount(), 1)
+ })
+
+ await it('should accept interval 0 and restarts through the settling path', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '0',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.AlignedDataInterval },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ response.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(restartSpy.mock.callCount(), 1)
+ assert.strictEqual(stopSpy.mock.callCount(), 0)
+ })
+
+ await it('should reject intervals longer than one UTC day', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: (Constants.SECONDS_PER_DAY + 1).toString(),
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.AlignedDataInterval },
+ },
+ ],
+ })
+
+ const setVariableResult = response.setVariableResult[0]
+ assert.strictEqual(setVariableResult.attributeStatus, SetVariableStatusEnumType.Rejected)
+ const attributeStatusInfo = setVariableResult.attributeStatusInfo
+ assert.ok(attributeStatusInfo != null)
+ assert.strictEqual(attributeStatusInfo.reasonCode, ReasonCodeEnumType.ValueTooHigh)
+ assert.strictEqual(restartSpy.mock.callCount(), 0)
+ assert.strictEqual(stopSpy.mock.callCount(), 0)
+ })
+
+ await it('should accept intervals that do not partition the UTC day evenly', () => {
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '7',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.AlignedDataInterval },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ response.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(restartSpy.mock.callCount(), 1)
+ assert.strictEqual(stopSpy.mock.callCount(), 0)
+ })
+ await it('should settle with the previous energy measurands before replacing them', () => {
+ upsertConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ let measurandsDuringSettlement: string | undefined
+ Object.assign(mockStation, {
+ settleTransactionEnergyMeterValues: () => {
+ measurandsDuringSettlement = getConfigurationKey(
+ mockStation,
+ TX_UPDATED_MEASURANDS_KEY
+ )?.value
+ },
+ })
+
+ const response = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT,
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedMeasurands },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ response.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(
+ measurandsDuringSettlement,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(
+ getConfigurationKey(mockStation, TX_UPDATED_MEASURANDS_KEY)?.value,
+ OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT
+ )
+ })
+
+ await it('should settle before disabling and restarts when AlignedDataCtrlr.Enabled changes', () => {
+ upsertConfigurationKey(mockStation, ALIGNED_ENABLED_KEY, 'true')
+ const disableResponse = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'false',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.Enabled },
+ },
+ ],
+ })
+ const enableResponse = testableService.handleRequestSetVariables(mockStation, {
+ setVariableData: [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'true',
+ component: { name: OCPP20ComponentName.AlignedDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.Enabled },
+ },
+ ],
+ })
+
+ assert.strictEqual(
+ disableResponse.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(
+ enableResponse.setVariableResult[0].attributeStatus,
+ SetVariableStatusEnumType.Accepted
+ )
+ assert.strictEqual(stopSpy.mock.callCount(), 0)
+ assert.strictEqual(restartSpy.mock.callCount(), 2)
+ assert.deepStrictEqual(restartSpy.mock.calls[0].arguments, [undefined, true])
+ assert.deepStrictEqual(restartSpy.mock.calls[1].arguments, [])
+ })
+ })
+
+ await describe('ChargingStation aligned timer lifecycle', async () => {
+ let templateFile: string
+ let station: ChargingStation
+
+ beforeEach(() => {
+ templateFile = writeStationTemplate(
+ {
+ $schemaVersion: 1,
+ baseName: 'TEST-ALIGNED-MV',
+ chargePointModel: 'Simulator simple',
+ chargePointVendor: 'Simulator',
+ currentOutType: 'AC',
+ Evses: {
+ 0: { Connectors: { 0: {} } },
+ 1: {
+ Connectors: { 1: {} },
+ MeterValues: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ],
+ },
+ },
+ ocppVersion: '2.0.1',
+ power: 22000,
+ powerUnit: 'W',
+ randomConnectors: false,
+ },
+ 'aligned-mv.station-template.json'
+ )
+ station = createStationFromTemplate(templateFile)
+ upsertConfigurationKey(station, ALIGNED_ENABLED_KEY, 'true')
+ })
+
+ afterEach(() => {
+ cleanupChargingStation(station)
+ cleanupStationTemplates()
+ })
+
+ await it('should load EVSE-level MeterValues from the station template', () => {
+ assert.deepStrictEqual(station.getEvseStatus(1)?.MeterValues, [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ },
+ ])
+ })
+
+ await it('should not arm aligned sampling before initial registration', () => {
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ const testableStation = station as unknown as {
+ openWSConnection: () => void
+ templateFileWatcher?: { close: () => void }
+ }
+ mock.method(testableStation, 'openWSConnection', noop)
+
+ station.start()
+
+ assert.strictEqual(station.started, true)
+ assert.strictEqual(startSpy.mock.callCount(), 0)
+ testableStation.templateFileWatcher?.close()
+ })
+ await it('should arm exactly one timer and guards double start', async () => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ station.startAlignedMeterValues()
+
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ await flushPendingPromises()
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 2)
+ })
+
+ await it('should capture every boundary while prior delivery remains in flight', async () => {
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '60')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ let releaseSweep: (() => void) | undefined
+ const sweepBlocked = new Promise<void>(resolve => {
+ releaseSweep = resolve
+ })
+ const emitSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'emitClockAlignedMeterValues',
+ (): Promise<void> => sweepBlocked
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.tick(60_000)
+ for (let boundary = 0; boundary < 10; boundary++) mock.timers.tick(60_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 11)
+ releaseSweep?.()
+ await flushPendingPromises()
+ mock.timers.tick(60_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 12)
+ })
+
+ await it('should align the first emission to the next wall-clock boundary', async () => {
+ // now = 300 s into a 900 s interval → first emission 600 s later (at the
+ // next boundary), not a full interval after start.
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 300_000 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+
+ mock.timers.tick(599_999)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ mock.timers.tick(1)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ await flushPendingPromises()
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 2)
+ })
+
+ await it('should support intervals that do not partition the UTC day evenly', async () => {
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '7')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.tick(7_000)
+ await flushPendingPromises()
+
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ })
+ await it('should not arm the scheduler while AlignedDataCtrlr.Enabled is false', () => {
+ upsertConfigurationKey(station, ALIGNED_ENABLED_KEY, 'false')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.tick(900_000)
+
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+
+ await it('should not backdate samples after a delayed callback', async () => {
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '60')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.setTime(70_000)
+ mock.timers.tick(60_000)
+ await flushPendingPromises()
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ const delayedTimestamp = emitSpy.mock.calls[0].arguments[1]
+ assert.ok(delayedTimestamp instanceof Date)
+ assert.strictEqual(delayedTimestamp.getTime(), 130_000)
+ mock.timers.tick(49_999)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ mock.timers.tick(1)
+ assert.strictEqual(emitSpy.mock.callCount(), 2)
+ const nextTimestamp = emitSpy.mock.calls[1].arguments[1]
+ assert.ok(nextTimestamp instanceof Date)
+ assert.strictEqual(nextTimestamp.getTime(), 180_000)
+ })
+ await it('should restart the interval while prior boundary delivery remains in flight', async () => {
+ station.started = true
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '60')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const firstSweep = Promise.withResolvers<undefined>()
+ const emitSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'emitClockAlignedMeterValues',
+ (): Promise<void> =>
+ emitSpy.mock.callCount() === 0 ? firstSweep.promise : Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.tick(60_000)
+ station.restartAlignedMeterValues()
+ for (let boundary = 0; boundary < 10; boundary++) mock.timers.tick(60_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 11)
+
+ firstSweep.resolve(undefined)
+ await flushPendingPromises()
+ mock.timers.tick(60_000)
+
+ assert.strictEqual(emitSpy.mock.callCount(), 12)
+ })
+
+ await it('should stop cleanly and survives repeated online cycles without leaks', () => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ station.stopAlignedMeterValues()
+ mock.timers.tick(9_000_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+
+ // Simulated reconnect cycles: start → stop → start must not stack timers.
+ station.startAlignedMeterValues()
+ station.stopAlignedMeterValues()
+ station.startAlignedMeterValues()
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ })
+
+ await it('should start every boundary while prior delivery remains in flight', async () => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ let releaseFirstSweep: () => void = noop
+ const firstSweepBlocked = new Promise<void>(resolve => {
+ releaseFirstSweep = resolve
+ })
+ let emissionCount = 0
+ const emitSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'emitClockAlignedMeterValues',
+ async (): Promise<void> => {
+ emissionCount++
+ if (emissionCount === 1) await firstSweepBlocked
+ }
+ )
+
+ station.startAlignedMeterValues()
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 2)
+
+ releaseFirstSweep()
+ await flushPendingPromises()
+ mock.timers.tick(900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 3)
+ })
+
+ await it('should keep the cadence running while queued TransactionEvents replay', async () => {
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ const stopSpy = mock.method(station, 'stopAlignedMeterValues')
+ const testableStation = station as unknown as {
+ flushMessageBuffer: () => void
+ onOpen: () => Promise<void>
+ }
+ const flushMessageBufferSpy = mock.method(testableStation, 'flushMessageBuffer', noop)
+ station.started = true
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => true)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [{} as QueuedTransactionEvent]
+ let signalFlushStarted: (() => void) | undefined
+ const flushStarted = new Promise<void>(resolve => {
+ signalFlushStarted = resolve
+ })
+ let releaseFlush: (() => void) | undefined
+ const flushBlocked = new Promise<void>(resolve => {
+ releaseFlush = resolve
+ })
+ mock.method(OCPP20ServiceUtils, 'sendQueuedTransactionEvents', async () => {
+ signalFlushStarted?.()
+ await flushBlocked
+ })
+
+ const onOpenPromise = testableStation.onOpen()
+ await flushStarted
+ assert.strictEqual(flushMessageBufferSpy.mock.callCount(), 1)
+ const startsBeforeFlushCompleted = startSpy.mock.callCount()
+ const stopsBeforeFlushCompleted = stopSpy.mock.callCount()
+ releaseFlush?.()
+ await onOpenPromise
+
+ assert.strictEqual(startsBeforeFlushCompleted, 0)
+ assert.strictEqual(stopsBeforeFlushCompleted, 0)
+ assert.strictEqual(startSpy.mock.callCount(), 1)
+ })
+
+ await it('should arm after an Accepted transition outside onOpen', async () => {
+ station.started = true
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => true)
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ const testableStation = station as unknown as {
+ startMessageSequence: () => Promise<void>
+ }
+ mock.method(testableStation, 'startMessageSequence', () => Promise.resolve())
+
+ station.emitChargingStationEvent(ChargingStationEvents.accepted)
+ await flushPendingPromises()
+ await flushPendingPromises()
+
+ assert.strictEqual(startSpy.mock.callCount(), 1)
+ })
+
+ await it('should replay once after accepted-state startup notifications fail', async () => {
+ station.started = true
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => true)
+ const testableStation = station as unknown as {
+ startAcceptedMessageSequence: () => Promise<void>
+ startAlignedMeterValuesAfterReplay: () => Promise<void>
+ startMessageSequence: () => Promise<void>
+ }
+ const sequenceSpy = mock.method(testableStation, 'startMessageSequence', () =>
+ Promise.reject(new Error('startup notification failed'))
+ )
+ const replaySpy = mock.method(testableStation, 'startAlignedMeterValuesAfterReplay', () =>
+ Promise.resolve()
+ )
+
+ await Promise.all([
+ testableStation.startAcceptedMessageSequence(),
+ testableStation.startAcceptedMessageSequence(),
+ ])
+
+ assert.strictEqual(sequenceSpy.mock.callCount(), 1)
+ assert.strictEqual(replaySpy.mock.callCount(), 1)
+ })
+
+ await it('should not re-arm after disconnecting during queued event replay', async () => {
+ station.started = true
+ let connected = true
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => connected)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [
+ { request: { offline: false } } as QueuedTransactionEvent,
+ ]
+ let signalFlushStarted: (() => void) | undefined
+ const flushStarted = new Promise<void>(resolve => {
+ signalFlushStarted = resolve
+ })
+ let releaseFlush: (() => void) | undefined
+ const flushBlocked = new Promise<void>(resolve => {
+ releaseFlush = resolve
+ })
+ mock.method(OCPP20ServiceUtils, 'sendQueuedTransactionEvents', async () => {
+ signalFlushStarted?.()
+ await flushBlocked
+ })
+
+ const onOpenPromise = (station as unknown as { onOpen: () => Promise<void> }).onOpen()
+ await flushStarted
+ connected = false
+ station.emitChargingStationEvent(ChargingStationEvents.disconnected)
+ releaseFlush?.()
+ await onOpenPromise
+
+ assert.strictEqual(startSpy.mock.callCount(), 0)
+ })
+
+ await it('should not re-arm after stop begins during queued event replay', async () => {
+ station.started = true
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => true)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [{} as QueuedTransactionEvent]
+ const flushStarted = Promise.withResolvers<undefined>()
+ const releaseFlush = Promise.withResolvers<undefined>()
+ mock.method(OCPP20ServiceUtils, 'sendQueuedTransactionEvents', async () => {
+ flushStarted.resolve(undefined)
+ await releaseFlush.promise
+ })
+ const testableStation = station as unknown as {
+ onOpen: () => Promise<void>
+ stopping: boolean
+ }
+
+ const onOpenPromise = testableStation.onOpen()
+ await flushStarted.promise
+ testableStation.stopping = true
+ station.stopAlignedMeterValues()
+ releaseFlush.resolve(undefined)
+ await onOpenPromise
+
+ assert.strictEqual(startSpy.mock.callCount(), 0)
+ testableStation.stopping = false
+ })
+
+ await it('should re-arm after an interval change during queued event replay', async () => {
+ station.started = true
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ mock.method(station, 'inAcceptedState', () => true)
+ mock.method(station, 'isWebSocketConnectionOpened', () => true)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEventQueue = [{} as QueuedTransactionEvent]
+ const flushStarted = Promise.withResolvers<undefined>()
+ const releaseFlush = Promise.withResolvers<undefined>()
+ mock.method(OCPP20ServiceUtils, 'sendQueuedTransactionEvents', async () => {
+ flushStarted.resolve(undefined)
+ await releaseFlush.promise
+ })
+
+ const onOpenPromise = (station as unknown as { onOpen: () => Promise<void> }).onOpen()
+ await flushStarted.promise
+ station.restartAlignedMeterValues()
+ releaseFlush.resolve(undefined)
+ await onOpenPromise
+
+ assert.strictEqual(startSpy.mock.callCount(), 1)
+ })
+
+ await it('should not restart aligned sampling while station shutdown is in progress', () => {
+ station.started = true
+ const startSpy = mock.method(station, 'startAlignedMeterValues', noop)
+ const testableStation = station as unknown as { stopping: boolean }
+ testableStation.stopping = true
+
+ station.restartAlignedMeterValues()
+
+ assert.strictEqual(startSpy.mock.callCount(), 0)
+ testableStation.stopping = false
+ })
+
+ await it('should keep the aligned clock running while the station is disconnected', () => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+ station.emitChargingStationEvent(ChargingStationEvents.disconnected)
+ mock.timers.tick(900_000)
+
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ })
+
+ await it('should re-arm with the new cadence after an interval change and restart', async () => {
+ station.started = true
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '60')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.restartAlignedMeterValues()
+
+ mock.timers.tick(59_999)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ mock.timers.tick(1)
+ assert.strictEqual(emitSpy.mock.callCount(), 1)
+ await flushPendingPromises()
+ mock.timers.tick(60_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 2)
+ })
+
+ await it('should not arm for OCPP 1.6 stations', () => {
+ station.stationInfo = {
+ ocppVersion: OCPPVersion.VERSION_16,
+ } as ChargingStationInfo
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+
+ mock.timers.tick(10 * 900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+
+ await it('should not arm when the configured interval is 0', () => {
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, '0')
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ station.startAlignedMeterValues()
+
+ mock.timers.tick(10 * 900_000)
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+
+ await it('should not arm for invalid persisted intervals', () => {
+ mock.timers.enable({ apis: ['setInterval', 'setTimeout', 'Date'], now: 0 })
+ const emitSpy = mock.method(OCPP20ServiceUtils, 'emitClockAlignedMeterValues', () =>
+ Promise.resolve()
+ )
+
+ for (const value of [(Constants.SECONDS_PER_DAY + 1).toString(), '86400.5', '-0.5']) {
+ upsertConfigurationKey(station, ALIGNED_DATA_INTERVAL_KEY, value)
+ station.startAlignedMeterValues()
+ mock.timers.tick(Constants.MAX_SETINTERVAL_DELAY_MS)
+ station.stopAlignedMeterValues()
+ }
+
+ assert.strictEqual(emitSpy.mock.callCount(), 0)
+ })
+ })
+})
import assert from 'node:assert/strict'
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
-import type { ChargingStation, CoherentSession } from '../../../../src/charging-station/index.js'
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
import type { ConnectorStatus } from '../../../../src/types/index.js'
+import { type CoherentSession } from '../../../../src/charging-station/meter-values/index.js'
import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
-import { CurrentType, OCPPVersion, Voltage } from '../../../../src/types/index.js'
+import {
+ ConnectorStatusEnum,
+ CurrentType,
+ OCPPVersion,
+ Voltage,
+} from '../../../../src/types/index.js'
import {
flushMicrotasks,
standardCleanup,
connectorStatus.transactionStarted = true
connectorStatus.transactionId = 'tx-1'
connectorStatus.locked = true
+ connectorStatus.status = ConnectorStatusEnum.Finishing
})
afterEach(() => {
await it('should delay Available transition after transaction end', async t => {
// Act
await withMockTimers(t, ['setTimeout'], async () => {
+ connectorStatus.transactionEnding = true
+ connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(
+ () => undefined,
+ 60_000
+ )
+ connectorStatus.transactionEndedMeterValuesSetInterval = setInterval(() => undefined, 60_000)
+ let lockedAtSave: boolean | undefined
+ const saveQueueSpy = mock.method(station, 'saveTransactionEventQueues', () => {
+ lockedAtSave = connectorStatus.locked
+ })
const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.locked, true)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Finishing)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(saveQueueSpy.mock.callCount(), 1)
+ assert.strictEqual(lockedAtSave, false)
+
for (let i = 0; i < 10; i++) {
await flushMicrotasks()
}
// Arrange
assert.ok(station.stationInfo != null, 'stationInfo should be defined')
station.stationInfo.postTransactionDelay = 0
+ const saveQueueSpy = mock.method(station, 'saveTransactionEventQueues')
// Act
await OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
assert.strictEqual(connectorStatus.transactionId, undefined)
assert.strictEqual(connectorStatus.locked, false)
assert.ok(requestHandlerMock.mock.calls.length >= 1, 'Should send StatusNotification')
+ assert.strictEqual(saveQueueSpy.mock.callCount(), 1)
+ const requestParams = requestHandlerMock.mock.calls[0].arguments[3] as
+ undefined | { responseTimeoutMs?: number }
+ assert.strictEqual(requestParams?.responseTimeoutMs, 30_000)
+ })
+
+ await it('should preserve a replacement transaction started during the delay', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const cleanup = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 'tx-2'
+ connectorStatus.postTransactionDelayTransactionId = 'tx-2'
+ connectorStatus.locked = true
+ connectorStatus.status = ConnectorStatusEnum.Occupied
+ t.mock.timers.tick(3000)
+ await flushMicrotasks()
+ await cleanup
+
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionId, 'tx-2')
+ assert.strictEqual(connectorStatus.postTransactionDelayTransactionId, 'tx-2')
+ assert.strictEqual(connectorStatus.locked, true)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Occupied)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+ })
+
+ await it('should preserve a newer post-transaction delay', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const cleanup = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+ connectorStatus.postTransactionDelayTransactionId = 'tx-2'
+ connectorStatus.locked = true
+ connectorStatus.status = ConnectorStatusEnum.Finishing
+
+ t.mock.timers.tick(3000)
+ await flushMicrotasks()
+ await cleanup
+
+ assert.strictEqual(connectorStatus.postTransactionDelayTransactionId, 'tx-2')
+ assert.strictEqual(connectorStatus.locked, true)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Finishing)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+ })
+
+ await it('should skip post-transaction delay while the station is stopping', async () => {
+ mock.method(station, 'isStopping', () => true)
+
+ await OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+
+ assert.strictEqual(connectorStatus.postTransactionDelayTransactionId, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+
+ await it('should abort post-transaction delay with the station lifecycle', async t => {
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const lifecycleAbortController = new AbortController()
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleAbortController.signal,
+ })
+ const cleanup = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+ await flushMicrotasks()
+
+ lifecycleAbortController.abort()
+ await cleanup
+
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ })
+ })
+
+ await it('should not block cleanup on the post-transaction status response', async () => {
+ assert.ok(station.stationInfo != null, 'stationInfo should be defined')
+ station.stationInfo.postTransactionDelay = 0
+ const statusResponse = Promise.withResolvers<Record<string, never>>()
+ requestHandlerMock.mock.mockImplementation(() => statusResponse.promise)
+ const cleanup = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
+
+ const outcome = await Promise.race([
+ cleanup.then(() => 'resolved' as const),
+ new Promise<'pending'>(resolve => {
+ setImmediate(() => {
+ resolve('pending')
+ })
+ }),
+ ])
+
+ assert.strictEqual(outcome, 'resolved')
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
+ connectorStatus.status = ConnectorStatusEnum.Occupied
+ statusResponse.resolve({})
+ await flushMicrotasks()
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Occupied)
})
- await it('should skip cleanup when station stops during delay', async t => {
+ await it('should finish local cleanup when station stops during delay', async t => {
// Act
await withMockTimers(t, ['setTimeout'], async () => {
const promise = OCPP20ServiceUtils.cleanupEndedTransaction(station, 1, connectorStatus)
await promise
})
- // Assert — transactionStarted stays true (blocks ATG), transactionId cleared (blocks stopAll)
- assert.strictEqual(connectorStatus.transactionStarted, true)
+ // Local state must be reusable after restart; only the wire notification is skipped.
+ assert.strictEqual(connectorStatus.transactionStarted, false)
assert.strictEqual(connectorStatus.transactionId, undefined)
- assert.strictEqual(connectorStatus.locked, true)
+ assert.strictEqual(connectorStatus.locked, false)
assert.strictEqual(
requestHandlerMock.mock.calls.length,
0,
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 type { ConnectorStatus, EmptyObject } from '../../../../src/types/index.js'
+import type { CoherentSession } from '../../../../src/charging-station/meter-values/types.js'
+import type { ConnectorStatus, EmptyObject, EvseStatus } from '../../../../src/types/index.js'
-import { addConfigurationKey } from '../../../../src/charging-station/index.js'
+import { ChargingStation } from '../../../../src/charging-station/ChargingStation.js'
+import {
+ prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
+} from '../../../../src/charging-station/HelpersConnectorStatus.js'
+import { addConfigurationKey, buildConfigKey } from '../../../../src/charging-station/index.js'
+import { recordTransactionIntervalConsumption } from '../../../../src/charging-station/meter-values/TransactionIntervalUtils.js'
+import { TransactionMeterValueDeliveryBarrier } from '../../../../src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.js'
+import { createTestableResponseService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
+import { buildOCPP20SampledValue } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestBuilders.js'
+import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
import {
buildTransactionEvent,
OCPP20ServiceUtils,
} from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
-import { startUpdatedMeterValues } from '../../../../src/charging-station/ocpp/OCPPServiceOperations.js'
+import {
+ flushQueuedTransactionMessages,
+ startUpdatedMeterValues,
+} from '../../../../src/charging-station/ocpp/OCPPServiceOperations.js'
+import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ enqueueBoundedTransactionEvent,
+ isTransactionEventQueueBlocked,
+ resetTransactionEventQueueRuntimeState,
+} from '../../../../src/charging-station/TransactionEventQueueUtils.js'
import { OCPPError } from '../../../../src/exception/index.js'
import {
AttributeEnumType,
ConnectorStatusEnum,
+ CurrentType,
+ ErrorType,
OCPP20ChargingStateEnumType,
OCPP20ComponentName,
OCPP20IdTokenEnumType,
type OCPP20IdTokenType,
OCPP20MeasurandEnumType,
type OCPP20MeterValue,
+ OCPP20OptionalVariableName,
OCPP20ReadingContextEnumType,
OCPP20ReasonEnumType,
OCPP20RequestCommand,
OCPP20RequiredVariableName,
OCPP20TransactionEventEnumType,
+ type OCPP20TransactionEventRequest,
+ type OCPP20TransactionEventResponse,
type OCPP20TransactionType,
OCPP20TriggerReasonEnumType,
+ OCPP20UnitEnumType,
OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
+ type RequestParams,
+ type SampledValueTemplate,
+ SigningMethodEnumType,
+ Voltage,
} from '../../../../src/types/index.js'
import { Constants, generateUUID } from '../../../../src/utils/index.js'
import {
+ flushMicrotasks,
setupConnectorWithTransaction,
standardCleanup,
withMockTimers,
} from '../../../helpers/TestLifecycleHelpers.js'
-import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_PUBLIC_KEY_HEX,
+} from '../../ChargingStationTestConstants.js'
import { createMockChargingStation } from '../../helpers/StationHelpers.js'
import {
type CapturedOCPPRequest,
},
] as const
+const preparePersistedConnectorStatus = (
+ station: ChargingStation,
+ connectorStatus: ConnectorStatus
+): ConnectorStatus => {
+ for (const queuedEvent of connectorStatus.transactionEventQueue ?? []) {
+ queuedEvent.ownerEvseId ??= 1
+ queuedEvent.ownerConnectorId ??= 1
+ }
+ return preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(connectorStatus),
+ request =>
+ station.ocppRequestService.validateRequestPayload(
+ station,
+ OCPP20RequestCommand.TRANSACTION_EVENT,
+ request,
+ { forceValidation: true }
+ ),
+ 1,
+ 1
+ )
+}
+
await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
await describe('E01-E04 - OCPP 2.0.1 TransactionEvent Implementation', async () => {
let mockStation: ChargingStation
const connectorId = 1
const transactionId = generateUUID()
const triggerReason = OCPP20TriggerReasonEnumType.Authorized
+ const timestamp = new Date('2026-08-28T15:00:00.000Z')
// Reset sequence number to simulate new transaction
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
const transactionEvent = buildTransactionEvent(mockStation, {
connectorId,
eventType: OCPP20TransactionEventEnumType.Started,
+ timestamp,
transactionId,
triggerReason,
})
assert.strictEqual(transactionEvent.eventType, OCPP20TransactionEventEnumType.Started)
assert.strictEqual(transactionEvent.triggerReason, triggerReason)
assert.strictEqual(transactionEvent.seqNo, 0) // First event should have seqNo 0
- assert.ok(transactionEvent.timestamp instanceof Date)
+ assert.strictEqual(transactionEvent.timestamp, timestamp)
if (transactionEvent.evse == null) {
assert.fail('Expected evse to be defined')
}
assert.strictEqual(transactionEvent.seqNo, 0)
})
+ await it('should derive an Ended fallback transaction ID from the exact EVSE connector', () => {
+ const firstConnector = mockStation.getConnectorStatus(1, 1)
+ const secondConnector = mockStation.getConnectorStatus(2, 2)
+ const secondEvse = mockStation.getEvseStatus(2)
+ assert.ok(firstConnector != null)
+ assert.ok(secondConnector != null)
+ assert.ok(secondEvse != null)
+ firstConnector.transactionId = '00000000-0000-4000-8000-000000000101'
+ secondConnector.transactionId = '00000000-0000-4000-8000-000000000202'
+ secondEvse.connectors.delete(2)
+ secondEvse.connectors.set(1, secondConnector)
+
+ const endedEvent = buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ evseId: 2,
+ })
+
+ assert.strictEqual(endedEvent.transactionInfo.transactionId, secondConnector.transactionId)
+ assert.deepStrictEqual(endedEvent.evse, { connectorId: 1, id: 2 })
+ assert.strictEqual(firstConnector.transactionSeqNo, undefined)
+ assert.strictEqual(secondConnector.transactionSeqNo, 0)
+
+ delete secondConnector.transactionId
+ const unownedEndedEvent = buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ evseId: 2,
+ })
+ assert.notStrictEqual(
+ unownedEndedEvent.transactionInfo.transactionId,
+ firstConnector.transactionId
+ )
+ assert.match(
+ unownedEndedEvent.transactionInfo.transactionId,
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u
+ )
+ })
+
+ await it('should resolve an EVSE-only identity against that exact EVSE', () => {
+ const firstConnector = mockStation.getConnectorStatus(1, 1)
+ const secondConnector = mockStation.getConnectorStatus(2, 2)
+ const secondEvse = mockStation.getEvseStatus(2)
+ assert.ok(firstConnector != null)
+ assert.ok(secondConnector != null)
+ assert.ok(secondEvse != null)
+ firstConnector.transactionId = '00000000-0000-4000-8000-000000000101'
+ secondConnector.transactionId = '00000000-0000-4000-8000-000000000202'
+ secondEvse.connectors.delete(2)
+ secondEvse.connectors.set(1, secondConnector)
+
+ const endedEvent = buildTransactionEvent(mockStation, {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ evse: { id: 2 },
+ })
+
+ assert.strictEqual(endedEvent.transactionInfo.transactionId, secondConnector.transactionId)
+ assert.deepStrictEqual(endedEvent.evse, { connectorId: 1, id: 2 })
+ assert.strictEqual(firstConnector.transactionSeqNo, undefined)
+ assert.strictEqual(firstConnector.transactionEvseSent, undefined)
+ assert.strictEqual(secondConnector.transactionSeqNo, 0)
+ assert.strictEqual(secondConnector.transactionEvseSent, true)
+ })
+
+ await it('should reject conflicting connector identities before mutating state', () => {
+ const firstConnector = mockStation.getConnectorStatus(1, 1)
+ const secondConnector = mockStation.getConnectorStatus(2, 2)
+ assert.ok(firstConnector != null)
+ assert.ok(secondConnector != null)
+
+ assert.throws(
+ () =>
+ buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ evse: { connectorId: 2, id: 1 },
+ }),
+ /Conflicting connector IDs/u
+ )
+ assert.strictEqual(firstConnector.transactionSeqNo, undefined)
+ assert.strictEqual(firstConnector.transactionEvseSent, undefined)
+ assert.strictEqual(secondConnector.transactionSeqNo, undefined)
+ assert.strictEqual(secondConnector.transactionEvseSent, undefined)
+ })
+
+ await it('should preserve EVSE-as-connector resolution for a flat legacy topology', () => {
+ const { station: flatStation } = createMockChargingStation({
+ connectorsCount: 2,
+ ocppVersion: OCPPVersion.VERSION_16,
+ })
+ assert.strictEqual(flatStation.hasEvses, false)
+
+ const transactionEvent = buildTransactionEvent(flatStation, {
+ eventType: OCPP20TransactionEventEnumType.Started,
+ evse: { id: 2 },
+ })
+
+ assert.deepStrictEqual(transactionEvent.evse, { id: 2 })
+ assert.strictEqual(flatStation.getConnectorStatus(1)?.transactionSeqNo, undefined)
+ assert.strictEqual(flatStation.getConnectorStatus(2)?.transactionSeqNo, 0)
+ })
+
await it('should increment sequence number for subsequent events', () => {
const connectorId = 2
const transactionId = generateUUID()
assert.strictEqual(typeof response, 'object')
})
- await it('should handle errors gracefully', async () => {
- // Create a mock charging station that throws an error
+ await it('should retain a pre-send failure for later replay without retrying immediately', async () => {
+ const preSendFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent failed before transport send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn(() => Promise.reject(preSendFailure))
const { station: errorMockChargingStation } = createMockChargingStation({
baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 1,
evseConfiguration: { evsesCount: 1 },
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Network error')
- },
- },
+ ocppRequestService: { requestHandler: requestHandlerMock },
stationInfo: {
ocppStrictCompliance: true,
ocppVersion: OCPPVersion.VERSION_201,
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(errorMockChargingStation, connectorId, { transactionId })
+ const signedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ }
+ addConfigurationKey(
+ errorMockChargingStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ errorMockChargingStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [signedMeterValue] }
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const connectorStatus = errorMockChargingStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+ await it('should preserve rejected signed interval energy without mutating its evidence', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ mockStation.isWebSocketConnectionOpened = () => false
+ setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const signedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(signedMeterValue, baselineKey, 10)
+ const signedEvidence = JSON.stringify(signedMeterValue)
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
- OCPP20TransactionEventEnumType.Started,
- OCPP20TriggerReasonEnumType.Authorized,
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transactionId
- )
- throw new Error('Should have thrown error')
- } catch (error) {
- assert.ok((error as Error).message.includes('Network error'))
+ transactionId,
+ { meterValue: [signedMeterValue] }
+ ),
+ /TransactionEvent queue capacity exhausted by protected entries/
+ )
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [baselineKey]: 10,
+ })
+ assert.strictEqual(JSON.stringify(signedMeterValue), signedEvidence)
+ })
+
+ await it('should restore unsigned interval consumption after queue capacity rejection', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ mockStation.isWebSocketConnectionOpened = () => false
+ setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const meterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(meterValue, baselineKey, 10)
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ {
+ customData: {
+ payload: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ vendorId: 'test',
+ },
+ meterValue: [meterValue],
+ }
+ ),
+ /TransactionEvent queue capacity exhausted by protected entries/
+ )
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [baselineKey]: 10,
+ })
+ })
+
+ await it('should retain locally rejected interval evidence for explicit replay', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ const preSendFailure = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'TransactionEvent validation failed before transport send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const deliveredPayloads: OCPP20TransactionEventRequest[] = []
+ let rejectNextRequest = true
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ if (rejectNextRequest) {
+ rejectNextRequest = false
+ return Promise.reject(preSendFailure)
+ }
+ const requestParams = args[3] as RequestParams
+ deliveredPayloads.push(payload)
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ meteringPerTransaction: true,
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ addConfigurationKey(
+ station,
+ intervalBaselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [intervalBaselineKey]: 0,
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const rejectedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(0),
}
+ recordTransactionIntervalConsumption(rejectedMeterValue, intervalBaselineKey, 10)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [rejectedMeterValue] }
+ )
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0].value,
+ 10
+ )
+ const successorMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ station,
+ connectorId,
+ 1,
+ transactionId,
+ 60_000,
+ intervalBaselineKey,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(0),
+ 60_000
+ )
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ assert.strictEqual(deliveredPayloads.length, 1)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [successorMeterValue] }
+ )
+
+ assert.strictEqual(deliveredPayloads.length, 2)
+ const replayedIntervalEnergy = deliveredPayloads[0].meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .find(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )?.value
+ assert.strictEqual(replayedIntervalEnergy, 10)
+ })
+
+ await it('should not create an Updated event after transaction ending starts', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+ const sequenceNumberBefore = connectorStatus.transactionSeqNo
+ const requestHandlerSpy = mock.method(mockStation.ocppRequestService, 'requestHandler')
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId
+ )
+
+ assert.strictEqual(requestHandlerSpy.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionSeqNo, sequenceNumberBefore)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
})
})
assert.strictEqual(connectorStatus?.transactionSeqNo, undefined)
})
+ await it('should reserve MAX_SAFE_INTEGER for the terminal event', () => {
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = Number.MAX_SAFE_INTEGER - 1
+ const transactionId = generateUUID()
+
+ assert.throws(
+ () =>
+ buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ }),
+ /reserving the final value for Ended/
+ )
+ assert.strictEqual(connectorStatus.transactionSeqNo, Number.MAX_SAFE_INTEGER - 1)
+
+ const request = buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ assert.strictEqual(request.seqNo, Number.MAX_SAFE_INTEGER)
+ assert.strictEqual(connectorStatus.transactionSeqNo, Number.MAX_SAFE_INTEGER)
+ })
+
+ await it('should reject sequence overflow before mutating transaction metadata', () => {
+ const connectorStatus = mockStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = Number.MAX_SAFE_INTEGER
+ connectorStatus.transactionEvseSent = false
+ connectorStatus.transactionIdTokenSent = false
+
+ assert.throws(
+ () =>
+ buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ idToken: {
+ idToken: 'OVERFLOW_TOKEN',
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ transactionId: generateUUID(),
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ }),
+ /Cannot allocate a safe transaction sequence number/
+ )
+ assert.strictEqual(connectorStatus.transactionSeqNo, Number.MAX_SAFE_INTEGER)
+ assert.strictEqual(connectorStatus.transactionEvseSent, false)
+ assert.strictEqual(connectorStatus.transactionIdTokenSent, false)
+ })
+
await it('should handle non-existent connector gracefully', () => {
const nonExistentConnectorId = 999
assert.notStrictEqual(response, undefined)
assert.strictEqual(typeof response, 'object')
})
-
- await it('should handle context-aware error scenarios gracefully', async () => {
- // Create error mock for this test
- const { station: errorMockChargingStation } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- evseConfiguration: { evsesCount: 1 },
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Context test error')
- },
- },
- stationInfo: {
- ocppStrictCompliance: true,
- ocppVersion: OCPPVersion.VERSION_201,
- },
- websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
- })
-
- const connectorId = 1
- const transactionId = generateUUID()
-
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.AbnormalCondition,
- connectorId,
- transactionId
- )
- throw new Error('Should have thrown error')
- } catch (error) {
- assert.ok((error as Error).message.includes('Context test error'))
- }
- })
})
await describe('Backward Compatibility', async () => {
await it('should queue TransactionEvent when WebSocket is disconnected', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const saveQueueSpy = mock.method(mockStation, 'saveTransactionEventQueues')
setOnline(false)
assert(connectorStatus.transactionEventQueue != null)
assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
assert.strictEqual(connectorStatus.transactionEventQueue[0].seqNo, 0)
+ assert.strictEqual(saveQueueSpy.mock.callCount(), 1)
})
await it('should queue multiple TransactionEvents in order when offline', async () => {
const connectorStatus = mockStation.getConnectorStatus(connectorId)
assert.strictEqual(connectorStatus?.transactionEventQueue?.length, 2)
- // Online path with mock doesn't call buildTransactionEvent, so seqNo starts from 0
- assert.strictEqual(connectorStatus.transactionEventQueue[0].seqNo, 0)
- assert.strictEqual(connectorStatus.transactionEventQueue[1].seqNo, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].seqNo, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[1].seqNo, 2)
})
await it('should include timestamp in queued events', async () => {
assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
assert.strictEqual(connectorStatus.transactionEventQueue[0].request.offline, true)
})
+
+ await it('should not backdate a queued event after disconnect', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setOnline(true)
+ mockStation.inAcceptedState = () => false
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+
+ const queue = mockStation.getConnectorStatus(connectorId)?.transactionEventQueue
+ assert.ok(queue != null)
+ assert.strictEqual(queue[0].request.offline, undefined)
+
+ setOnline(false)
+
+ assert.strictEqual(queue[0].request.offline, undefined)
+ })
})
await describe('Queue draining when coming online', async () => {
await it('should send all queued events when sendQueuedTransactionEvents is called', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const persistedAttempts: boolean[][] = []
+ mock.method(mockStation, 'persistTransactionEventQueues', () => {
+ persistedAttempts.push(
+ mockStation
+ .getConnectorStatus(connectorId)
+ ?.transactionEventQueue?.map(event => event.deliveryAttempted === true) ?? []
+ )
+ return Promise.resolve()
+ })
setOnline(false)
OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
assert.strictEqual(sentRequests.length, 2)
assert.strictEqual(sentRequests[0].payload.seqNo, 0)
assert.strictEqual(sentRequests[1].payload.seqNo, 1)
+ assert.deepStrictEqual(persistedAttempts, [[true, false], [true]])
})
- await it('should clear queue and cleanup connector after sending', async () => {
+ await it('should return after its initial replay generation while later events drain separately', async () => {
const connectorId = 1
const transactionId = generateUUID()
-
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
+ let online = false
+ const firstStarted = Promise.withResolvers<undefined>()
+ const releaseFirst = Promise.withResolvers<undefined>()
+ const secondStarted = Promise.withResolvers<undefined>()
+ const releaseSecond = Promise.withResolvers<undefined>()
+ const sentSeqNos: number[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentSeqNos.push(payload.seqNo)
+ requestParams.onMessageSent?.()
+ if (payload.seqNo === 0) {
+ firstStarted.resolve(undefined)
+ await releaseFirst.promise
+ } else {
+ secondStarted.resolve(undefined)
+ await releaseSecond.promise
+ }
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
+ station,
OCPP20TransactionEventEnumType.Started,
OCPP20TriggerReasonEnumType.Authorized,
connectorId,
transactionId
)
+ online = true
+ const initialReplay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await firstStarted.promise
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
- OCPP20TransactionEventEnumType.Ended,
- OCPP20TriggerReasonEnumType.StopAuthorized,
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
+ releaseFirst.resolve(undefined)
+
+ await initialReplay
+ await secondStarted.promise
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.seqNo),
+ [1]
+ )
- const connectorStatus = mockStation.getConnectorStatus(connectorId)
- assert(connectorStatus != null)
- setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
- connectorStatus.locked = true
- assert.strictEqual(connectorStatus.transactionEventQueue?.length, 2)
-
- setOnline(true)
- await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, connectorId)
-
- assert.strictEqual(connectorStatus.transactionEventQueue.length, 0)
- assert.strictEqual(connectorStatus.transactionStarted, false)
- assert.strictEqual(connectorStatus.transactionId, undefined)
- assert.strictEqual(connectorStatus.locked, false)
+ releaseSecond.resolve(undefined)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.deepStrictEqual(sentSeqNos, [0, 1])
})
- await it('should preserve FIFO order when draining queue', async () => {
+ await it('should keep a later Ended event visible while replaying Started', async () => {
const connectorId = 1
const transactionId = generateUUID()
-
- setOnline(false)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
+ let online = false
+ let sawQueuedEnded = false
+ const stationHolder: { station?: ChargingStation } = {}
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ if (payload.eventType === OCPP20TransactionEventEnumType.Started) {
+ const replayStation = stationHolder.station
+ assert.ok(replayStation != null)
+ sawQueuedEnded =
+ replayStation
+ .getConnectorStatus(connectorId)
+ ?.transactionEventQueue?.some(
+ event => event.request.eventType === OCPP20TransactionEventEnumType.Ended
+ ) === true
+ }
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({} as EmptyObject)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ stationHolder.station = station
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ online = true
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(sawQueuedEnded, true)
+ })
+
+ await it('should clear queue and cleanup connector after sending', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert(connectorStatus != null)
+ setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
+ connectorStatus.locked = true
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 2)
+
+ setOnline(true)
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, connectorId)
+
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 0)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ })
+
+ await it('should keep every persisted replayed Ended snapshot consistent during post-transaction delay', async t => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const snapshots: {
+ connectorStatus: ConnectorStatus
+ eventTypes: OCPP20TransactionEventEnumType[]
+ status: ConnectorStatusEnum | undefined
+ transactionId: number | string | undefined
+ transactionStarted: boolean | undefined
+ }[] = []
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const command = args[1] as OCPP20RequestCommand
+ if (command === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ requestParams.onResponseReceived?.()
+ }
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ started: true,
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ postTransactionDelay: 3,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.locked = true
+ connectorStatus.status = ConnectorStatusEnum.Occupied
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ mock.method(station, 'saveTransactionEventQueues', () => {
+ snapshots.push({
+ connectorStatus: JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus,
+ eventTypes:
+ connectorStatus.transactionEventQueue?.map(event => event.request.eventType) ?? [],
+ status: connectorStatus.status,
+ transactionId: connectorStatus.transactionId,
+ transactionStarted: connectorStatus.transactionStarted,
+ })
+ })
+ online = true
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Occupied)
+ station.saveTransactionEventQueues()
+
+ t.mock.timers.tick(3000)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+ await replay
+ })
+
+ assert.ok(snapshots.length > 0)
+ for (const snapshot of snapshots) {
+ assert.strictEqual(
+ snapshot.eventTypes.includes(OCPP20TransactionEventEnumType.Ended),
+ false
+ )
+ assert.notStrictEqual(
+ snapshot.transactionStarted === true && snapshot.transactionId == null,
+ true
+ )
+ }
+ assert.deepStrictEqual(
+ {
+ eventTypes: snapshots[0].eventTypes,
+ status: snapshots[0].status,
+ transactionId: snapshots[0].transactionId,
+ transactionStarted: snapshots[0].transactionStarted,
+ },
+ {
+ eventTypes: [],
+ status: ConnectorStatusEnum.Occupied,
+ transactionId: undefined,
+ transactionStarted: false,
+ }
+ )
+ const finalSnapshot = snapshots.at(-1)
+ assert.deepStrictEqual(
+ finalSnapshot == null
+ ? undefined
+ : {
+ eventTypes: finalSnapshot.eventTypes,
+ status: finalSnapshot.status,
+ transactionId: finalSnapshot.transactionId,
+ transactionStarted: finalSnapshot.transactionStarted,
+ },
+ {
+ eventTypes: [],
+ status: ConnectorStatusEnum.Available,
+ transactionId: undefined,
+ transactionStarted: false,
+ }
+ )
+ const restoredConnectorStatus = prepareConnectorStatus(snapshots[0].connectorStatus)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, false)
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.connectors.set(1, restoredConnectorStatus)
+ const startUpdatedSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'startUpdatedMeterValues',
+ () => undefined
+ )
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(station)
+ assert.strictEqual(startUpdatedSpy.mock.callCount(), 0)
+ })
+
+ await it('should preserve FIFO order when draining queue', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
await OCPP20ServiceUtils.sendTransactionEvent(
mockStation,
OCPP20TransactionEventEnumType.Started,
connectorId,
transactionId
)
- // Online path sends minimal params (no seqNo in payload)
+ assert.strictEqual(sentRequests[0].payload.seqNo, 0)
assert.strictEqual(
sentRequests[0].payload.eventType,
OCPP20TransactionEventEnumType.Started
await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockStation, connectorId)
- // Queued events are pre-built payloads with seqNo (starts from 0 since
- // the online path with mock doesn't call buildTransactionEvent)
- assert.strictEqual(sentRequests[1].payload.seqNo, 0)
- assert.strictEqual(sentRequests[2].payload.seqNo, 1)
+ // Online and queued paths build the same wire payload, preserving
+ // sequence continuity across the connection boundary.
+ assert.strictEqual(sentRequests[1].payload.seqNo, 1)
+ assert.strictEqual(sentRequests[2].payload.seqNo, 2)
await OCPP20ServiceUtils.sendTransactionEvent(
mockStation,
transactionId
)
- // Online path sends minimal params (no seqNo in payload)
assert.strictEqual(sentRequests[3].payload.eventType, OCPP20TransactionEventEnumType.Ended)
-
- // Verify seqNo continuity for queued events (indices 1 and 2)
- assert.strictEqual(sentRequests[1].payload.seqNo, 0)
- assert.strictEqual(sentRequests[2].payload.seqNo, 1)
+ assert.strictEqual(sentRequests[3].payload.seqNo, 3)
// Verify total request count
assert.strictEqual(sentRequests.length, 4)
})
})
+ await it('should keep identical sequence numbers from successive offline transactions', async () => {
+ const connectorId = 1
+ const firstTransactionId = generateUUID()
+ const secondTransactionId = generateUUID()
+ setOnline(false)
+
+ for (const transactionId of [firstTransactionId, secondTransactionId]) {
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ }
+
+ const queue = mockStation.getConnectorStatus(connectorId)?.transactionEventQueue
+ assert.ok(queue != null)
+ assert.deepStrictEqual(
+ queue.map(event => [event.request.transactionInfo.transactionId, event.seqNo]),
+ [
+ [firstTransactionId, 0],
+ [firstTransactionId, 1],
+ [secondTransactionId, 0],
+ [secondTransactionId, 1],
+ ]
+ )
+ })
+
await describe('Multiple connectors with independent queues', async () => {
await it('should maintain separate queues for each connector', async () => {
const transactionId1 = generateUUID()
})
await describe('Error handling during queue drain', async () => {
- await it('should continue sending remaining events if one fails', async () => {
+ await it('should preserve a failed event and the remaining queue in order', async () => {
const connectorId = 1
const transactionId = generateUUID()
let callCount = 0
+ let online = false
const errorOnSecondMock = mock.fn(async () => {
callCount++
if (callCount === 2) {
- throw new Error('Network error on second event')
+ online = false
+ throw new Error('Connection lost on second event')
}
return Promise.resolve({} as EmptyObject)
})
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
+ addConfigurationKey(
+ errorStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
- errorStation.isWebSocketConnectionOpened = () => false
+ errorStation.isWebSocketConnectionOpened = () => online
OCPP20ServiceUtils.resetTransactionSequenceNumber(errorStation, connectorId)
transactionId
)
- errorStation.isWebSocketConnectionOpened = () => true
+ online = true
const connectorStatus = errorStation.getConnectorStatus(connectorId)
- if (connectorStatus != null) {
- connectorStatus.transactionStarted = true
- }
+ assert(connectorStatus != null)
+ connectorStatus.transactionStarted = true
await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId)
- assert.strictEqual(callCount, 4)
- })
- })
- })
-
- // ============================================================================
- // Periodic TransactionEvent Tests
- // ============================================================================
-
- await describe('E02 - OCPP 2.0.1 Periodic TransactionEvent at TxUpdatedInterval', async () => {
- let mockTracking: MockStationWithTracking
- let mockStation: ChargingStation
- let sentRequests: CapturedOCPPRequest[]
-
- beforeEach(() => {
- mockTracking = createMockStationWithRequestTracking()
- mockStation = mockTracking.station
- sentRequests = mockTracking.sentRequests
- })
-
- afterEach(() => {
- // Clean up any running timers
- for (let connectorId = 1; connectorId <= 3; connectorId++) {
- const connectorStatus = mockStation.getConnectorStatus(connectorId)
- if (connectorStatus?.transactionUpdatedMeterValuesSetInterval != null) {
- clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval)
- connectorStatus.transactionUpdatedMeterValuesSetInterval = undefined
- }
- }
- standardCleanup()
- })
-
- await describe('startUpdatedMeterValues', async () => {
- await it('should not start OCPP 2.0 timer for OCPP 1.6 stations via dispatch', async t => {
- await withMockTimers(t, ['setInterval'], () => {
- const { station: ocpp16Station } = createMockChargingStation({
- baseName: TEST_CHARGING_STATION_BASE_NAME,
- connectorsCount: 1,
- stationInfo: {
- ocppVersion: OCPPVersion.VERSION_16,
- },
- })
+ assert.strictEqual(callCount, 2)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Updated, OCPP20TransactionEventEnumType.Ended]
+ )
- startUpdatedMeterValues(ocpp16Station, 1, 60000)
+ online = true
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(errorStation, connectorId)
- const connectorStatus = ocpp16Station.getConnectorStatus(1)
- assert.strictEqual(connectorStatus?.transactionUpdatedMeterValuesSetInterval, undefined)
- })
+ assert.strictEqual(callCount, 4)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
})
- await it('should not start timer when interval is zero', () => {
+ await it('should retry an ambiguous open-socket send timeout reported before confirmation', async () => {
const connectorId = 1
+ const transactionId = generateUUID()
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport send timed out',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onTransportError?.(transportError, true)
+ return Promise.reject(transportError)
+ })
+ const { station: retryStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ retryStation.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(retryStation, connectorId, { transactionId })
+ const transportErrorCallback = mock.fn(
+ (_error: OCPPError, _deliveryAmbiguous: boolean) => undefined
+ )
- // Simulate startTxUpdatedInterval with zero interval
- const connectorStatus = mockStation.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
- assert(connectorStatus != null)
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ retryStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ {},
+ {
+ onTransportError: transportErrorCallback,
+ skipBufferingOnError: true,
+ throwError: true,
+ }
+ ),
+ /Transport send timed out/
+ )
- // Zero interval should not start timer
- // This is verified by the implementation logging debug message
- assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(transportErrorCallback.mock.callCount(), 2)
+ assert.ok(
+ transportErrorCallback.mock.calls.every(
+ call => call.arguments[0] === transportError && call.arguments[1]
+ )
+ )
+ const connectorStatus = retryStation.getConnectorStatus(connectorId)
+ assert(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
})
- await it('should not start timer when interval is negative', () => {
+ await it('should retain an event when a reported transport send failure closes the socket', async () => {
const connectorId = 1
- const connectorStatus = mockStation.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
- assert(connectorStatus != null)
+ const transactionId = generateUUID()
+ let online = true
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport disconnected during send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onTransportError?.(transportError, false)
+ online = false
+ return Promise.reject(transportError)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
- // Negative interval should not start timer
- assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.length,
+ 1
+ )
})
- await it('should handle non-existent connector gracefully', () => {
- const nonExistentConnectorId = 999
-
- // Should not throw for non-existent connector
- assert.doesNotThrow(() => {
- mockStation.getConnectorStatus(nonExistentConnectorId)
- })
-
- // Should return undefined for non-existent connector
- assert.strictEqual(mockStation.getConnectorStatus(nonExistentConnectorId), undefined)
- })
- })
-
- await describe('Periodic TransactionEvent generation', async () => {
- await it('should send TransactionEvent with MeterValuePeriodic trigger reason', async () => {
+ await it('should queue and replays a definitely not sent live event while the socket stays open', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport rejected the frame before writing it',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const replayCompleted = Promise.withResolvers<undefined>()
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ if (attempt++ === 0) {
+ requestParams.onTransportError?.(transportError, false)
+ return Promise.reject(transportError)
+ }
+ requestParams.onMessageSent?.()
+ replayCompleted.resolve(undefined)
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
- // Reset sequence number
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
- // Simulate sending periodic TransactionEvent (what the timer callback does)
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
+ station,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
transactionId
)
-
- // Verify the request was sent with correct trigger reason
- assert.strictEqual(sentRequests.length, 1)
- assert.strictEqual(sentRequests[0].command, OCPP20RequestCommand.TRANSACTION_EVENT)
- assert.strictEqual(
- sentRequests[0].payload.eventType,
- OCPP20TransactionEventEnumType.Updated
- )
- assert.strictEqual(
- sentRequests[0].payload.triggerReason,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic
- )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await replayCompleted.promise
+ await replay
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
})
- await it('should increment seqNo for each periodic event', () => {
+ await it('should retain a definitely-unsent direct Ended until explicit replay finalizes it', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const preSendFailure = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'Ended failed before transport send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ if (attempt++ === 0) return Promise.reject(preSendFailure)
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
- // Reset sequence number for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
- // Send initial Started event
- const startEvent = buildTransactionEvent(mockStation, {
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
connectorId,
- eventType: OCPP20TransactionEventEnumType.Started,
- transactionId,
- triggerReason: OCPP20TriggerReasonEnumType.Authorized,
- })
- assert.strictEqual(startEvent.seqNo, 0)
+ transactionId
+ )
- // Send multiple periodic events (simulating timer ticks)
- for (let i = 1; i <= 3; i++) {
- const periodicEvent = buildTransactionEvent(mockStation, {
- connectorId,
- eventType: OCPP20TransactionEventEnumType.Updated,
- transactionId,
- triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- })
- assert.strictEqual(periodicEvent.seqNo, i)
- }
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.eventType,
+ OCPP20TransactionEventEnumType.Ended
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
- // Verify sequence numbers are continuous: 0, 1, 2, 3
- const connectorStatus = mockStation.getConnectorStatus(connectorId)
- assert.strictEqual(connectorStatus?.transactionSeqNo, 3)
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
})
- await it('should maintain correct eventType (Updated) for periodic events', async () => {
- const connectorId = 2
+ await it('should discard a malformed queued head and sends the following event', async () => {
+ const connectorId = 1
const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
- // Send periodic event
+ let online = false
+ const attemptedSequenceNumbers: number[] = []
+ const malformedTriggerReason = 'Bogus' as OCPP20TriggerReasonEnumType
+ let successorPayload: OCPP20TransactionEventRequest | undefined
+ const malformedError = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'Malformed queued TransactionEvent',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ attemptedSequenceNumbers.push(payload.seqNo)
+ if (payload.triggerReason === malformedTriggerReason) {
+ requestParams.onError?.(malformedError, true)
+ return Promise.reject(malformedError)
+ }
+ successorPayload = payload
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
+ station,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transactionId
+ transactionId,
+ { meterValue: [intervalMeterValue] }
)
-
- // Verify eventType is Updated (not Started or Ended)
- assert.strictEqual(
- sentRequests[0].payload.eventType,
- OCPP20TransactionEventEnumType.Updated
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId
)
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const malformedHead = connectorStatus.transactionEventQueue?.[0]
+ assert.ok(malformedHead != null)
+ malformedHead.request.triggerReason = malformedTriggerReason
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.deepStrictEqual(attemptedSequenceNumbers, [0, 1])
+ assert.ok(successorPayload != null)
+ const successorIntervalEnergy =
+ successorPayload.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .reduce((total, sampledValue) => total + sampledValue.value, 0) ?? 0
+ assert.strictEqual(successorIntervalEnergy, 10)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
})
- await it('should include EVSE information in periodic events', async () => {
+ await it('should dispose a definitely not sent queued head after retries and sends the next event', async () => {
const connectorId = 1
const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
+ let online = false
+ const attemptedSequenceNumbers: number[] = []
+ let successorPayload: OCPP20TransactionEventRequest | undefined
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport rejected the queued frame before writing it',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ attemptedSequenceNumbers.push(payload.seqNo)
+ if (payload.seqNo === 0) {
+ requestParams.onTransportError?.(transportError, false)
+ return Promise.reject(transportError)
+ }
+ successorPayload = payload
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
+ station,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transactionId
+ transactionId,
+ { meterValue: [intervalMeterValue] }
)
-
- // Online path sends minimal params with connectorId (EVSE resolved by buildRequestPayload)
- assert.strictEqual(sentRequests[0].payload.connectorId, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 20,
+ },
+ ],
+ timestamp: new Date(2_000),
+ },
+ ],
+ }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.deepStrictEqual(attemptedSequenceNumbers, [0, 0, 1])
+ assert.ok(successorPayload != null)
+ const successorPublicKeys =
+ successorPayload.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(sampledValue => sampledValue.signedMeterValue?.publicKey)
+ .filter(publicKey => (publicKey?.length ?? 0) > 0) ?? []
+ assert.deepStrictEqual(successorPublicKeys, ['public-key'])
+ const successorIntervalEnergy =
+ successorPayload.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .reduce((total, sampledValue) => total + sampledValue.value, 0) ?? 0
+ assert.strictEqual(successorIntervalEnergy, 10)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
})
- await it('should include transactionInfo with correct transactionId', async () => {
+ await it('should not transfer a queued public key or energy after an ambiguous attempt precedes a definite local retry failure', async () => {
const connectorId = 1
const transactionId = generateUUID()
-
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
+ let online = false
+ let attempt = 0
+ const attemptedSequenceNumbers: number[] = []
+ let successorPayload: OCPP20TransactionEventRequest | undefined
+ const ambiguousFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Queued TransactionEvent outcome is ambiguous',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const localFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Queued TransactionEvent retry failed locally before writing',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ attemptedSequenceNumbers.push(payload.seqNo)
+ attempt++
+ if (payload.seqNo === 0 && attempt === 1) {
+ return Promise.resolve().then(() => {
+ requestParams.onTransportError?.(ambiguousFailure, true)
+ throw ambiguousFailure
+ })
+ }
+ if (payload.seqNo === 0) {
+ requestParams.onTransportError?.(localFailure, false)
+ return Promise.reject(localFailure)
+ }
+ successorPayload = payload
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
await OCPP20ServiceUtils.sendTransactionEvent(
- mockStation,
+ station,
OCPP20TransactionEventEnumType.Updated,
OCPP20TriggerReasonEnumType.MeterValuePeriodic,
connectorId,
- transactionId
+ transactionId,
+ { meterValue: [intervalMeterValue] }
)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 20,
+ },
+ ],
+ timestamp: new Date(2_000),
+ },
+ ],
+ }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
- // Online path sends minimal params with transactionId at top level
- assert.strictEqual(sentRequests[0].payload.transactionId, transactionId)
+ assert.deepStrictEqual(attemptedSequenceNumbers, [0, 0])
+ assert.strictEqual(successorPayload, undefined)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 2)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0]
+ .signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
})
- })
- await describe('Timer lifecycle integration', async () => {
- await it('should continue seqNo sequence across multiple periodic events', () => {
+ await it('should scale TransactionEvent retry delays by preceding transmissions', async () => {
const connectorId = 1
const transactionId = generateUUID()
-
- // Reset for new transaction
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
-
- // Simulate full transaction lifecycle with periodic updates
- // 1. Started event (seqNo: 0)
- const startEvent = buildTransactionEvent(mockStation, {
- connectorId,
- eventType: OCPP20TransactionEventEnumType.Started,
- transactionId,
- triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ const attemptTimes: number[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ attemptTimes.push(Number(process.hrtime.bigint() / 1_000_000n))
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.reject(new Error('CSMS rejected event'))
})
- assert.strictEqual(startEvent.seqNo, 0)
+ const { station: retryStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ retryStation.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(retryStation, connectorId, { transactionId })
- // 2. Multiple periodic updates (seqNo: 1, 2, 3)
- for (let i = 1; i <= 3; i++) {
- const updateEvent = buildTransactionEvent(mockStation, {
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ retryStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
connectorId,
- eventType: OCPP20TransactionEventEnumType.Updated,
transactionId,
- triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- })
- assert.strictEqual(updateEvent.seqNo, i)
- }
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ ),
+ /CSMS rejected event/
+ )
- // 3. Ended event (seqNo: 4)
- const endEvent = buildTransactionEvent(mockStation, {
- connectorId,
- eventType: OCPP20TransactionEventEnumType.Ended,
- transactionId,
- triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
- })
- assert.strictEqual(endEvent.seqNo, 4)
+ assert.strictEqual(attemptTimes.length, 3)
+ const intervals = attemptTimes.slice(1).map((time, index) => time - attemptTimes[index])
+ assert.ok(intervals[0] >= 900 && intervals[0] < 1500, intervals.join(','))
+ assert.ok(intervals[1] >= 1900 && intervals[1] < 2500, intervals.join(','))
})
- await it('should handle multiple connectors with independent timers', () => {
- const transactionId1 = generateUUID()
- const transactionId2 = generateUUID()
-
- // Reset sequence numbers for both connectors
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 1)
- OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 2)
-
- // Build events for connector 1
- const event1Start = buildTransactionEvent(mockStation, {
- connectorId: 1,
- eventType: OCPP20TransactionEventEnumType.Started,
- transactionId: transactionId1,
- triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ await it('should cancel a TransactionEvent retry delay when its lifecycle is aborted', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const lifecycleAbortController = new AbortController()
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ lifecycleAbortController.abort()
+ return Promise.reject(new Error('CSMS rejected event'))
})
- const event1Update = buildTransactionEvent(mockStation, {
- connectorId: 1,
- eventType: OCPP20TransactionEventEnumType.Updated,
- transactionId: transactionId1,
- triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- })
-
- // Build events for connector 2
- const event2Start = buildTransactionEvent(mockStation, {
- connectorId: 2,
- eventType: OCPP20TransactionEventEnumType.Started,
- transactionId: transactionId2,
- triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ const { station: retryStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
- const event2Update = buildTransactionEvent(mockStation, {
- connectorId: 2,
- eventType: OCPP20TransactionEventEnumType.Updated,
- transactionId: transactionId2,
- triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ Object.defineProperty(retryStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: lifecycleAbortController.signal,
})
+ retryStation.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '60',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(retryStation, connectorId, { transactionId })
- // Verify independent sequence numbers
- assert.strictEqual(event1Start.seqNo, 0)
- assert.strictEqual(event1Update.seqNo, 1)
- assert.strictEqual(event2Start.seqNo, 0)
- assert.strictEqual(event2Update.seqNo, 1)
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ retryStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ ),
+ /CSMS rejected event/
+ )
- // Verify different transaction IDs
- assert.strictEqual(event1Start.transactionInfo.transactionId, transactionId1)
- assert.strictEqual(event2Start.transactionInfo.transactionId, transactionId2)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
})
- })
- await describe('Error handling', async () => {
- await it('should handle network errors gracefully during periodic event', async () => {
- const { station: errorMockChargingStation } = createMockChargingStation({
+ await it('should keep a fresh Ended queued behind an ambiguous old lifecycle request', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const oldLifecycle = new AbortController()
+ let lifecycleAbortSignal = oldLifecycle.signal
+ const oldRequestStarted = Promise.withResolvers<undefined>()
+ const oldRequest = Promise.withResolvers<EmptyObject>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ sentEventTypes.push(request.eventType)
+ if (request.eventType === OCPP20TransactionEventEnumType.Started) {
+ oldRequestStarted.resolve(undefined)
+ return await oldRequest.promise
+ }
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
baseName: TEST_CHARGING_STATION_BASE_NAME,
connectorsCount: 1,
evseConfiguration: { evsesCount: 1 },
- ocppRequestService: {
- requestHandler: () => {
- throw new Error('Network timeout')
- },
- },
+ ocppRequestService: { requestHandler: requestHandlerMock },
stationInfo: {
ocppStrictCompliance: true,
ocppVersion: OCPPVersion.VERSION_201,
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleAbortSignal,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
- // Mock WebSocket as open
- errorMockChargingStation.isWebSocketConnectionOpened = () => true
+ const oldDeliveryResult = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ ).catch((error: unknown) => error)
+ await oldRequestStarted.promise
+
+ oldLifecycle.abort()
+ lifecycleAbortSignal = new AbortController().signal
+ const stopDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ await Promise.resolve()
+ assert.deepStrictEqual(sentEventTypes, [OCPP20TransactionEventEnumType.Started])
+
+ oldRequest.reject(new Error('old request cancelled'))
+ assert.match(String(await oldDeliveryResult), /old request cancelled/)
+ await stopDelivery
+
+ assert.deepStrictEqual(sentEventTypes, [OCPP20TransactionEventEnumType.Started])
+ assert.deepStrictEqual(
+ station
+ .getConnectorStatus(connectorId)
+ ?.transactionEventQueue?.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Ended]
+ )
+ })
+ await it('should queue an Ended waiter blocked behind a CALL when disconnect aborts delivery', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ const oldLifecycle = new AbortController()
+ Object.defineProperty(mockStation, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: oldLifecycle.signal,
+ })
+ const activeRequestStarted = Promise.withResolvers<undefined>()
+ const activeRequest = Promise.withResolvers<OCPP20TransactionEventResponse>()
+ const requestHandlerMock = mock.method(
+ mockStation.ocppRequestService,
+ 'requestHandler',
+ (async (...args: unknown[]): Promise<OCPP20TransactionEventResponse> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ activeRequestStarted.resolve(undefined)
+ return await activeRequest.promise
+ }) as typeof mockStation.ocppRequestService.requestHandler
+ )
+ let online = true
+ mockStation.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(mockStation, connectorId, { transactionId })
- try {
- await OCPP20ServiceUtils.sendTransactionEvent(
- errorMockChargingStation,
- OCPP20TransactionEventEnumType.Updated,
- OCPP20TriggerReasonEnumType.MeterValuePeriodic,
- connectorId,
- transactionId
+ const activeResult = OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ ).catch((error: unknown) => error)
+ await activeRequestStarted.promise
+ const endedWaiter = OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ online = false
+ oldLifecycle.abort()
+ activeRequest.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'old request cancelled',
+ OCPP20RequestCommand.TRANSACTION_EVENT
)
- throw new Error('Should have thrown network error')
- } catch (error) {
- assert.ok((error as Error).message.includes('Network timeout'))
- }
- })
- })
- })
+ )
+ const activeError = await activeResult
+ assert.ok(activeError instanceof Error)
+ assert.match(activeError.message, /old request cancelled/)
+ await assert.rejects(endedWaiter, /lifecycle generation was sealed/)
- await describe('getTxUpdatedInterval', async () => {
- let station: ChargingStation
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Ended]
+ )
+ })
- beforeEach(() => {
- const mockTracking = createMockStationWithRequestTracking()
- station = mockTracking.station
- resetLimits(station)
- })
+ await it('should serialize old replay drains and a fresh Ended without duplicate or reordered sends', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const oldLifecycle = new AbortController()
+ let lifecycleAbortSignal = oldLifecycle.signal
+ let online = false
+ let stopping = false
+ let handlerCallCount = 0
+ let activeHandlers = 0
+ let maximumActiveHandlers = 0
+ const firstRequestStarted = Promise.withResolvers<undefined>()
+ const firstRequest = Promise.withResolvers<EmptyObject>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ handlerCallCount++
+ activeHandlers++
+ maximumActiveHandlers = Math.max(maximumActiveHandlers, activeHandlers)
+ try {
+ if (handlerCallCount === 1) {
+ firstRequestStarted.resolve(undefined)
+ return await firstRequest.promise
+ }
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ sentEventTypes.push(request.eventType)
+ requestParams.onResponseReceived?.()
+ return {}
+ } finally {
+ activeHandlers--
+ }
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleAbortSignal,
+ })
+ station.isStopping = () => stopping
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ online = true
+
+ const firstReplay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await firstRequestStarted.promise
+ const secondReplay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ oldLifecycle.abort()
+ lifecycleAbortSignal = new AbortController().signal
+ stopping = true
+ const stopDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ await Promise.resolve()
+ assert.strictEqual(handlerCallCount, 1)
- afterEach(() => {
- OCPP20VariableManager.getInstance().resetRuntimeOverrides()
- standardCleanup()
- })
+ firstRequest.reject(new Error('old replay cancelled before send'))
+ await Promise.all([firstReplay, secondReplay, stopDelivery])
- await it('should return default interval when TxUpdatedInterval is not configured', () => {
- const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(maximumActiveHandlers, 1)
+ assert.strictEqual(handlerCallCount, 3)
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
- assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
- })
+ await it('should not let a local start seize a pending remote-start reservation', async () => {
+ const transactionId = generateUUID()
+ const requestHandlerMock = mock.fn(() => Promise.resolve({}))
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-REMOTE-RESERVATION`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionPending = true
+ connectorStatus.transactionIdTag = 'REMOTE-TOKEN'
+ connectorStatus.remoteStartId = 501
+
+ const result = await OCPP20ServiceUtils.startTransactionOnConnector(
+ station,
+ 1,
+ 'LOCAL-TOKEN'
+ )
- await it('should return configured interval in milliseconds', () => {
- OCPP20VariableManager.getInstance().setVariables(station, [
- {
- attributeType: AttributeEnumType.Actual,
- attributeValue: '60',
- component: { name: OCPP20ComponentName.SampledDataCtrlr },
- variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
- },
- ])
+ assert.strictEqual(result.accepted, false)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionPending, true)
+ assert.strictEqual(connectorStatus.transactionIdTag, 'REMOTE-TOKEN')
+ assert.strictEqual(connectorStatus.remoteStartId, 501)
+ assert.notStrictEqual(connectorStatus.transactionStarting, true)
+ })
- const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+ await it('should deliver a local Authorized start while transactionStarting owns the connector', async () => {
+ const sentRequests: OCPP20TransactionEventRequest[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentRequests.push(request)
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-LOCAL-START`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isWebSocketConnectionOpened = () => true
- assert.strictEqual(interval, 60000)
- })
+ const result = await OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1')
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
- await it('should return default for non-numeric value', () => {
- OCPP20VariableManager.getInstance().setVariables(station, [
- {
- attributeType: AttributeEnumType.Actual,
- attributeValue: 'abc',
- component: { name: OCPP20ComponentName.SampledDataCtrlr },
- variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
- },
- ])
+ assert.strictEqual(result.accepted, true)
+ assert.strictEqual(sentRequests.length, 1)
+ assert.strictEqual(sentRequests[0].eventType, OCPP20TransactionEventEnumType.Started)
+ assert.strictEqual(sentRequests[0].triggerReason, OCPP20TriggerReasonEnumType.Authorized)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(station, 1, 1)
+ OCPP20ServiceUtils.stopEndedMeterValues(station, 1, 1)
+ })
- const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+ await it('should roll back a local start that loses ownership behind the delivery chain', async () => {
+ const transactionId = generateUUID()
+ const firstRequestStarted = Promise.withResolvers<undefined>()
+ const releaseFirstRequest = Promise.withResolvers<EmptyObject>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentEventTypes.push(request.eventType)
+ requestParams.onMessageSent?.()
+ firstRequestStarted.resolve(undefined)
+ const response = await releaseFirstRequest.promise
+ requestParams.onResponseReceived?.()
+ return response
+ })
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-LOCAL-START-OWNERSHIP`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isWebSocketConnectionOpened = () => true
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = transactionId
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ 1,
+ transactionId
+ )
+ await firstRequestStarted.promise
+ const localStart = OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1')
+ connectorStatus.transactionEnding = true
+ releaseFirstRequest.resolve({})
+
+ await firstDelivery
+ const result = await localStart
+ assert.strictEqual(result.accepted, false)
+ assert.deepStrictEqual(sentEventTypes, [OCPP20TransactionEventEnumType.Updated])
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ })
- assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
- })
+ await it('should drop a queued Started after Ended seizes its pending transaction', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstRequestStarted = Promise.withResolvers<undefined>()
+ const releaseFirstRequest = Promise.withResolvers<EmptyObject>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentEventTypes.push(request.eventType)
+ requestParams.onMessageSent?.()
+ if (sentEventTypes.length === 1) {
+ firstRequestStarted.resolve(undefined)
+ const response = await releaseFirstRequest.promise
+ requestParams.onResponseReceived?.()
+ return response
+ }
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-STARTED-ENDED-ORDER`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ setupConnectorWithTransaction(station, connectorId, {
+ pending: true,
+ transactionId,
+ })
- await it('should return default for zero value', () => {
- OCPP20VariableManager.getInstance().setVariables(station, [
- {
- attributeType: AttributeEnumType.Actual,
- attributeValue: '0',
- component: { name: OCPP20ComponentName.SampledDataCtrlr },
- variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
- },
- ])
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await firstRequestStarted.promise
+ const startedDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.RemoteStart,
+ connectorId,
+ transactionId,
+ { evseId: 1 },
+ undefined,
+ undefined,
+ 'pending'
+ )
+ const endedDelivery = OCPP20ServiceUtils.requestStopTransaction(
+ station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ releaseFirstRequest.resolve({})
+
+ await firstDelivery
+ await assert.rejects(startedDelivery, /no longer owns its pending transaction/)
+ await endedDelivery
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ })
- const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+ await it('should recompute connectivity after waiting for the connector delivery chain', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstRequestStarted = Promise.withResolvers<undefined>()
+ const releaseFirstRequest = Promise.withResolvers<EmptyObject>()
+ const secondRequestStarted = Promise.withResolvers<undefined>()
+ const sentRequests: OCPP20TransactionEventRequest[] = []
+ let online = true
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentRequests.push(request)
+ requestParams.onMessageSent?.()
+ if (sentRequests.length === 1) {
+ firstRequestStarted.resolve(undefined)
+ const response = await releaseFirstRequest.promise
+ requestParams.onResponseReceived?.()
+ return response
+ }
+ secondRequestStarted.resolve(undefined)
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-CONNECTIVITY-CHAIN`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
- assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
- })
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await firstRequestStarted.promise
+ online = false
+ const delayedDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ online = true
+ const startupDrain = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId, 1)
+ releaseFirstRequest.resolve({})
+
+ await secondRequestStarted.promise
+ await Promise.all([firstDelivery, delayedDelivery, startupDrain])
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(sentRequests.length, 2)
+ assert.strictEqual(sentRequests[1].offline, undefined)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length ?? 0, 0)
+ })
- await it('should return default for negative value', () => {
- OCPP20VariableManager.getInstance().setVariables(station, [
+ await it('should not remove a replacement queue head after the delivered object was replaced', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ let replacement: NonNullable<ConnectorStatus['transactionEventQueue']>[number] | undefined
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue != null)
+ replacement = {
+ ...connectorStatus.transactionEventQueue[0],
+ request: { ...connectorStatus.transactionEventQueue[0].request },
+ }
+ connectorStatus.transactionEventQueue[0] = replacement
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue != null)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], replacement)
+ })
+
+ await it('should queue a sent event when station shutdown interrupts delivery', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let attemptedStatePersisted = false
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ assert.strictEqual(attemptedStatePersisted, true)
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.reject(new Error('shutdown interrupted delivery'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isStopping = () => true
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ station.persistTransactionEventQueues = () => {
+ attemptedStatePersisted =
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.[0]
+ .deliveryAttempted === true
+ return Promise.resolve()
+ }
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.seqNo),
+ [0]
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ })
+
+ await it('should preserve replayed events when station shutdown aborts delivery', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.reject(new Error('shutdown interrupted replay'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+ station.isStopping = () => true
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.seqNo),
+ [0]
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ })
+
+ await it('should not reschedule a preserved queue head after stopping begins', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = true
+ let stopping = false
+ let callCount = 0
+ const firstStarted = Promise.withResolvers<undefined>()
+ const releaseFirst = Promise.withResolvers<undefined>()
+ const replayAttempted = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ callCount++
+ requestParams.onMessageSent?.()
+ if (payload.seqNo === 0) {
+ firstStarted.resolve(undefined)
+ await releaseFirst.promise
+ requestParams.onResponseReceived?.()
+ return {}
+ }
+ replayAttempted.resolve(undefined)
+ if (callCount >= 3) online = false
+ throw new Error('shutdown interrupted replay')
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ station.isStopping = () => stopping
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const startedSend = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await firstStarted.promise
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+ stopping = true
+ releaseFirst.resolve(undefined)
+ await startedSend
+ await replayAttempted.promise
+ await new Promise(resolve => setImmediate(resolve))
+ await new Promise(resolve => setImmediate(resolve))
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(callCount, 2)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.seqNo),
+ [1]
+ )
+ })
+
+ await it('should retry a staged Ended after an earlier coalesced persistence failure', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const priorSave = Promise.withResolvers<undefined>()
+ const snapshots: OCPP20TransactionEventEnumType[][] = []
+ const priorSaveFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Prior coalesced save failed'
+ )
+ const requestHandlerMock = mock.fn(() => Promise.resolve({} as EmptyObject))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = false
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const stationInternals = station as unknown as {
+ pendingConfigurationSave: Promise<void>
+ saveConfiguration: (onError?: (error: Error) => void) => void
+ transactionEventQueueSaveDirty: boolean
+ transactionEventQueueSaveImmediate: boolean
+ }
+ stationInternals.pendingConfigurationSave = Promise.resolve()
+ stationInternals.transactionEventQueueSaveDirty = false
+ stationInternals.transactionEventQueueSaveImmediate = false
+ stationInternals.saveConfiguration = onError => {
+ snapshots.push(
+ (connectorStatus.transactionEventQueue ?? []).map(({ request }) => request.eventType)
+ )
+ stationInternals.pendingConfigurationSave =
+ snapshots.length === 1
+ ? priorSave.promise.then(() => {
+ onError?.(priorSaveFailure)
+ return undefined
+ })
+ : Promise.resolve()
+ }
+ station.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(station)
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+ station.saveTransactionEventQueues()
+ assert.deepStrictEqual(snapshots, [[]])
+
+ const ended = OCPP20ServiceUtils.requestStopTransaction(station, connectorId)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+
+ priorSave.resolve(undefined)
+ await ended
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ })
+
+ await it('should wait for staged Ended retry persistence before sending', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const writeFailure = Object.assign(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'No space left while persisting Ended',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ ),
+ { code: 'ENOSPC' }
+ )
+ let saveAttempt = 0
+ const retrySave = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(() => Promise.resolve({} as EmptyObject))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = false
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const stationInternals = station as unknown as {
+ pendingConfigurationSave: Promise<void>
+ saveConfiguration: (onError?: (error: Error) => void) => void
+ transactionEventQueueSaveDirty: boolean
+ transactionEventQueueSaveImmediate: boolean
+ }
+ stationInternals.pendingConfigurationSave = Promise.resolve()
+ stationInternals.transactionEventQueueSaveDirty = false
+ stationInternals.transactionEventQueueSaveImmediate = false
+ stationInternals.saveConfiguration = onError => {
+ saveAttempt++
+ stationInternals.pendingConfigurationSave =
+ saveAttempt === 1
+ ? Promise.resolve().then(() => {
+ onError?.(writeFailure)
+ return undefined
+ })
+ : saveAttempt === 2
+ ? retrySave.promise
+ : Promise.resolve()
+ }
+ station.saveTransactionEventQueues =
+ ChargingStation.prototype.saveTransactionEventQueues.bind(station)
+ station.persistTransactionEventQueues =
+ ChargingStation.prototype.persistTransactionEventQueues.bind(station)
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(station, connectorId)
+ await flushMicrotasks()
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(saveAttempt, 2)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+
+ retrySave.resolve(undefined)
+ await stopped
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ })
+
+ await it('should persist a key transfer into a waiting unattempted Ended before transport', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const replayStarted = Promise.withResolvers<undefined>()
+ const releaseReplay = Promise.withResolvers<undefined>()
+ const replayFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'definitely unsent replay',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const transportedRequests: OCPP20TransactionEventRequest[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ if (request.eventType === OCPP20TransactionEventEnumType.Updated) {
+ replayStarted.resolve(undefined)
+ await releaseReplay.promise
+ requestParams.onTransportError?.(replayFailure, false)
+ throw replayFailure
+ }
+ transportedRequests.push(structuredClone(request))
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ station.isStopping = () => false
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const updatedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'updated-signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(updatedMeterValue, 'periodic', 10)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [updatedMeterValue] }
+ )
+ online = true
+ const snapshots: { deliveryAttempted?: boolean; request: string; seqNo: number }[][] = []
+ station.persistTransactionEventQueues = () => {
+ snapshots.push(
+ (connectorStatus.transactionEventQueue ?? []).map(event => ({
+ deliveryAttempted: event.deliveryAttempted,
+ request: JSON.stringify(event.request),
+ seqNo: event.seqNo,
+ }))
+ )
+ return Promise.resolve()
+ }
+ station.saveTransactionEventQueues = () => undefined
+
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await replayStarted.promise
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'ended-signed-data',
+ signingMethod: '',
+ },
+ value: 2,
+ },
+ ],
+ timestamp: new Date(2_000),
+ },
+ ],
+ }
+ )
+ await flushMicrotasks()
+
+ const unattemptedSnapshot = snapshots.find(snapshot =>
+ snapshot.some(event => event.seqNo === 1 && event.deliveryAttempted === false)
+ )
+ assert.ok(unattemptedSnapshot != null)
+ const restored = preparePersistedConnectorStatus(station, {
+ transactionEventQueue: unattemptedSnapshot.map(event => ({
+ deliveryAttempted: event.deliveryAttempted,
+ request: JSON.parse(event.request) as OCPP20TransactionEventRequest,
+ seqNo: event.seqNo,
+ timestamp: new Date(),
+ })),
+ } as ConnectorStatus)
+ assert.strictEqual(restored.transactionEventQueue?.[1].deliveryAttempted, false)
+ assert.strictEqual(transportedRequests.length, 0)
+
+ releaseReplay.resolve(undefined)
+ await Promise.all([replay, ended])
+
+ assert.strictEqual(transportedRequests.length, 1)
+ const attemptedSnapshot = snapshots.find(snapshot =>
+ snapshot.some(event => {
+ if (event.seqNo !== 1 || event.deliveryAttempted !== true) return false
+ const request = JSON.parse(event.request) as OCPP20TransactionEventRequest
+ return (
+ request.meterValue?.some(meterValue =>
+ meterValue.sampledValue.some(
+ sample => sample.signedMeterValue?.publicKey === 'public-key'
+ )
+ ) === true
+ )
+ })
+ )
+ assert.ok(attemptedSnapshot != null)
+ const persistedEnded = attemptedSnapshot.find(event => event.seqNo === 1)
+ assert.strictEqual(persistedEnded?.request, JSON.stringify(transportedRequests[0]))
+ assert.deepStrictEqual(
+ transportedRequests[0].meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .map(sample => sample.value),
+ [10]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ })
+
+ await it('should immediately replay Ended after a write-ahead rollback is persisted', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const writeFailure = new Error('final write-ahead failed')
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.isStopping = () => false
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ let persistenceCalls = 0
+ const persistedAttemptStates: (boolean | undefined)[] = []
+ station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ persistedAttemptStates.push(connectorStatus.transactionEventQueue?.[0].deliveryAttempted)
+ return persistenceCalls === 2 ? Promise.reject(writeFailure) : Promise.resolve()
+ }
+ station.saveTransactionEventQueues = () => undefined
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.deepStrictEqual(persistedAttemptStates, [false, true, false, true])
+ })
+
+ await it('should reject when the unattempted marker cannot be persisted after a final write-ahead failure', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const rollbackFailure = new Error('unattempted marker persistence failed')
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ const terminalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 12,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(terminalMeterValue, intervalBaselineKey, 12)
+ const requestHandlerMock = mock.fn(() => Promise.resolve({} as EmptyObject))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.isStopping = () => false
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ let persistenceCalls = 0
+ station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ return persistenceCalls >= 2 ? Promise.reject(rollbackFailure) : Promise.resolve()
+ }
+ station.saveTransactionEventQueues = () => undefined
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId,
+ { meterValue: [terminalMeterValue] }
+ ),
+ error => error === rollbackFailure || (error as Error).message === rollbackFailure.message
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [intervalBaselineKey]: 12,
+ })
+ })
+
+ await it('should retain direct Ended until response handling completes before persisting cleanup', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const responseReceived = Promise.withResolvers<undefined>()
+ const releaseResponseHandler = Promise.withResolvers<undefined>()
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ responseReceived.resolve(undefined)
+ await releaseResponseHandler.promise
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ postTransactionDelay: 0,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.inAcceptedState = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const snapshots: {
+ eventTypes: OCPP20TransactionEventEnumType[]
+ transactionId: number | string | undefined
+ transactionStarted: boolean | undefined
+ }[] = []
+ mock.method(station, 'saveTransactionEventQueues', () => {
+ snapshots.push({
+ eventTypes:
+ connectorStatus.transactionEventQueue?.map(event => event.request.eventType) ?? [],
+ transactionId: connectorStatus.transactionId,
+ transactionStarted: connectorStatus.transactionStarted,
+ })
+ })
+
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ await responseReceived.promise
+
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ assert.deepStrictEqual(snapshots.at(-1)?.eventTypes, [OCPP20TransactionEventEnumType.Ended])
+
+ releaseResponseHandler.resolve(undefined)
+ await delivery
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.deepStrictEqual(snapshots.at(-1), {
+ eventTypes: [],
+ transactionId: undefined,
+ transactionStarted: false,
+ })
+ assert.strictEqual(
+ snapshots.some(
+ snapshot =>
+ snapshot.eventTypes.length === 0 &&
+ snapshot.transactionId != null &&
+ snapshot.transactionStarted === true
+ ),
+ false
+ )
+ })
+
+ await it('should commit an acknowledged direct Ended before observing a sealed lifecycle', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const oldLifecycle = new AbortController()
+ let lifecycleAbortSignal = oldLifecycle.signal
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ oldLifecycle.abort()
+ lifecycleAbortSignal = new AbortController().signal
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ postTransactionDelay: 0,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleAbortSignal,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.inAcceptedState = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const persistedEventTypes: OCPP20TransactionEventEnumType[][] = []
+ mock.method(station, 'saveTransactionEventQueues', () => {
+ persistedEventTypes.push(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType) ?? []
+ )
+ })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ ),
+ /lifecycle generation was sealed/
+ )
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.deepStrictEqual(persistedEventTypes.at(-1), [])
+ })
+
+ await it('should retain an attempted event when local response handling fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = true
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ online = false
+ return Promise.reject(new Error('local response handler failed'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const queuedEvent = station.getConnectorStatus(connectorId)?.transactionEventQueue?.[0]
+ assert.ok(queuedEvent != null)
+ assert.strictEqual(queuedEvent.deliveryAttempted, true)
+ })
+
+ await it('should not send after a delayed write-ahead crosses a sealed lifecycle generation', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(() => Promise.resolve({} as EmptyObject))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.isStopping = () => false
+ const lifecycleAbortController = new AbortController()
+ ;(station as unknown as { lifecycleAbortSignal: AbortSignal }).lifecycleAbortSignal =
+ lifecycleAbortController.signal
+ station.saveTransactionEventQueues = () => undefined
+ station.persistTransactionEventQueues = () => {
+ persistenceStarted.resolve(undefined)
+ return releasePersistence.promise
+ }
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await persistenceStarted.promise
+ lifecycleAbortController.abort()
+ ;(station as unknown as { lifecycleAbortSignal: AbortSignal }).lifecycleAbortSignal =
+ new AbortController().signal
+ releasePersistence.resolve(undefined)
+
+ await assert.rejects(delivery, /lifecycle generation was sealed/)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.length,
+ 1
+ )
+ })
+
+ await it('should retain an ambiguous older Ended without cleaning up the current transaction', async () => {
+ const connectorId = 1
+ const firstTransactionId = generateUUID()
+ const currentTransactionId = generateUUID()
+ let online = false
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.reject(new Error('CSMS rejected historical Ended event'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId: firstTransactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ firstTransactionId
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId: currentTransactionId })
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionId, currentTransactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.transactionInfo.transactionId,
+ firstTransactionId
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ })
+
+ await it('should insert an interrupted in-flight event before newer offline events', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = true
+ const firstAttemptStarted = Promise.withResolvers<undefined>()
+ const firstAttempt = Promise.withResolvers<EmptyObject>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ firstAttemptStarted.resolve(undefined)
+ return firstAttempt.promise
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const inFlight = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+ await firstAttemptStarted.promise
+ online = false
+ const laterOfflineEvent = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.map(event => event.seqNo),
+ [0, 1]
+ )
+ await laterOfflineEvent
+ firstAttempt.reject(new Error('connection lost'))
+ await inFlight
+
+ assert.deepStrictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.map(event => event.seqNo),
+ [0, 1]
+ )
+ assert.deepStrictEqual(
+ station
+ .getConnectorStatus(connectorId)
+ ?.transactionEventQueue?.map(event => event.request.offline),
+ [undefined, true]
+ )
+ })
+
+ await it('should preserve cross-transaction FIFO when replay is interrupted', async () => {
+ const connectorId = 1
+ const firstTransactionId = generateUUID()
+ const secondTransactionId = generateUUID()
+ let online = false
+ const firstReplayStarted = Promise.withResolvers<undefined>()
+ const firstReplay = Promise.withResolvers<EmptyObject>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ firstReplayStarted.resolve(undefined)
+ return firstReplay.promise
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId: firstTransactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ firstTransactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ firstTransactionId
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId: secondTransactionId })
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ secondTransactionId
+ )
+ online = true
+
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await firstReplayStarted.promise
+ online = false
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ secondTransactionId
+ )
+ firstReplay.reject(new Error('connection lost'))
+ await replay
+
+ const queue = station.getConnectorStatus(connectorId)?.transactionEventQueue
+ assert.deepStrictEqual(
+ queue?.map(event => [event.request.transactionInfo.transactionId, event.seqNo]),
+ [
+ [firstTransactionId, 0],
+ [firstTransactionId, 1],
+ [secondTransactionId, 0],
+ [secondTransactionId, 1],
+ ]
+ )
+ })
+
+ await it('should drain queued events before a concurrent live event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const firstReplayStarted = Promise.withResolvers<undefined>()
+ const releaseFirstReplay = Promise.withResolvers<undefined>()
+ const sentSequenceNumbers: number[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as { seqNo: number }
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ sentSequenceNumbers.push(payload.seqNo)
+ if (sentSequenceNumbers.length === 1) {
+ firstReplayStarted.resolve(undefined)
+ await releaseFirstReplay.promise
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ online = true
+
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await firstReplayStarted.promise
+ const live = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId
+ )
+ releaseFirstReplay.resolve(undefined)
+ await Promise.all([replay, live])
+
+ assert.deepStrictEqual(sentSequenceNumbers, [0, 1, 2])
+ })
+
+ await it('should drain an existing queue before sending a later live event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setOnline(false)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ setOnline(true)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ assert.deepStrictEqual(
+ sentRequests.map(request => request.payload.seqNo),
+ [0, 1, 2]
+ )
+ assert.deepStrictEqual(
+ mockStation.getConnectorStatus(connectorId)?.transactionEventQueue,
+ []
+ )
+ })
+
+ await it('should not let a later aligned event overtake a serialized live event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseFirstDelivery = Promise.withResolvers<undefined>()
+ const sentSequenceNumbers: number[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ sentSequenceNumbers.push(payload.seqNo)
+ if (payload.seqNo === 0) {
+ firstDeliveryStarted.resolve(undefined)
+ await releaseFirstDelivery.promise
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const first = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Trigger,
+ connectorId,
+ transactionId
+ )
+ await firstDeliveryStarted.promise
+ const second = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+
+ releaseFirstDelivery.resolve(undefined)
+ await Promise.all([first, second])
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+
+ assert.deepStrictEqual(sentSequenceNumbers, [0, 1, 2])
+ })
+
+ await it('should complete an Updated retry before a concurrent Ended event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstAttemptStarted = Promise.withResolvers<undefined>()
+ const releaseFirstAttempt = Promise.withResolvers<undefined>()
+ const sentSequenceNumbers: number[] = []
+ let updatedAttempts = 0
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as {
+ eventType: OCPP20TransactionEventEnumType
+ seqNo: number
+ }
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ sentSequenceNumbers.push(payload.seqNo)
+ if (
+ payload.eventType === OCPP20TransactionEventEnumType.Updated &&
+ updatedAttempts++ === 0
+ ) {
+ firstAttemptStarted.resolve(undefined)
+ await releaseFirstAttempt.promise
+ throw new Error('First attempt rejected')
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const updated = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ {},
+ { skipBufferingOnError: true, throwError: true }
+ )
+ await firstAttemptStarted.promise
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ releaseFirstAttempt.resolve(undefined)
+ await Promise.all([updated, ended])
+
+ assert.deepStrictEqual(sentSequenceNumbers, [0, 0, 1])
+ })
+
+ for (const callError of [false, true]) {
+ await it(`uses the exact E13 attempt count and linear delays for ${callError ? 'CALLERROR before send confirmation' : 'timeout'}`, async t => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ const rejection = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ callError ? 'CSMS rejected TransactionEvent' : 'TransactionEvent response timed out',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ if (!callError) requestParams.onMessageSent?.()
+ requestParams.onError?.(rejection, callError)
+ return Promise.reject(rejection)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
+
+ await withMockTimers(t, ['setTimeout'], async () => {
+ const delivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [intervalMeterValue] },
+ { skipBufferingOnError: true, throwError: true }
+ )
+ const rejectedDelivery = assert.rejects(delivery, /TransactionEvent/)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+
+ t.mock.timers.tick(999)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ t.mock.timers.tick(1)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+
+ t.mock.timers.tick(1_999)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ t.mock.timers.tick(1)
+ await flushMicrotasks()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ await rejectedDelivery
+ assert.deepStrictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEnergyActiveImportIntervalCarry,
+ callError ? { [intervalBaselineKey]: 10 } : undefined
+ )
+ })
+ })
+ }
+
+ await it('should retain a pre-send-failed public-key event ahead of later queued work', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const successfulPayloads: OCPP20TransactionEventRequest[] = []
+ const localFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Local validation failed before send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const deliveryContext: {
+ connectorStatus?: ConnectorStatus
+ station?: ChargingStation
+ } = {}
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ attempt++
+ if (attempt === 1) {
+ assert.ok(deliveryContext.station != null)
+ assert.ok(deliveryContext.connectorStatus != null)
+ const successorRequest = buildTransactionEvent(deliveryContext.station, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'second-signed-data',
+ signingMethod: '',
+ },
+ value: 20,
+ },
+ ],
+ timestamp: new Date(2_000),
+ },
+ ],
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ enqueueBoundedTransactionEvent(deliveryContext.connectorStatus, {
+ request: successorRequest,
+ seqNo: successorRequest.seqNo,
+ timestamp: successorRequest.timestamp,
+ })
+ return Promise.reject(localFailure)
+ }
+ successfulPayloads.push(payload)
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const result = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { meteringPerTransaction: true, ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const { station } = result
+ deliveryContext.station = station
+ resetConnectorTransactionState(station)
+ resetLimits(station)
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const activeConnectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(activeConnectorStatus != null)
+ const connectorStatus = activeConnectorStatus
+ deliveryContext.connectorStatus = connectorStatus
+ const firstMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'first-signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [firstMeterValue] },
+ { throwError: true }
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 2)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0]
+ .signedMeterValue?.publicKey,
+ 'public-key'
+ )
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(successfulPayloads.length, 2)
+ assert.strictEqual(
+ successfulPayloads[0].meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.strictEqual(
+ successfulPayloads[1].meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ ''
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ })
+
+ await it('should keep public-key ownership and drops energy after an ambiguous attempt precedes a definite local retry failure', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ const ambiguousFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent outcome is ambiguous',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const localFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'TransactionEvent retry failed locally before writing',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const successfulPayloads: OCPP20TransactionEventRequest[] = []
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ attempt++
+ if (attempt === 1) {
+ return Promise.resolve().then(() => {
+ requestParams.onTransportError?.(ambiguousFailure, true)
+ throw ambiguousFailure
+ })
+ }
+ if (attempt === 2) {
+ requestParams.onTransportError?.(localFailure, false)
+ return Promise.reject(localFailure)
+ }
+ successfulPayloads.push(payload)
+ requestParams.onMessageSent?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ meteringPerTransaction: true,
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(evseStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: 'Wh',
+ },
+ ] as unknown as EvseStatus['MeterValues']
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ intervalBaselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [intervalBaselineKey]: 0,
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const ambiguousMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(0),
+ }
+ recordTransactionIntervalConsumption(ambiguousMeterValue, intervalBaselineKey, 10)
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [ambiguousMeterValue] }
+ ),
+ /TransactionEvent retry failed locally/
+ )
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ const nextSignedSample = buildOCPP20SampledValue(
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '0',
+ } as unknown as SampledValueTemplate,
+ 20,
+ undefined,
+ undefined,
+ {
+ enabled: true,
+ meterSerialNumber: 'meter-1',
+ publicKeyHex: 'public-key',
+ publicKeySentInTransaction: connectorStatus.publicKeySentInTransaction,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ transactionId,
+ }
+ )
+ assert.strictEqual(nextSignedSample.publicKeyIncluded, false)
+ assert.strictEqual(nextSignedSample.sampledValue.signedMeterValue?.publicKey, '')
+ const successorMeterValue = OCPP20ServiceUtils.buildTransactionMeterValue(
+ station,
+ connectorId,
+ 1,
+ transactionId,
+ 60_000,
+ intervalBaselineKey,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ new Date(0),
+ 60_000
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [successorMeterValue] }
+ )
+
+ assert.strictEqual(successfulPayloads.length, 1)
+ const successorIntervalEnergy =
+ successfulPayloads[0].meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .reduce((total, sampledValue) => total + sampledValue.value, 0) ?? 0
+ assert.strictEqual(successorIntervalEnergy, 0)
+ })
+
+ await it('should not carry interval energy when a timeout precedes exhausted CALLERROR retries', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ const callError = ++attempt > 1
+ const rejection = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ callError ? 'CSMS rejected TransactionEvent' : 'TransactionEvent response timed out',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(rejection, callError)
+ return Promise.reject(rejection)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [intervalMeterValue] },
+ { skipBufferingOnError: true, throwError: true }
+ ),
+ /TransactionEvent/
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ assert.strictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEnergyActiveImportIntervalCarry,
+ undefined
+ )
+ })
+
+ await it('should keep a prior CALLERROR classification when the final retry is definitely unsent', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ let attempt = 0
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ const failure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ attempt === 0 ? 'CSMS rejected TransactionEvent' : 'Transport failed before send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ if (attempt++ === 0) {
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(failure, true)
+ } else {
+ requestParams.onTransportError?.(failure, false)
+ }
+ return Promise.reject(failure)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const meterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(meterValue, intervalBaselineKey, 10)
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [meterValue] },
+ { throwError: true }
+ ),
+ /Transport failed before send/
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [intervalBaselineKey]: 10,
+ })
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ for (const confirmedRejected of [false, true]) {
+ await it(`carries exhausted queued Updated energy only after ${confirmedRejected ? 'CALLERROR' : 'an ambiguous timeout'}`, async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const deliveryError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ confirmedRejected ? 'CALLERROR' : 'Timeout',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(deliveryError, confirmedRejected)
+ return Promise.reject(deliveryError)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const intervalBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ const intervalMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(intervalMeterValue, intervalBaselineKey, 10)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [intervalMeterValue] }
+ )
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ if (confirmedRejected) {
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [intervalBaselineKey]: 10,
+ })
+ } else {
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry,
+ undefined
+ )
+ }
+ })
+ }
+
+ await it('should count same-transaction direct deliveries across the serialization wait', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstDeliveryStarted = Promise.withResolvers<undefined>()
+ const secondDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseFirstDelivery = Promise.withResolvers<undefined>()
+ const releaseSecondDelivery = Promise.withResolvers<undefined>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ sentEventTypes.push(payload.eventType)
+ if (sentEventTypes.length === 1) {
+ firstDeliveryStarted.resolve(undefined)
+ await releaseFirstDelivery.promise
+ } else {
+ secondDeliveryStarted.resolve(undefined)
+ await releaseSecondDelivery.promise
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+
+ const started = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await firstDeliveryStarted.promise
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(connectorStatus, transactionId),
+ true
+ )
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+
+ releaseFirstDelivery.resolve(undefined)
+ await secondDeliveryStarted.promise
+ await started
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(connectorStatus, transactionId),
+ true
+ )
+
+ releaseSecondDelivery.resolve(undefined)
+ await ended
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(connectorStatus, transactionId),
+ false
+ )
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ })
+
+ await it('should scope parallel Updated deliveries by connector and transaction', async () => {
+ const firstTransactionId = generateUUID()
+ const secondTransactionId = generateUUID()
+ const firstDeliveryStarted = Promise.withResolvers<undefined>()
+ const secondDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseFirstDelivery = Promise.withResolvers<undefined>()
+ const releaseSecondDelivery = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ if (payload.transactionInfo.transactionId === firstTransactionId) {
+ firstDeliveryStarted.resolve(undefined)
+ await releaseFirstDelivery.promise
+ } else {
+ secondDeliveryStarted.resolve(undefined)
+ await releaseSecondDelivery.promise
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, 1, { transactionId: firstTransactionId })
+ setupConnectorWithTransaction(station, 2, { transactionId: secondTransactionId })
+ const firstConnectorStatus = station.getConnectorStatus(1)
+ const secondConnectorStatus = station.getConnectorStatus(2)
+ assert.ok(firstConnectorStatus != null)
+ assert.ok(secondConnectorStatus != null)
+
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ 1,
+ firstTransactionId
+ )
+ const secondDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ 2,
+ secondTransactionId
+ )
+ await Promise.all([firstDeliveryStarted.promise, secondDeliveryStarted.promise])
+
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ firstConnectorStatus,
+ firstTransactionId
+ ),
+ true
+ )
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ firstConnectorStatus,
+ secondTransactionId
+ ),
+ false
+ )
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ secondConnectorStatus,
+ secondTransactionId
+ ),
+ true
+ )
+
+ releaseFirstDelivery.resolve(undefined)
+ await firstDelivery
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ firstConnectorStatus,
+ firstTransactionId
+ ),
+ false
+ )
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(
+ secondConnectorStatus,
+ secondTransactionId
+ ),
+ true
+ )
+
+ releaseSecondDelivery.resolve(undefined)
+ await secondDelivery
+ assert.strictEqual(
+ OCPP20ServiceUtils.hasPendingTransactionEventDelivery(secondConnectorStatus),
+ false
+ )
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(firstConnectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(secondConnectorStatus.transactionEventQueue, undefined)
+ })
+
+ for (const eventType of [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Updated,
+ ]) {
+ await it(
+ 'replays a failed ' + eventType + ' before an Ended event waiting for delivery',
+ async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstAttemptStarted = Promise.withResolvers<undefined>()
+ const releaseFirstAttempt = Promise.withResolvers<undefined>()
+ const deliveryTrace: [OCPP20TransactionEventEnumType, number][] = []
+ let attempts = 0
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport failed during send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ deliveryTrace.push([payload.eventType, payload.seqNo])
+ if (attempts++ === 0) {
+ firstAttemptStarted.resolve(undefined)
+ await releaseFirstAttempt.promise
+ requestParams.onTransportError?.(transportError, false)
+ throw transportError
+ }
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const preceding = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ eventType,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await firstAttemptStarted.promise
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+
+ releaseFirstAttempt.resolve(undefined)
+ await Promise.all([preceding, ended])
+
+ assert.deepStrictEqual(deliveryTrace, [
+ [eventType, 0],
+ [eventType, 0],
+ [OCPP20TransactionEventEnumType.Ended, 1],
+ ])
+ assert.deepStrictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue ?? [],
+ []
+ )
+ }
+ )
+ }
+
+ await it('should not replay a later queued Updated before a waiting Ended event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstAttemptStarted = Promise.withResolvers<undefined>()
+ const releaseFirstAttempt = Promise.withResolvers<undefined>()
+ const sentSequenceNumbers: number[] = []
+ let attempts = 0
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport failed during send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentSequenceNumbers.push(payload.seqNo)
+ if (attempts++ === 0) {
+ firstAttemptStarted.resolve(undefined)
+ await releaseFirstAttempt.promise
+ requestParams.onTransportError?.(transportError, false)
+ throw transportError
+ }
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const preceding = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId
+ )
+ await firstAttemptStarted.promise
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ releaseFirstAttempt.resolve(undefined)
+ await Promise.all([preceding, ended])
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+
+ assert.deepStrictEqual(sentSequenceNumbers, [0, 0, 1, 2])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should queue periodic updates without stacking delivery promises', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseFirstDelivery = Promise.withResolvers<undefined>()
+ const secondDeliveryCompleted = Promise.withResolvers<undefined>()
+ const sentSequenceNumbers: number[] = []
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ sentSequenceNumbers.push(payload.seqNo)
+ if (payload.seqNo === 0) {
+ firstDeliveryStarted.resolve(undefined)
+ await releaseFirstDelivery.promise
+ } else {
+ secondDeliveryCompleted.resolve(undefined)
+ }
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await firstDeliveryStarted.promise
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.deepStrictEqual(
+ station
+ .getConnectorStatus(connectorId)
+ ?.transactionEventQueue?.map(queuedEvent => queuedEvent.seqNo),
+ [0, 1]
+ )
+ releaseFirstDelivery.resolve(undefined)
+ await Promise.all([firstDelivery, secondDeliveryCompleted.promise])
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+
+ assert.deepStrictEqual(sentSequenceNumbers, [0, 1])
+ assert.deepStrictEqual(station.getConnectorStatus(connectorId)?.transactionEventQueue, [])
+ })
+
+ await it('should replay a retained public-key event only after an explicit wake', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const firstDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseFirstDelivery = Promise.withResolvers<undefined>()
+ const secondDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseSecondDelivery = Promise.withResolvers<undefined>()
+ const preSendFailure = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'TransactionEvent failed before send',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ let deliveredPayload: OCPP20TransactionEventRequest | undefined
+ let attempt = 0
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ attempt++
+ if (attempt === 1) {
+ firstDeliveryStarted.resolve(undefined)
+ await releaseFirstDelivery.promise
+ throw preSendFailure
+ }
+ deliveredPayload = payload
+ secondDeliveryStarted.resolve(undefined)
+ await releaseSecondDelivery.promise
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const signedIntervalMeterValue = (publicKey: string): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(),
+ })
+ const firstDelivery = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [signedIntervalMeterValue('public-key')] }
+ )
+ await firstDeliveryStarted.promise
+ const laterClockEvent = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ { meterValue: [signedIntervalMeterValue('')] }
+ )
+ await flushMicrotasks()
+ assert.deepStrictEqual(
+ station.getConnectorStatus(connectorId)?.transactionEventQueue?.map(event => event.seqNo),
+ [0, 1]
+ )
+ await laterClockEvent
+ releaseFirstDelivery.resolve(undefined)
+ await firstDelivery
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await secondDeliveryStarted.promise
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ assert.ok(
+ deliveredPayload?.meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .some(sampledValue => sampledValue.signedMeterValue?.publicKey === 'public-key') ===
+ true
+ )
+ const nextSignedSample = buildOCPP20SampledValue(
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '0',
+ } as unknown as SampledValueTemplate,
+ 20,
+ undefined,
+ undefined,
+ {
+ enabled: true,
+ meterSerialNumber: 'meter-1',
+ publicKeyHex: 'public-key',
+ publicKeySentInTransaction: connectorStatus.publicKeySentInTransaction,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ transactionId,
+ }
+ )
+ assert.strictEqual(nextSignedSample.publicKeyIncluded, false)
+ assert.strictEqual(nextSignedSample.sampledValue.signedMeterValue?.publicKey, '')
+
+ releaseSecondDelivery.resolve(undefined)
+ await replay
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should retain a reserved public key when queued delivery fails before send', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const preSendFailure = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'Queued TransactionEvent validation failed',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn(() => Promise.reject(preSendFailure))
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const meterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ }
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [meterValue] }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should move a rejected public key past malformed persisted signing metadata', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const replayedPayloads: OCPP20TransactionEventRequest[] = []
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected TransactionEvent',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ replayedPayloads.push(payload)
+ requestParams.onMessageSent?.()
+ if (replayedPayloads.length === 1) {
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const signedMeterValue = (publicKey: string): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [signedMeterValue('public-key')] }
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ { meterValue: [signedMeterValue('')] }
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [signedMeterValue('')] }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue?.[2] != null)
+ const malformedSample =
+ connectorStatus.transactionEventQueue[1].request.meterValue?.[0].sampledValue[0]
+ assert.ok(malformedSample != null)
+ malformedSample.signedMeterValue =
+ 'malformed' as unknown as OCPP20MeterValue['sampledValue'][number]['signedMeterValue']
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ assert.strictEqual(
+ replayedPayloads[1].meterValue?.[0].sampledValue[0].signedMeterValue,
+ 'malformed'
+ )
+ assert.strictEqual(
+ replayedPayloads[2].meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should reopen a public key reservation after rejecting malformed signed metadata', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected malformed signed metadata',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const replayedPayloads: OCPP20TransactionEventRequest[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ replayedPayloads.push(payload)
+ requestParams.onMessageSent?.()
+ if (replayedPayloads.length === 1) {
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ },
+ ],
+ }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue?.[0] != null)
+ const malformedSample =
+ connectorStatus.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0]
+ assert.ok(malformedSample != null)
+ malformedSample.signedMeterValue = {
+ publicKey: 'public-key',
+ } as unknown as OCPP20MeterValue['sampledValue'][number]['signedMeterValue']
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ const nextSignedSample = buildOCPP20SampledValue(
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '0',
+ } as unknown as SampledValueTemplate,
+ 2,
+ undefined,
+ undefined,
+ {
+ enabled: true,
+ meterSerialNumber: 'meter-1',
+ publicKeyHex: 'public-key',
+ publicKeySentInTransaction: connectorStatus.publicKeySentInTransaction,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ transactionId,
+ }
+ )
+ assert.strictEqual(nextSignedSample.publicKeyIncluded, true)
+ const freshPublicKey = nextSignedSample.sampledValue.signedMeterValue?.publicKey
+ assert.ok(typeof freshPublicKey === 'string' && freshPublicKey.length > 0)
+ assert.notStrictEqual(freshPublicKey, 'public-key')
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [nextSignedSample.sampledValue],
+ timestamp: new Date(),
+ },
+ ],
+ }
+ )
+ assert.strictEqual(
+ replayedPayloads[1].meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ freshPublicKey
+ )
+ })
+
+ await it('should retain a historical public-key event after a pre-send failure', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const replayedPayloads: OCPP20TransactionEventRequest[] = []
+ const preSendFailure = new OCPPError(
+ ErrorType.FORMAT_VIOLATION,
+ 'Queued TransactionEvent validation failed',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ replayedPayloads.push(payload)
+ if (replayedPayloads.length === 1) {
+ return Promise.reject(preSendFailure)
+ }
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.resolve({} as EmptyObject)
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const buildSignedMeterValue = (publicKey: string): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ })
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [buildSignedMeterValue('public-key')] }
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ { meterValue: [buildSignedMeterValue('')] }
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ delete connectorStatus.transactionId
+ connectorStatus.publicKeySentInTransaction = false
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(replayedPayloads.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue?.[0].request.meterValue?.[0].sampledValue[0]
+ .signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 2)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should move a CALLERROR-rejected live public key to a later queued event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const stationHolder: { station?: ChargingStation } = {}
+ const buildSignedMeterValue = (publicKey: string): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(),
+ })
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<never> => {
+ const replayStation = stationHolder.station
+ assert.ok(replayStation != null)
+ replayStation.isWebSocketConnectionOpened = () => false
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ replayStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ { meterValue: [buildSignedMeterValue('')] }
+ )
+ replayStation.isWebSocketConnectionOpened = () => true
+ const requestParams = args[3] as RequestParams
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected TransactionEvent',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(callError, true)
+ throw callError
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ stationHolder.station = station
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [buildSignedMeterValue('public-key')] },
+ { skipBufferingOnError: true, throwError: true }
+ ),
+ /CSMS rejected TransactionEvent/
+ )
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0]
+ .signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should carry signed interval energy without duplicating its public key after queued CALLERROR', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const stationHolder: { station?: ChargingStation } = {}
+ const replayedPayloads: OCPP20TransactionEventRequest[] = []
+ const buildSignedMeterValue = (
+ publicKey: string,
+ value: number,
+ timestamp: Date
+ ): OCPP20MeterValue => ({
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value,
+ },
+ ],
+ timestamp,
+ })
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ replayedPayloads.push(payload)
+ const requestParams = args[3] as RequestParams
+ if (replayedPayloads.length === 1) {
+ const replayStation = stationHolder.station
+ assert.ok(replayStation != null)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ replayStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ connectorId,
+ transactionId,
+ { meterValue: [buildSignedMeterValue('', 2, new Date(2_000))] }
+ )
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected TransactionEvent',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(callError, true)
+ throw callError
+ }
+ requestParams.onMessageSent?.()
+ return {} as EmptyObject
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ stationHolder.station = station
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const rejectedMeterValue = buildSignedMeterValue('public-key', 10, new Date(1_000))
+ recordTransactionIntervalConsumption(rejectedMeterValue, 'test', 10)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [rejectedMeterValue] }
+ )
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ await OCPP20ServiceUtils.waitForTransactionEventDelivery(connectorStatus)
+
+ assert.strictEqual(replayedPayloads.length, 2)
+ const successorPublicKeys =
+ replayedPayloads[1].meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .map(sampledValue => sampledValue.signedMeterValue?.publicKey)
+ .filter(publicKey => (publicKey?.length ?? 0) > 0) ?? []
+ assert.deepStrictEqual(successorPublicKeys, ['public-key'])
+ const successorIntervalEnergy =
+ replayedPayloads[1].meterValue
+ ?.flatMap(meterValue => meterValue.sampledValue)
+ .filter(
+ sampledValue =>
+ sampledValue.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .reduce((total, sampledValue) => total + sampledValue.value, 0) ?? 0
+ assert.strictEqual(successorIntervalEnergy, 12)
+ assert.strictEqual(
+ station.getConnectorStatus(connectorId)?.publicKeySentInTransaction,
+ true
+ )
+ })
+
+ await it('should retry an accepted update without transferring its interval energy', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ let updateAttempts = 0
+ const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
+ const payload = args[2] as Record<string, unknown>
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ if (
+ payload.eventType === OCPP20TransactionEventEnumType.Updated &&
+ ++updateAttempts === 1
+ ) {
+ throw new Error('CSMS rejected event')
+ }
+ return Promise.resolve({} as EmptyObject)
+ })
+ const { station: retryStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '2',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ retryStation,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ retryStation.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(retryStation, connectorId, { transactionId })
+
+ const updatedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(),
+ }
+ recordTransactionIntervalConsumption(updatedMeterValue, 'test', 10)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ retryStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [updatedMeterValue] }
+ )
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(retryStation, connectorId)
+
+ const connectorStatus = retryStation.getConnectorStatus(connectorId)
+ assert(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(
+ requestHandlerMock.mock.calls[0].arguments[2],
+ requestHandlerMock.mock.calls[1].arguments[2]
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
+ })
+ })
+
+ // ============================================================================
+ // Periodic TransactionEvent Tests
+ // ============================================================================
+
+ await describe('E02 - OCPP 2.0.1 Periodic TransactionEvent at TxUpdatedInterval', async () => {
+ let mockTracking: MockStationWithTracking
+ let mockStation: ChargingStation
+ let sentRequests: CapturedOCPPRequest[]
+
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ mockStation = mockTracking.station
+ sentRequests = mockTracking.sentRequests
+ })
+
+ afterEach(() => {
+ // Clean up any running timers
+ for (let connectorId = 1; connectorId <= 3; connectorId++) {
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ if (connectorStatus?.transactionUpdatedMeterValuesSetInterval != null) {
+ clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval)
+ connectorStatus.transactionUpdatedMeterValuesSetInterval = undefined
+ }
+ }
+ standardCleanup()
+ })
+
+ await describe('startUpdatedMeterValues', async () => {
+ await it('should not start OCPP 2.0 timer for OCPP 1.6 stations via dispatch', async t => {
+ await withMockTimers(t, ['setInterval'], () => {
+ const { station: ocpp16Station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ stationInfo: {
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+
+ startUpdatedMeterValues(ocpp16Station, 1, 60000)
+
+ const connectorStatus = ocpp16Station.getConnectorStatus(1)
+ assert.strictEqual(connectorStatus?.transactionUpdatedMeterValuesSetInterval, undefined)
+ })
+ })
+
+ await it('should coalesce periodic ticks while one TransactionEvent is pending', async t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const firstResponse = Promise.withResolvers<unknown>()
+ let transactionEventRequests = 0
+ mockTracking.requestHandlerMock.mock.mockImplementation((...args: unknown[]) => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ transactionEventRequests++
+ return transactionEventRequests === 1 ? firstResponse.promise : Promise.resolve({})
+ })
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockStation, 1, { transactionId })
+ const observedIntervals: number[] = []
+ mock.method(
+ OCPP20ServiceUtils,
+ 'buildTransactionMeterValue',
+ (...args: Parameters<typeof OCPP20ServiceUtils.buildTransactionMeterValue>) => {
+ observedIntervals.push(args[4])
+ return {
+ sampledValue: [{ value: 1 }],
+ timestamp: new Date(),
+ }
+ }
+ )
+
+ OCPP20ServiceUtils.startUpdatedMeterValues(mockStation, 1, 1000, 1)
+ t.mock.timers.tick(10_000)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+ assert.strictEqual(transactionEventRequests, 1)
+
+ firstResponse.resolve({})
+ await flushMicrotasks()
+ t.mock.timers.tick(1000)
+ for (let index = 0; index < 10; index++) await flushMicrotasks()
+ assert.strictEqual(transactionEventRequests, 2)
+ assert.deepStrictEqual(observedIntervals, [1000, 10_000])
+ })
+
+ await it('should not start timer when interval is zero', () => {
+ const connectorId = 1
+
+ // Simulate startTxUpdatedInterval with zero interval
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ assert(connectorStatus != null)
+
+ // Zero interval should not start timer
+ // This is verified by the implementation logging debug message
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ })
+
+ await it('should not start timer when interval is negative', () => {
+ const connectorId = 1
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ assert(connectorStatus != null)
+
+ // Negative interval should not start timer
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ })
+
+ await it('should handle non-existent connector gracefully', () => {
+ const nonExistentConnectorId = 999
+
+ // Should not throw for non-existent connector
+ assert.doesNotThrow(() => {
+ mockStation.getConnectorStatus(nonExistentConnectorId)
+ })
+
+ // Should return undefined for non-existent connector
+ assert.strictEqual(mockStation.getConnectorStatus(nonExistentConnectorId), undefined)
+ })
+ })
+
+ await describe('Periodic TransactionEvent generation', async () => {
+ await it('should send TransactionEvent with MeterValuePeriodic trigger reason', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Reset sequence number
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ // Simulate sending periodic TransactionEvent (what the timer callback does)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ // Verify the request was sent with correct trigger reason
+ assert.strictEqual(sentRequests.length, 1)
+ assert.strictEqual(sentRequests[0].command, OCPP20RequestCommand.TRANSACTION_EVENT)
+ assert.strictEqual(
+ sentRequests[0].payload.eventType,
+ OCPP20TransactionEventEnumType.Updated
+ )
+ assert.strictEqual(
+ sentRequests[0].payload.triggerReason,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic
+ )
+ })
+
+ await it('should increment seqNo for each periodic event', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Reset sequence number for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ // Send initial Started event
+ const startEvent = buildTransactionEvent(mockStation, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ assert.strictEqual(startEvent.seqNo, 0)
+
+ // Send multiple periodic events (simulating timer ticks)
+ for (let i = 1; i <= 3; i++) {
+ const periodicEvent = buildTransactionEvent(mockStation, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ assert.strictEqual(periodicEvent.seqNo, i)
+ }
+
+ // Verify sequence numbers are continuous: 0, 1, 2, 3
+ const connectorStatus = mockStation.getConnectorStatus(connectorId)
+ assert.strictEqual(connectorStatus?.transactionSeqNo, 3)
+ })
+
+ await it('should maintain correct eventType (Updated) for periodic events', async () => {
+ const connectorId = 2
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ // Send periodic event
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ // Verify eventType is Updated (not Started or Ended)
+ assert.strictEqual(
+ sentRequests[0].payload.eventType,
+ OCPP20TransactionEventEnumType.Updated
+ )
+ })
+
+ await it('should include EVSE information in periodic events', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ const evse = sentRequests[0].payload.evse as undefined | { id: number }
+ assert.strictEqual(evse?.id, connectorId)
+ })
+
+ await it('should include transactionInfo with correct transactionId', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ mockStation,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+
+ const transactionInfo = sentRequests[0].payload.transactionInfo as
+ OCPP20TransactionType | undefined
+ assert.strictEqual(transactionInfo?.transactionId, transactionId)
+ })
+ })
+
+ await describe('Timer lifecycle integration', async () => {
+ await it('should continue seqNo sequence across multiple periodic events', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+
+ // Reset for new transaction
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, connectorId)
+
+ // Simulate full transaction lifecycle with periodic updates
+ // 1. Started event (seqNo: 0)
+ const startEvent = buildTransactionEvent(mockStation, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ assert.strictEqual(startEvent.seqNo, 0)
+
+ // 2. Multiple periodic updates (seqNo: 1, 2, 3)
+ for (let i = 1; i <= 3; i++) {
+ const updateEvent = buildTransactionEvent(mockStation, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ assert.strictEqual(updateEvent.seqNo, i)
+ }
+
+ // 3. Ended event (seqNo: 4)
+ const endEvent = buildTransactionEvent(mockStation, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ assert.strictEqual(endEvent.seqNo, 4)
+ })
+
+ await it('should handle multiple connectors with independent timers', () => {
+ const transactionId1 = generateUUID()
+ const transactionId2 = generateUUID()
+
+ // Reset sequence numbers for both connectors
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 1)
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(mockStation, 2)
+
+ // Build events for connector 1
+ const event1Start = buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ transactionId: transactionId1,
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ const event1Update = buildTransactionEvent(mockStation, {
+ connectorId: 1,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId: transactionId1,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+
+ // Build events for connector 2
+ const event2Start = buildTransactionEvent(mockStation, {
+ connectorId: 2,
+ eventType: OCPP20TransactionEventEnumType.Started,
+ transactionId: transactionId2,
+ triggerReason: OCPP20TriggerReasonEnumType.Authorized,
+ })
+ const event2Update = buildTransactionEvent(mockStation, {
+ connectorId: 2,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId: transactionId2,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+
+ // Verify independent sequence numbers
+ assert.strictEqual(event1Start.seqNo, 0)
+ assert.strictEqual(event1Update.seqNo, 1)
+ assert.strictEqual(event2Start.seqNo, 0)
+ assert.strictEqual(event2Update.seqNo, 1)
+
+ // Verify different transaction IDs
+ assert.strictEqual(event1Start.transactionInfo.transactionId, transactionId1)
+ assert.strictEqual(event2Start.transactionInfo.transactionId, transactionId2)
+ })
+ })
+ })
+
+ await describe('getTxUpdatedInterval', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const mockTracking = createMockStationWithRequestTracking()
+ station = mockTracking.station
+ resetLimits(station)
+ })
+
+ afterEach(() => {
+ OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ standardCleanup()
+ })
+
+ await it('should return default interval when TxUpdatedInterval is not configured', () => {
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
+ })
+
+ await it('should return configured interval in milliseconds', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '60',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, 60000)
+ })
+
+ await it('should return default for non-numeric value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'abc',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
+ })
+
+ await it('should return default for zero value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '0',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
+ })
+
+ await it('should return default for negative value', () => {
+ OCPP20VariableManager.getInstance().setVariables(station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: '-10',
+ component: { name: OCPP20ComponentName.SampledDataCtrlr },
+ variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ },
+ ])
+
+ const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
+
+ assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
+ })
+ await it('should clamp persisted retry settings to their registry bounds', () => {
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.${OCPP20RequestCommand.TRANSACTION_EVENT}`,
+ '1000000000',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.${OCPP20RequestCommand.TRANSACTION_EVENT}`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ const serviceUtils = OCPP20ServiceUtils as unknown as {
+ readBoundedVariableAsInteger: (
+ target: ChargingStation,
+ componentName: string,
+ variableName: string,
+ defaultValue: number,
+ componentInstance?: string
+ ) => number
+ }
+
+ assert.strictEqual(
+ serviceUtils.readBoundedVariableAsInteger(
+ station,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageAttempts,
+ 3,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ ),
+ 10
+ )
+ assert.strictEqual(
+ serviceUtils.readBoundedVariableAsInteger(
+ station,
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20RequiredVariableName.MessageAttemptInterval,
+ 5,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ ),
+ 1
+ )
+ })
+ })
+
+ await describe('requestDeauthorizeTransaction', async () => {
+ let mockTracking: MockStationWithTracking
+
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ resetConnectorTransactionState(mockTracking.station)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should send Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(
+ r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(txEvents.length, 2)
+
+ const updatedEvent = txEvents[0].payload
+ assert.strictEqual(updatedEvent.eventType, OCPP20TransactionEventEnumType.Updated)
+ assert.strictEqual(updatedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+ const updatedTransactionInfo = updatedEvent.transactionInfo as OCPP20TransactionType
+ assert.strictEqual(
+ updatedTransactionInfo.chargingState,
+ OCPP20ChargingStateEnumType.SuspendedEVSE
+ )
+
+ const endedEvent = txEvents[1].payload
+ assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+ assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
+ const endedTransactionInfo = endedEvent.transactionInfo as OCPP20TransactionType
+ assert.strictEqual(endedTransactionInfo.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
+ })
+
+ await it('should include final meter values with Transaction.End context in Ended event', async () => {
+ // Arrange
+ const connectorId = 2
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1500
+ }
+
+ const evseStatus = mockTracking.station.getEvseStatus(
+ mockTracking.station.getEvseIdByConnectorId(connectorId) ?? 1
+ )
+ if (evseStatus != null) {
+ evseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as ConnectorStatus['MeterValues']
+ }
+
+ addConfigurationKey(
+ mockTracking.station,
+ `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`,
+ 'Energy.Active.Import.Register',
+ undefined,
+ { save: false }
+ )
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2)
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(
+ r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(txEvents.length, 2)
+
+ const endedPayload = txEvents[1].payload
+ const transactionInfo = endedPayload.transactionInfo as OCPP20TransactionType
+ assert.strictEqual(transactionInfo.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
+ const meterValues = endedPayload.meterValue as OCPP20MeterValue[] | undefined
+ assert.notStrictEqual(meterValues, undefined)
+ if (meterValues == null) {
+ assert.fail('Expected meterValue to be defined in Ended event')
+ }
+ assert.strictEqual(meterValues.length, 1)
+ const endedMeterValue = meterValues[0]
+ assert.ok(endedMeterValue.timestamp instanceof Date)
+ assert.strictEqual(endedMeterValue.sampledValue.length, 1)
+ const sampledValue = endedMeterValue.sampledValue[0]
+ assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.TRANSACTION_END)
+ assert.strictEqual(
+ sampledValue.measurand,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ })
+
+ await it('should reset connector status after deauthorization', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ connectorStatus.transactionDeauthorized = true
+ connectorStatus.transactionDeauthorizedEnergyWh = 50
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert
+ const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(postStatus, undefined)
+ if (postStatus != null) {
+ assert.strictEqual(postStatus.transactionStarted, false)
+ assert.strictEqual(postStatus.transactionId, undefined)
+ assert.strictEqual(postStatus.transactionDeauthorized, undefined)
+ assert.strictEqual(postStatus.transactionDeauthorizedEnergyWh, undefined)
+ }
+ })
+
+ await it('should throw if no active transaction', async () => {
+ const connectorId = 1
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+ (error: unknown) => {
+ assert.ok(error instanceof OCPPError)
+ return true
+ }
+ )
+ })
+
+ await it('should not terminate when StopTxOnInvalidId is false', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+ OCPP20VariableManager.getInstance().setVariables(mockTracking.station, [
+ {
+ attributeType: AttributeEnumType.Actual,
+ attributeValue: 'false',
+ component: { name: OCPP20ComponentName.TxCtrlr },
+ variable: { name: OCPP20RequiredVariableName.StopTxOnInvalidId },
+ },
+ ])
+
+ // Act
+ await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert — only Updated(Deauthorized), no Ended
+ const txEvents = mockTracking.sentRequests.filter(
+ r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(txEvents.length, 1)
+ assert.strictEqual(txEvents[0].payload.eventType, OCPP20TransactionEventEnumType.Updated)
+ assert.strictEqual(
+ txEvents[0].payload.triggerReason,
+ OCPP20TriggerReasonEnumType.Deauthorized
+ )
+
+ // Transaction should still be active
+ const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+ if (postStatus != null) {
+ assert.strictEqual(postStatus.transactionStarted, true)
+ assert.strictEqual(postStatus.transactionId, transactionId)
+ }
+
+ OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ })
+
+ await it('should track deauth state for deferred termination via periodic meter values', () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ if (connectorStatus == null) {
+ assert.fail('connectorStatus should not be undefined')
+ }
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 500
+ connectorStatus.transactionDeauthorized = true
+ connectorStatus.transactionDeauthorizedEnergyWh = 500
+
+ assert.strictEqual(connectorStatus.transactionDeauthorized, true)
+ assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, 500)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ })
+
+ await it('should propagate error and skip cleanup if Updated event fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ const originalSend = OCPP20ServiceUtils.sendTransactionEvent.bind(OCPP20ServiceUtils)
+ const sendMock = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', async () => {
+ sendMock.mock.restore()
+ OCPP20ServiceUtils.sendTransactionEvent = originalSend
+ return Promise.reject(new Error('Network failure'))
+ })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
+ (error: unknown) => {
+ assert.ok(error instanceof Error)
+ assert.strictEqual(error.message, 'Network failure')
+ return true
+ }
+ )
+
+ const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+ if (postStatus != null) {
+ assert.strictEqual(postStatus.transactionStarted, true)
+ assert.strictEqual(postStatus.transactionId, transactionId)
+ }
+ })
+ })
+
+ await describe('restored and interrupted transaction startup', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should restore coherent SoC on EVSE 2 when connector ids repeat before arming timers', () => {
+ mock.timers.enable({ apis: ['setInterval'] })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppRequestService: { requestHandler: async () => Promise.resolve({}) },
+ stationInfo: { coherentMeterValues: true, ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const transactionId = '00000000-0000-4000-8000-000000000077'
+ const firstEvse = station.getEvseStatus(1)
+ const secondEvse = station.getEvseStatus(2)
+ assert.ok(firstEvse != null && secondEvse != null)
+ const firstConnector = firstEvse.connectors.get(1)
+ const connectorStatus = secondEvse.connectors.get(2)
+ assert.ok(firstConnector != null && connectorStatus != null)
+ secondEvse.connectors.delete(2)
+ secondEvse.connectors.set(1, connectorStatus)
+ firstConnector.transactionId = '00000000-0000-4000-8000-000000000001'
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionRestored = true
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 4000
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ evseId: 2,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'restored-profile',
+ initialSocPercentMax: 30,
+ initialSocPercentMin: 30,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: Date.now(),
+ socPercent: 30,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ let created = false
+ mock.method(station, 'getCoherentSession', () => (created ? session : undefined))
+ const createSpy = mock.method(station, 'createCoherentSession', () => {
+ created = true
+ return session
+ })
+ mock.method(OCPP20ServiceUtils, 'getTxUpdatedInterval', () => 1000)
+ mock.method(OCPP20ServiceUtils, 'getTxEndedInterval', () => 1000)
+ const assertRestorationApplied = (): void => {
+ assert.strictEqual(session.socPercent, 40)
+ assert.ok(connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt != null)
+ }
+ const updatedTimerSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'startUpdatedMeterValues',
+ assertRestorationApplied
+ )
+ const endedTimerSpy = mock.method(
+ OCPP20ServiceUtils,
+ 'startEndedMeterValues',
+ assertRestorationApplied
+ )
+
+ const resumedAfter = Date.now()
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(station)
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(station)
+
+ assert.strictEqual(createSpy.mock.callCount(), 1)
+ assert.strictEqual(createSpy.mock.calls[0].arguments[0], transactionId)
+ assert.strictEqual(createSpy.mock.calls[0].arguments[1], 1)
+ assert.strictEqual(createSpy.mock.calls[0].arguments[2], 2)
+ assert.strictEqual(station.getCoherentSession(transactionId), session)
+ assert.strictEqual(session.evseId, 2)
+ assert.strictEqual(session.socPercent, 40)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ const lastUpdatedAt = connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt
+ assert.ok(lastUpdatedAt != null)
+ assert.ok(lastUpdatedAt.getTime() >= resumedAfter)
+ assert.strictEqual(updatedTimerSpy.mock.callCount(), 1)
+ assert.strictEqual(updatedTimerSpy.mock.calls[0].arguments[3], 2)
+ assert.strictEqual(endedTimerSpy.mock.callCount(), 1)
+ assert.strictEqual(endedTimerSpy.mock.calls[0].arguments[3], 2)
+ })
+
+ await it('should apply restored energy once to an existing coherent session and clamps SoC', () => {
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: async () => Promise.resolve({}) },
+ stationInfo: { coherentMeterValues: true, ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const transactionId = '00000000-0000-4000-8000-000000000078'
+ setupConnectorWithTransaction(station, 1, { transactionId })
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionRestored = true
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 4000
+ const session: CoherentSession = {
+ connectorId: 1,
+ currentType: CurrentType.AC,
+ numberOfPhases: 1,
+ profile: {
+ batteryCapacityWh: 40000,
+ chargingCurve: [{ powerFraction: 1, socPercent: 0 }],
+ id: 'existing-restored-profile',
+ initialSocPercentMax: 95,
+ initialSocPercentMin: 95,
+ maxPowerW: 11000,
+ weight: 1,
+ },
+ rampUpDurationMs: 0,
+ sessionStartMs: Date.now(),
+ socPercent: 95,
+ transactionId,
+ voltageOutNominal: Voltage.VOLTAGE_230,
+ }
+ station.__injectCoherentSession(transactionId, session)
+ const createSpy = mock.method(station, 'createCoherentSession')
+ mock.method(OCPP20ServiceUtils, 'startUpdatedMeterValues', () => undefined)
+ mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(station)
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(station)
+
+ assert.strictEqual(createSpy.mock.callCount(), 0)
+ assert.strictEqual(session.socPercent, Constants.SOC_MAXIMUM_PERCENT)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ })
+
+ for (const restored of [false, true]) {
+ await it(`disposes each exhausted ${restored ? 'restored' : 'live queued'} event before continuing`, async () => {
+ const connectorId = 1
+ const failedTransactionId = generateUUID()
+ const replacementTransactionId = generateUUID()
+ let online = false
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ if (request.transactionInfo.transactionId === failedTransactionId) {
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Rejected restored transaction',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = false
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '3',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttemptInterval}.TransactionEvent`,
+ '0',
+ undefined,
+ { save: false }
+ )
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ failedTransactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ failedTransactionId
+ )
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ 'SampledDataCtrlr.TxEndedMeasurands': 4,
+ test: 4,
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 40
+ const replacementBeginMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value: 42,
+ },
+ ],
+ timestamp: new Date('2026-09-08T12:00:00.000Z'),
+ }
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ replacementTransactionId,
+ { meterValue: [replacementBeginMeterValue] }
+ )
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ 'SampledDataCtrlr.TxEndedMeasurands': 23,
+ test: 23,
+ }
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 123
+ const replacementIdToken = 'REPLACEMENT-CABLE-FIRST'
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ replacementTransactionId,
+ {
+ idToken: {
+ idToken: replacementIdToken,
+ type: OCPP20IdTokenEnumType.ISO14443,
+ },
+ }
+ )
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.at(-1)
+ ?.transactionEnergyActiveImportIntervalBaselines,
+ { test: 23 }
+ )
+ delete connectorStatus.transactionEnergyActiveImportIntervalBaselines
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ replacementTransactionId
+ )
+ connectorStatus.transactionId = failedTransactionId
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = restored
+ const destroySessionSpy = mock.method(station, 'destroyCoherentSession')
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ const attemptedTransactionIds = requestHandlerMock.mock.calls
+ .filter(call => call.arguments[1] === OCPP20RequestCommand.TRANSACTION_EVENT)
+ .map(
+ call =>
+ (call.arguments[2] as OCPP20TransactionEventRequest).transactionInfo.transactionId
+ )
+ assert.deepStrictEqual(attemptedTransactionIds, [
+ failedTransactionId,
+ failedTransactionId,
+ failedTransactionId,
+ failedTransactionId,
+ failedTransactionId,
+ failedTransactionId,
+ replacementTransactionId,
+ replacementTransactionId,
+ replacementTransactionId,
+ ])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionIdTag, replacementIdToken)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 2)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines,
+ undefined
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 123)
+ assert.deepStrictEqual(
+ connectorStatus.transactionBeginMeterValue,
+ replacementBeginMeterValue
+ )
+ assert.deepStrictEqual(
+ destroySessionSpy.mock.calls.map(call => call.arguments[0]),
+ [failedTransactionId]
+ )
+ })
+ }
+
+ for (const hasEndedSuccessor of [false, true]) {
+ await it(`retains a restored Started after response handling fails${hasEndedSuccessor ? ' before Ended' : ''}`, async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const handlerFailure = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Local Started response handler failed',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ if (request.eventType === OCPP20TransactionEventEnumType.Started) {
+ requestParams?.onResponseReceived?.()
+ return Promise.reject(handlerFailure)
+ }
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ if (hasEndedSuccessor) {
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ }
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ hasEndedSuccessor
+ ? [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Ended]
+ : [OCPP20TransactionEventEnumType.Started]
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ assert.strictEqual(connectorStatus.transactionStartedExhaustedTransactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+ })
+ }
+
+ await it('should not clear a replacement transaction installed during CALLERROR handling', async () => {
+ const connectorId = 1
+ const failedTransactionId = generateUUID()
+ const replacementTransactionId = generateUUID()
+ let online = false
+ const stationHolder: { station?: ChargingStation } = {}
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ const currentStation = stationHolder.station
+ assert.ok(currentStation != null)
+ const connectorStatus = currentStation.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionStarting = false
+ delete connectorStatus.transactionRestored
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Rejected restored transaction',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ })
+ const createdStation = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const station = createdStation.station
+ stationHolder.station = station
+ station.started = false
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ OCPP20ServiceUtils.resetTransactionSequenceNumber(station, connectorId)
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ failedTransactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue != null)
+ connectorStatus.transactionId = failedTransactionId
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ const destroySessionSpy = mock.method(station, 'destroyCoherentSession')
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 3)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ assert.deepStrictEqual(
+ destroySessionSpy.mock.calls.map(call => call.arguments[0]),
+ [failedTransactionId]
+ )
+ })
+
+ await it('should not clear a replacement transaction after a stale Ended response', async () => {
+ const connectorId = 1
+ const endedTransactionId = generateUUID()
+ const replacementTransactionId = generateUUID()
+ const requestStarted = Promise.withResolvers<undefined>()
+ const responseGate = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<unknown> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestStarted.resolve(undefined)
+ await responseGate.promise
+ requestParams?.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, {
+ transactionId: endedTransactionId,
+ })
+
+ const ended = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.EVCommunicationLost,
+ connectorId,
+ endedTransactionId,
+ { evseId: 1 }
+ )
+ await requestStarted.promise
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionStarting = false
+ connectorStatus.transactionSeqNo = 0
+ connectorStatus.transactionIdTag = 'REPLACEMENT'
+ responseGate.resolve(undefined)
+
+ await ended
+
+ assert.strictEqual(connectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 0)
+ assert.strictEqual(connectorStatus.transactionIdTag, 'REPLACEMENT')
+ })
+
+ await it('should restore Ended ownership after exhausted Started and drains remaining events', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ let interruptFollowingEvent = true
+ const replayedEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ replayedEventTypes.push(request.eventType)
+ if (request.eventType === OCPP20TransactionEventEnumType.Started) {
+ requestParams.onMessageSent?.()
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Started rejected',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ if (interruptFollowingEvent) {
+ interruptFollowingEvent = false
+ online = false
+ return Promise.reject(
+ new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Transport interrupted',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ )
+ }
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { pending: true, transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Ended,
+ OCPP20TriggerReasonEnumType.StopAuthorized,
+ connectorId,
+ transactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId, 1)
+
+ assert.strictEqual(connectorStatus.transactionStartedExhaustedTransactionId, transactionId)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Updated, OCPP20TransactionEventEnumType.Ended]
+ )
+ const restoredConnectorStatus = preparePersistedConnectorStatus(
+ station,
+ JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ )
+ evseStatus.connectors.set(connectorId, restoredConnectorStatus)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionEnding, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId, 1)
+
+ assert.deepStrictEqual(replayedEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.transactionId, undefined)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ })
+
+ await it('should restore an Ended-only transaction and clears its ownership after replay', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z')
+ const replayedEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] === OCPP20RequestCommand.TRANSACTION_EVENT) {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ replayedEventTypes.push(request.eventType)
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ }
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { pending: true, transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ const evseStatus = station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.publicKeySentInTransaction = true
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionSeqNo = 2
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionEvseSent = true
+ connectorStatus.transactionIdTokenSent = true
+ connectorStatus.transactionEventQueue = [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ },
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ },
+ ]
+ const restoredConnectorStatus = preparePersistedConnectorStatus(
+ station,
+ JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ )
+ evseStatus.connectors.set(connectorId, restoredConnectorStatus)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionEnding, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId, 1)
+
+ assert.deepStrictEqual(replayedEventTypes, [OCPP20TransactionEventEnumType.Ended])
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.transactionId, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionSeqNo, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionEnding, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, undefined)
+ assert.strictEqual(restoredConnectorStatus.publicKeySentInTransaction, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionEvseSent, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionIdTokenSent, undefined)
+ })
+
+ for (const rejectUpdated of [false, true]) {
+ await it(`cleans exhausted Started ownership after the final Updated is ${rejectUpdated ? 'disposed' : 'accepted'}`, async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const replayedEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ replayedEventTypes.push(request.eventType)
+ requestParams.onMessageSent?.()
+ if (request.eventType === OCPP20TransactionEventEnumType.Started || rejectUpdated) {
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ `${request.eventType} rejected`,
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ requestParams.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ requestParams.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { pending: true, transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId, 1)
+
+ assert.deepStrictEqual(replayedEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Updated,
+ ])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStartedExhaustedTransactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ const restoredConnectorStatus = prepareConnectorStatus(
+ JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ )
+ assert.strictEqual(
+ restoredConnectorStatus.transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ assert.strictEqual(restoredConnectorStatus.transactionId, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, false)
+ })
+ }
+
+ await it('should keep a restored owning Started event recoverable across disconnects', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ let rejectReplay = true
+ const stationHolder: { station?: ChargingStation } = {}
+ const replayedRequests: OCPP20TransactionEventRequest[] = []
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return {}
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ replayedRequests.push(request)
+ requestParams?.onMessageSent?.()
+ if (rejectReplay) {
+ online = false
+ throw new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'Connection lost during TransactionEvent.Started replay',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ }
+ requestParams?.onResponseReceived?.()
+ const replayStation = stationHolder.station
+ assert.ok(replayStation != null)
+ await responseService.handleResponseTransactionEvent(replayStation, {}, request)
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ stationHolder.station = station
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ setupConnectorWithTransaction(station, connectorId, { pending: true, transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus?.transactionEventQueue?.[0] != null)
+ connectorStatus.transactionPending = false
+ connectorStatus.transactionStarted = false
+ connectorStatus.transactionStarting = true
+ connectorStatus.transactionRestored = true
+ const queuedEvent = connectorStatus.transactionEventQueue[0]
+ const originalPayload = structuredClone(queuedEvent.request)
+ const saveQueueSpy = mock.method(station, 'saveTransactionEventQueues')
+ online = true
+
+ await flushQueuedTransactionMessages(station)
+ await new Promise(resolve => setImmediate(resolve))
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], queuedEvent)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue[0].request, originalPayload)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+ assert.strictEqual(saveQueueSpy.mock.callCount(), 1)
+
+ online = true
+ await flushQueuedTransactionMessages(station)
+ await new Promise(resolve => setImmediate(resolve))
+
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 1)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], queuedEvent)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue[0].request, originalPayload)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+ assert.strictEqual(saveQueueSpy.mock.callCount(), 2)
+
+ rejectReplay = false
+ online = true
+ await flushQueuedTransactionMessages(station)
+
+ assert.strictEqual(replayedRequests.length, 3)
+ assert.strictEqual(replayedRequests[0], replayedRequests[1])
+ assert.strictEqual(replayedRequests[1], replayedRequests[2])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(station, connectorId)
+ OCPP20ServiceUtils.stopEndedMeterValues(station, connectorId)
+ })
+
+ await it('should commit an interrupted Started on same-instance restart without arming timers early', async () => {
+ let stopping = false
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ stopping = true
+ station.started = false
+ OCPP20ServiceUtils.pauseTransactionMeterValues(station)
+ return Promise.reject(new Error('shutdown interrupted Started'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => stopping
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+
+ const result = await OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1')
+
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(result.accepted, true)
+ const transactionEventQueue = connectorStatus.transactionEventQueue
+ assert.ok(transactionEventQueue != null)
+ assert.strictEqual(transactionEventQueue.length, 1)
+ const queuedRequest = transactionEventQueue[0].request
+ assert.strictEqual(queuedRequest.eventType, OCPP20TransactionEventEnumType.Started)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+ const transactionId = connectorStatus.transactionId
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return {}
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ requestParams?.onResponseReceived?.()
+ return {}
+ })
+ stopping = false
+ station.started = true
+
+ await flushQueuedTransactionMessages(station)
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ assert.strictEqual(queuedRequest.transactionInfo.transactionId, transactionId)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(station, 1)
+ OCPP20ServiceUtils.stopEndedMeterValues(station, 1)
+ })
+
+ await it('should reconstruct a durable Started after lifecycle sealing for same-instance replay', async () => {
+ const lifecycleAbortController = new AbortController()
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ lifecycleAbortController.abort()
+ return Promise.reject(new Error('shutdown sealed Started delivery'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: lifecycleAbortController.signal,
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+
+ await assert.rejects(
+ OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1'),
+ /shutdown sealed Started delivery/
+ )
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const queuedStarted = connectorStatus.transactionEventQueue?.[0]
+ assert.ok(queuedStarted != null)
+ const transactionId = queuedStarted.request.transactionInfo.transactionId
+ assert.strictEqual(queuedStarted.request.eventType, OCPP20TransactionEventEnumType.Started)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ value: new AbortController().signal,
+ })
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ return {}
+ })
+
+ await flushQueuedTransactionMessages(station)
+ await new Promise(resolve => setImmediate(resolve))
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(station, 1, 1)
+ OCPP20ServiceUtils.stopEndedMeterValues(station, 1, 1)
+ })
+
+ await it('should queue Ended when a restored Started is stopped again before replay', async () => {
+ let stopping = false
+ let online = true
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ sentEventTypes.push(request.eventType)
+ requestParams?.onMessageSent?.()
+ stopping = true
+ online = false
+ station.started = false
+ OCPP20ServiceUtils.pauseTransactionMeterValues(station)
+ return Promise.reject(new Error('shutdown interrupted Started'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isStopping = () => stopping
+ station.isWebSocketConnectionOpened = () => online
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+
+ const startResult = await OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1')
+ assert.strictEqual(startResult.accepted, true)
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ const transactionId = connectorStatus.transactionId
+ assert.ok(transactionId != null)
+ assert.strictEqual(connectorStatus.transactionStarting, false)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Started]
+ )
+
+ stopping = false
+ station.started = true
+ await OCPP20ServiceUtils.requestStopTransaction(station, 1, 1)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Ended]
+ )
+
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return {}
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ sentEventTypes.push(request.eventType)
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ return {}
+ })
+ online = true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, 1, 1)
+
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionRestored ?? false, false)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ })
+
+ await it('should queue Ended behind an interrupted Started and does not resurrect on replay', async () => {
+ const startedDelivery = Promise.withResolvers<OCPP20TransactionEventResponse>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<unknown> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ return await startedDelivery.promise
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = true
+ station.isWebSocketConnectionOpened = () => true
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+
+ const startPromise = OCPP20ServiceUtils.startTransactionOnConnector(station, 1, 'TAG-1')
+ await new Promise(resolve => setImmediate(resolve))
+ const connectorStatus = station.getConnectorStatus(1, 1)
+ assert.ok(connectorStatus != null)
+ assert.strictEqual(connectorStatus.transactionStarting, true)
+
+ station.started = false
+ station.isStopping = () => true
+ station.isWebSocketConnectionOpened = () => false
+ const stopPromise = OCPP20ServiceUtils.requestStopTransaction(station, 1, 1)
+ startedDelivery.reject(new Error('shutdown interrupted Started'))
+ await Promise.all([startPromise, stopPromise])
+
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Started, OCPP20TransactionEventEnumType.Ended]
+ )
+ const responseService = createTestableResponseService(new OCPP20ResponseService())
+ requestHandlerMock.mock.mockImplementation(async (...args: unknown[]): Promise<unknown> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ await responseService.handleResponseTransactionEvent(station, {}, request)
+ return {}
+ })
+ station.started = true
+ station.isStopping = () => false
+ station.isWebSocketConnectionOpened = () => true
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, 1, 1)
+
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ })
+ })
+
+ await it('should restore a direct Updated when its generation seals after an older drain', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const oldLifecycle = new AbortController()
+ let lifecycleAbortSignal = oldLifecycle.signal
+ let online = false
+ const olderDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseOlderDelivery = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ olderDeliveryStarted.resolve(undefined)
+ await releaseOlderDelivery.promise
+ requestParams.onMessageSent?.()
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ Object.defineProperty(station, 'lifecycleAbortSignal', {
+ configurable: true,
+ get: () => lifecycleAbortSignal,
+ })
+ station.isWebSocketConnectionOpened = () => online
+ station.inAcceptedState = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ const intervalBaselineKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId,
+ { evseId: 1 }
+ )
+ online = true
+ const consumedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: '',
+ },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(10_000),
+ }
+ recordTransactionIntervalConsumption(consumedMeterValue, intervalBaselineKey, 10)
+ const directUpdated = OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { evseId: 1, meterValue: [consumedMeterValue] }
+ )
+ await olderDeliveryStarted.promise
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ oldLifecycle.abort()
+ lifecycleAbortSignal = new AbortController().signal
+ releaseOlderDelivery.resolve(undefined)
+ await assert.rejects(directUpdated, /lifecycle generation was sealed/)
+
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry?.[intervalBaselineKey],
+ 10
+ )
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should preserve transaction ownership for a direct Ended behind exhausted Started replay', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ let online = false
+ const startedReplayEntered = Promise.withResolvers<undefined>()
+ const releaseStartedReplay = Promise.withResolvers<undefined>()
+ const endedDeliveryStarted = Promise.withResolvers<undefined>()
+ const releaseEndedDelivery = Promise.withResolvers<undefined>()
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ let ownershipAtEndedSend:
+ | undefined
+ | {
+ transactionEnding?: boolean
+ transactionId?: number | string
+ transactionStarted?: boolean
+ }
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CSMS rejected TransactionEvent.Started',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const payload = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentEventTypes.push(payload.eventType)
+ if (payload.eventType === OCPP20TransactionEventEnumType.Started) {
+ startedReplayEntered.resolve(undefined)
+ await releaseStartedReplay.promise
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(callError, true)
+ throw callError
+ }
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ ownershipAtEndedSend = {
+ transactionEnding: connectorStatus?.transactionEnding,
+ transactionId: connectorStatus?.transactionId,
+ transactionStarted: connectorStatus?.transactionStarted,
+ }
+ requestParams.onMessageSent?.()
+ endedDeliveryStarted.resolve(undefined)
+ await releaseEndedDelivery.promise
+ requestParams.onResponseReceived?.()
+ return {}
+ })
+ const context = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ const station = context.station
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => online
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TriggerReasonEnumType.Authorized,
+ connectorId,
+ transactionId
+ )
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const durableQueueSnapshots: OCPP20TransactionEventEnumType[][] = []
+ mock.method(station, 'saveTransactionEventQueues', () => {
+ durableQueueSnapshots.push(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType) ?? []
+ )
+ })
+ online = true
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ await startedReplayEntered.promise
+ const ended = OCPP20ServiceUtils.requestStopTransaction(station, connectorId)
+
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Started]
+ )
+ assert.deepStrictEqual(durableQueueSnapshots, [])
+ releaseStartedReplay.resolve(undefined)
+ await endedDeliveryStarted.promise
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue.map(({ request }) => request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ const finalDurableQueueSnapshot = durableQueueSnapshots[durableQueueSnapshots.length - 1]
+ assert.deepStrictEqual(finalDurableQueueSnapshot, [OCPP20TransactionEventEnumType.Ended])
+ releaseEndedDelivery.resolve(undefined)
+
+ await Promise.all([replay, ended])
+
+ assert.deepStrictEqual(sentEventTypes, [
+ OCPP20TransactionEventEnumType.Started,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.deepStrictEqual(ownershipAtEndedSend, {
+ transactionEnding: true,
+ transactionId,
+ transactionStarted: true,
+ })
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionStarted ?? false, false)
+ assert.strictEqual(connectorStatus.transactionEnding ?? false, false)
+ })
+
+ await describe('requestStopTransaction', async () => {
+ let mockTracking: MockStationWithTracking
+
+ beforeEach(() => {
+ mockTracking = createMockStationWithRequestTracking()
+ resetConnectorTransactionState(mockTracking.station)
+ })
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ await it('should share one Ended operation for concurrent stops of the same transaction', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const responseGate = Promise.withResolvers<EmptyObject>()
+ const requestStarted = Promise.withResolvers<undefined>()
+ const requestHandler = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ requestStarted.resolve(undefined)
+ const response = await responseGate.promise
+ requestParams?.onResponseReceived?.()
+ return response
+ })
+ const { station } = createMockChargingStation({
+ baseName: `${TEST_CHARGING_STATION_BASE_NAME}-SINGLE-FLIGHT`,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+
+ const firstStop = OCPP20ServiceUtils.requestStopTransaction(
+ station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const secondStop = OCPP20ServiceUtils.requestStopTransaction(
+ station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+
+ assert.strictEqual(secondStop, firstStop)
+ const replacementTransactionId = generateUUID()
+ connectorStatus.transactionId = replacementTransactionId
+ connectorStatus.transactionEnding = false
+ const conflictingStop = OCPP20ServiceUtils.requestStopTransaction(
+ station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ replacementTransactionId
+ )
+ connectorStatus.transactionId = transactionId
+ connectorStatus.transactionEnding = true
+ await assert.rejects(conflictingStop, /cannot stop replacement transaction/)
+ await requestStarted.promise
+ assert.strictEqual(requestHandler.mock.callCount(), 1)
+ responseGate.resolve({})
+ await Promise.all([firstStop, secondStop])
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(
+ call =>
+ (call.arguments[2] as OCPP20TransactionEventRequest).eventType ===
+ OCPP20TransactionEventEnumType.Ended
+ ).length,
+ 1
+ )
+ })
+
+ await it('should release a failed write-ahead stop so the same transaction can retry', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const writeFailure = new Error('terminal write-ahead failed')
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ let persistenceCalls = 0
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceCalls++
+ return persistenceCalls === 1 ? Promise.reject(writeFailure) : Promise.resolve()
+ })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ ),
+ /terminal write-ahead failed/
+ )
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+
+ await OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+
+ assert.strictEqual(
+ mockTracking.sentRequests.filter(
+ ({ payload }) => payload.eventType === OCPP20TransactionEventEnumType.Ended
+ ).length,
+ 1
+ )
+ })
+
+ await it('should cancel a staged stop without mutating a replacement connector object', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const replacementTransactionId = generateUUID()
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const originalConnectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ const evseStatus = mockTracking.station.getEvseStatus(1)
+ assert.ok(originalConnectorStatus != null)
+ assert.ok(evseStatus != null)
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceStarted.resolve(undefined)
+ return new Promise<void>(() => {
+ // Deliberately never settles.
+ })
+ })
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const stoppedRejection = assert.rejects(stopped, /shutdown finalized/)
+ await persistenceStarted.promise
+ const replacementConnectorStatus = {
+ ...originalConnectorStatus,
+ transactionEnding: false,
+ transactionEventQueue: [],
+ transactionId: replacementTransactionId,
+ transactionIdTag: 'REPLACEMENT',
+ transactionSeqNo: 0,
+ transactionStarted: true,
+ }
+ evseStatus.connectors.set(connectorId, replacementConnectorStatus)
+
+ OCPP20ServiceUtils.cancelTransactionTerminations(mockTracking.station)
+ await stoppedRejection
+ assert.strictEqual(replacementConnectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(replacementConnectorStatus.transactionIdTag, 'REPLACEMENT')
+ assert.strictEqual(replacementConnectorStatus.transactionStarted, true)
+ assert.strictEqual(replacementConnectorStatus.transactionEnding, false)
+ assert.strictEqual(replacementConnectorStatus.transactionSeqNo, 0)
+ })
+
+ await it('should cancel an offline stop blocked on its first persistence', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ const carryBeforeCancellation = { preserved: 17 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ ...carryBeforeCancellation,
+ }
+ mock.method(mockTracking.station, 'isWebSocketConnectionOpened', () => false)
+ let persistenceCalls = 0
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceCalls++
+ if (persistenceCalls === 1) {
+ persistenceStarted.resolve(undefined)
+ return releasePersistence.promise
+ }
+ return Promise.resolve()
+ })
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const stoppedRejection = assert.rejects(stopped, /shutdown finalized/)
+ await persistenceStarted.promise
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+
+ OCPP20ServiceUtils.cancelTransactionTerminations(mockTracking.station)
+ await stoppedRejection
+
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length ?? 0, 0)
+
+ releasePersistence.resolve(undefined)
+ await flushMicrotasks()
+ assert.strictEqual(mockTracking.requestHandlerMock.mock.callCount(), 0)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length ?? 0, 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry,
+ carryBeforeCancellation
+ )
+ })
+
+ await it('should restore the exact pre-Ended queue when shutdown cancels first persistence', async () => {
+ const connectorId = 1
+ const historicalTransactionId = generateUUID()
+ const transactionId = generateUUID()
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ mock.method(mockTracking.station, 'isWebSocketConnectionOpened', () => false)
+ const priorRequest = buildTransactionEvent(mockTracking.station, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId: historicalTransactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ priorRequest.customData = { payload: '', vendorId: 'test' }
+ const queuedUpdated = {
+ deliveryAttempted: false,
+ request: priorRequest,
+ seqNo: priorRequest.seqNo,
+ timestamp: new Date(1_000),
+ }
+ priorRequest.customData.payload = 'x'.repeat(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES -
+ Buffer.byteLength(JSON.stringify(queuedUpdated), 'utf8') -
+ 64
+ )
+ const originalQueue = [queuedUpdated]
+ const originalEvent = structuredClone(queuedUpdated)
+ connectorStatus.transactionEventQueue = originalQueue
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceStarted.resolve(undefined)
+ return releasePersistence.promise
+ })
+ const savedQueues: unknown[][] = []
+ mock.method(mockTracking.station, 'saveTransactionEventQueues', () => {
+ savedQueues.push(structuredClone(connectorStatus.transactionEventQueue ?? []))
+ })
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const stoppedRejection = assert.rejects(stopped, /shutdown finalized/)
+ await persistenceStarted.promise
+ assert.strictEqual(connectorStatus.transactionEventQueue.includes(queuedUpdated), false)
+
+ OCPP20ServiceUtils.cancelTransactionTerminations(mockTracking.station)
+ await stoppedRejection
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, originalQueue)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], queuedUpdated)
+ assert.deepStrictEqual(queuedUpdated, originalEvent)
+ assert.deepStrictEqual(savedQueues.at(-1), [originalEvent])
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+
+ releasePersistence.resolve(undefined)
+ await flushMicrotasks()
+ assert.strictEqual(connectorStatus.transactionEventQueue, originalQueue)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], queuedUpdated)
+ assert.deepStrictEqual(queuedUpdated, originalEvent)
+ assert.deepStrictEqual(savedQueues.at(-1), [originalEvent])
+ })
+
+ await it('should retain a durably staged Ended when shutdown cancels before transport', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const lifecycleAbortController = new AbortController()
+ const secondPersistenceStarted = Promise.withResolvers<undefined>()
+ const releaseSecondPersistence = Promise.withResolvers<undefined>()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ ;(
+ mockTracking.station as unknown as { lifecycleAbortSignal: AbortSignal }
+ ).lifecycleAbortSignal = lifecycleAbortController.signal
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ let persistenceCalls = 0
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceCalls++
+ if (persistenceCalls === 2) {
+ secondPersistenceStarted.resolve(undefined)
+ return releaseSecondPersistence.promise
+ }
+ return Promise.resolve()
+ })
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const stoppedRejection = assert.rejects(stopped, /shutdown finalized/)
+ await secondPersistenceStarted.promise
+ OCPP20ServiceUtils.pauseTransactionMeterValues(mockTracking.station)
+ lifecycleAbortController.abort()
+ OCPP20ServiceUtils.cancelTransactionTerminations(mockTracking.station)
+ await stoppedRejection
+
+ const countQueuedEnded = (): number =>
+ (connectorStatus.transactionEventQueue ?? []).filter(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ).length
+ assert.strictEqual(countQueuedEnded(), 1)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ assert.notStrictEqual(connectorStatus.transactionRestored, true)
+ const startUpdated = mock.method(
+ OCPP20ServiceUtils,
+ 'startUpdatedMeterValues',
+ () => undefined
+ )
+ const startEnded = mock.method(OCPP20ServiceUtils, 'startEndedMeterValues', () => undefined)
+ OCPP20ServiceUtils.resumeRestoredTransactionMeterValues(mockTracking.station)
+ assert.strictEqual(startUpdated.mock.callCount(), 0)
+ assert.strictEqual(startEnded.mock.callCount(), 0)
+ assert.strictEqual(mockTracking.requestHandlerMock.mock.callCount(), 0)
+
+ releaseSecondPersistence.resolve(undefined)
+ await flushMicrotasks()
+ assert.strictEqual(countQueuedEnded(), 1)
+ })
+
+ await it('should retain a durable Ended after an ambiguous transport start is cancelled', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const lifecycleAbortController = new AbortController()
+ const transportStarted = Promise.withResolvers<undefined>()
+ const releaseTransport = Promise.withResolvers<EmptyObject>()
+ const transportError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'ambiguous terminal delivery',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ ;(
+ mockTracking.station as unknown as { lifecycleAbortSignal: AbortSignal }
+ ).lifecycleAbortSignal = lifecycleAbortController.signal
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ mock.method(
+ mockTracking.station.ocppRequestService,
+ 'requestHandler',
+ (...args: unknown[]): Promise<EmptyObject> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ requestParams.onTransportError?.(transportError, true)
+ transportStarted.resolve(undefined)
+ return releaseTransport.promise
+ }
+ )
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => Promise.resolve())
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ const stoppedRejection = assert.rejects(stopped, /shutdown finalized/)
+ await transportStarted.promise
+ lifecycleAbortController.abort()
+ OCPP20ServiceUtils.cancelTransactionTerminations(mockTracking.station)
+ await stoppedRejection
+
+ const countQueuedEnded = (): number =>
+ (connectorStatus.transactionEventQueue ?? []).filter(
+ queuedEvent =>
+ queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended &&
+ queuedEvent.request.transactionInfo.transactionId === transactionId
+ ).length
+ assert.strictEqual(countQueuedEnded(), 1)
+ assert.strictEqual(connectorStatus.transactionEnding, true)
+ assert.notStrictEqual(connectorStatus.transactionRestored, true)
+
+ releaseTransport.reject(transportError)
+ await flushMicrotasks()
+ assert.strictEqual(countQueuedEnded(), 1)
+ })
+
+ await it('should not mutate a replacement connector while an older stop waits to stage', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const replacementTransactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const originalConnectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ const evseStatus = mockTracking.station.getEvseStatus(1)
+ assert.ok(originalConnectorStatus != null)
+ assert.ok(evseStatus != null)
+ const predecessor = TransactionMeterValueDeliveryBarrier.begin(
+ originalConnectorStatus,
+ transactionId
+ )
+ assert.ok(predecessor != null)
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ await flushMicrotasks()
+ const replacementConnectorStatus = {
+ ...originalConnectorStatus,
+ transactionEnding: false,
+ transactionEventQueue: [],
+ transactionId: replacementTransactionId,
+ transactionIdTag: 'REPLACEMENT',
+ transactionSeqNo: 0,
+ transactionStarted: true,
+ }
+ evseStatus.connectors.set(connectorId, replacementConnectorStatus)
+ predecessor.settle(true)
+
+ await assert.rejects(stopped, /no longer owns connector/)
+ assert.strictEqual(replacementConnectorStatus.transactionId, replacementTransactionId)
+ assert.strictEqual(replacementConnectorStatus.transactionIdTag, 'REPLACEMENT')
+ assert.strictEqual(replacementConnectorStatus.transactionStarted, true)
+ assert.strictEqual(replacementConnectorStatus.transactionEnding, false)
+ assert.strictEqual(replacementConnectorStatus.transactionSeqNo, 0)
+ assert.deepStrictEqual(replacementConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ })
+
+ await it('should default to RemoteStop triggerReason and Remote stoppedReason', async () => {
+ // Arrange
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(
+ r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(txEvents.length, 1)
+
+ const endedEvent = txEvents[0].payload
+ assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+ assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
+ const transactionInfo = endedEvent.transactionInfo as OCPP20TransactionType
+ assert.strictEqual(transactionInfo.stoppedReason, OCPP20ReasonEnumType.Remote)
+ })
+
+ await it('should use custom triggerReason and stoppedReason when provided', async () => {
+ // Arrange
+ const connectorId = 2
+ const transactionId = generateUUID()
+ const customTriggerReason = OCPP20TriggerReasonEnumType.Authorized
+ const customStoppedReason = OCPP20ReasonEnumType.DeAuthorized
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.notStrictEqual(connectorStatus, undefined)
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ if (connectorStatus != null) {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ }
+
+ // Act
+ await OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 2,
+ customTriggerReason,
+ customStoppedReason
+ )
+
+ // Assert
+ const txEvents = mockTracking.sentRequests.filter(
+ r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ assert.strictEqual(txEvents.length, 1)
+
+ const endedEvent = txEvents[0].payload
+ assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
+ assert.strictEqual(endedEvent.triggerReason, customTriggerReason)
+ const transactionInfo = endedEvent.transactionInfo as OCPP20TransactionType
+ assert.strictEqual(transactionInfo.stoppedReason, customStoppedReason)
+ })
+
+ await it('should deliver rejected TxUpdated interval energy in an immediate Ended event', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ const callError = new OCPPError(
+ ErrorType.GENERIC_ERROR,
+ 'CALLERROR for Updated',
+ OCPP20RequestCommand.TRANSACTION_EVENT
+ )
+ let endedRequest: OCPP20TransactionEventRequest | undefined
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ requestParams?.onMessageSent?.()
+ if (request.eventType === OCPP20TransactionEventEnumType.Updated) {
+ requestParams?.onError?.(callError, true)
+ return Promise.reject(callError)
+ }
+ endedRequest = request
+ requestParams?.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ intervalBaselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { save: false }
+ )
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const updatedMeterValue: OCPP20MeterValue = {
+ sampledValue: [
+ {
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
+ }
+ recordTransactionIntervalConsumption(updatedMeterValue, intervalBaselineKey, 10)
+
+ await assert.rejects(
+ OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ connectorId,
+ transactionId,
+ { meterValue: [updatedMeterValue] },
+ { throwError: true }
+ ),
+ /CALLERROR for Updated/
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [intervalBaselineKey]: 10,
+ })
+ const persistenceStarted = Promise.withResolvers<undefined>()
+ const releasePersistence = Promise.withResolvers<undefined>()
+ let persistenceCalls = 0
+ station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue?.[0].deliveryAttempted,
+ persistenceCalls !== 1
+ )
+ if (persistenceCalls === 1) {
+ persistenceStarted.resolve(undefined)
+ return releasePersistence.promise
+ }
+ return Promise.resolve()
+ }
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(station, connectorId)
+ await persistenceStarted.promise
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [intervalBaselineKey]: 10,
+ })
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ releasePersistence.resolve(undefined)
+ await stopped
+
+ const endedIntervalSamples = endedRequest?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ endedIntervalSamples?.map(({ value }) => value),
+ [10]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
+
+ await it('should rebuild an unattempted Ended after a buffered predecessor is rejected', async () => {
+ mock.timers.enable({ apis: ['Date'], now: 0 })
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ const evseStatus = mockTracking.station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [intervalBaselineKey]: 10 }
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ value: 0,
+ },
+ ]
+ addConfigurationKey(
+ mockTracking.station,
+ intervalBaselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { save: false }
+ )
+ const predecessor = TransactionMeterValueDeliveryBarrier.begin(connectorStatus, transactionId)
+ assert.ok(predecessor != null)
+ predecessor.markBuffered()
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1
+ )
+ await flushMicrotasks()
+
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(
+ connectorStatus.transactionEventQueue?.[0]?.request.eventType,
+ OCPP20TransactionEventEnumType.Ended
+ )
+ assert.strictEqual(connectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [intervalBaselineKey]: 7 }
+ predecessor.settle(true)
+ await stopped
+
+ const endedRequest = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedRequest?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [7]
+ )
+ })
+
+ await it('should recover a blocked Ended after predecessor reconciliation persistence fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const predecessor = TransactionMeterValueDeliveryBarrier.begin(connectorStatus, transactionId)
+ assert.ok(predecessor != null)
+ predecessor.markBuffered()
+ let persistenceCalls = 0
+ mockTracking.station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ return persistenceCalls === 2
+ ? Promise.reject(new Error('predecessor reconciliation persistence failed'))
+ : Promise.resolve()
+ }
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1
+ )
+ await flushMicrotasks()
+ const retainedEnded = connectorStatus.transactionEventQueue?.[0]
+ assert.ok(retainedEnded != null)
+ mockTracking.station.started = false
+ predecessor.settle(true)
+ await stopped
+
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(connectorStatus.transactionEventQueue?.[0], retainedEnded)
+ assert.strictEqual(retainedEnded.meterValuePredecessorsPending, true)
+ assert.strictEqual(isTransactionEventQueueBlocked(retainedEnded), false)
+
+ resetTransactionEventQueueRuntimeState(connectorStatus)
+ mockTracking.station.started = true
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockTracking.station, connectorId, 1)
+
+ const sentEnded = mockTracking.sentRequests.filter(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )
+ assert.strictEqual(sentEnded.length, 1)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
+ })
+
+ await it('should rebuild a persisted pending Ended before queue replay', async () => {
+ mock.timers.enable({ apis: ['Date'], now: 0 })
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ const evseStatus = mockTracking.station.getEvseStatus(1)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [intervalBaselineKey]: 10 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [intervalBaselineKey]: 7 }
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: OCPP20UnitEnumType.WATT_HOUR,
+ value: 0,
+ },
+ ]
+ addConfigurationKey(
+ mockTracking.station,
+ intervalBaselineKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { save: false }
+ )
+ const endedRequest = buildTransactionEvent(mockTracking.station, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: [],
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ connectorStatus.transactionEventQueue = [
{
- attributeType: AttributeEnumType.Actual,
- attributeValue: '-10',
- component: { name: OCPP20ComponentName.SampledDataCtrlr },
- variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
+ meterValuePredecessorsPending: true,
+ request: endedRequest,
+ seqNo: endedRequest.seqNo,
+ timestamp: endedRequest.timestamp,
},
- ])
-
- const interval = OCPP20ServiceUtils.getTxUpdatedInterval(station)
-
- assert.strictEqual(interval, Constants.DEFAULT_TX_UPDATED_INTERVAL_SECONDS * 1000)
+ ]
+
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(mockTracking.station, connectorId, 1)
+
+ const replayedEnded = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = replayedEnded?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [7]
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [])
})
- })
- await describe('requestDeauthorizeTransaction', async () => {
- let mockTracking: MockStationWithTracking
+ await it('should bound collected TxEnded samples while preserving interval energy and signing evidence', t => {
+ t.mock.timers.enable({ apis: ['setInterval'] })
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ let sampleIndex = 0
+ mock.method(OCPP20ServiceUtils, 'buildTransactionMeterValue', () => {
+ const index = sampleIndex++
+ return {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ ...(index === 0 || index === 50
+ ? {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: index === 0 ? 'public-key' : '',
+ signedMeterData: `signed-${index.toString()}`,
+ signingMethod: '',
+ },
+ }
+ : { customData: { padding: 'x'.repeat(15_000), vendorId: 'test' } }),
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ }
+ })
- beforeEach(() => {
- mockTracking = createMockStationWithRequestTracking()
- resetConnectorTransactionState(mockTracking.station)
- })
+ OCPP20ServiceUtils.startEndedMeterValues(mockTracking.station, connectorId, 1, 1)
+ t.mock.timers.tick(100)
+ OCPP20ServiceUtils.stopEndedMeterValues(mockTracking.station, connectorId, 1)
- afterEach(() => {
- standardCleanup()
+ const collected = connectorStatus.transactionEndedMeterValues as OCPP20MeterValue[]
+ assert.ok(collected.length < sampleIndex)
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(collected), 'utf8') <=
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ assert.strictEqual(collected[0].timestamp.getTime(), 0)
+ assert.strictEqual(collected.at(-1)?.timestamp.getTime(), 99_000)
+ const intervalSamples = collected.flatMap(meterValue => meterValue.sampledValue)
+ assert.strictEqual(
+ intervalSamples.reduce((total, sampledValue) => total + sampledValue.value, 0),
+ sampleIndex
+ )
+ assert.ok(
+ intervalSamples.some(
+ sampledValue => sampledValue.signedMeterValue?.publicKey === 'public-key'
+ )
+ )
+ assert.ok(
+ intervalSamples.some(
+ sampledValue => sampledValue.signedMeterValue?.signedMeterData === 'signed-50'
+ )
+ )
})
- await it('should send Updated(Deauthorized, SuspendedEVSE) then Ended(Deauthorized, DeAuthorized)', async () => {
- // Arrange
+ await it('should preserve consumed signed interval carry through Ended queue compaction', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
+ const intervalBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ const intervalEnergyWh = 100
setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ [intervalBaselineKey]: intervalEnergyWh,
}
-
- // Act
- await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
-
- // Assert
- const txEvents = mockTracking.sentRequests.filter(
- r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ connectorStatus.transactionEndedMeterValues = Array.from(
+ { length: intervalEnergyWh },
+ (_, index) => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ ...(index === 0 || index === 50 || index === intervalEnergyWh - 1
+ ? {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: index === 0 ? 'public-key' : '',
+ signedMeterData: `signed-${index.toString()}`,
+ signingMethod: '',
+ },
+ }
+ : { customData: { padding: 'x'.repeat(15_000), vendorId: 'test' } }),
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ },
+ ],
+ timestamp: new Date(index * 1000),
+ })
)
- assert.strictEqual(txEvents.length, 2)
- const updatedEvent = txEvents[0].payload
- assert.strictEqual(updatedEvent.eventType, OCPP20TransactionEventEnumType.Updated)
- assert.strictEqual(updatedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
- assert.strictEqual(updatedEvent.chargingState, OCPP20ChargingStateEnumType.SuspendedEVSE)
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
- const endedEvent = txEvents[1].payload
- assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
- assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.Deauthorized)
- assert.strictEqual(endedEvent.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples =
+ endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ) ?? []
+ assert.strictEqual(
+ intervalSamples.reduce((total, sample) => total + sample.value, 0),
+ intervalEnergyWh
+ )
+ assert.strictEqual(
+ intervalSamples
+ .filter(sample => sample.signedMeterValue == null)
+ .reduce((total, sample) => total + sample.value, 0),
+ intervalEnergyWh - 3
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
})
- await it('should include final meter values with Transaction.End context in Ended event', async () => {
- // Arrange
- const connectorId = 2
+ await it('should use signed cumulative terminal evidence without an unsigned recovery sample', async () => {
+ const connectorId = 1
const transactionId = generateUUID()
- const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 1500
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 10 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 10 }
+ addConfigurationKey(
+ mockTracking.station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'true',
+ undefined,
+ { save: false }
+ )
+ const signedTerminalSample = {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-terminal-register',
+ signingMethod: '',
+ },
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
}
+ mock.method(OCPP20ServiceUtils, 'buildTransactionMeterValue', () => ({
+ sampledValue: [signedTerminalSample],
+ timestamp: new Date(10_000),
+ }))
- const evseStatus = mockTracking.station.getEvseStatus(
- mockTracking.station.getEvseIdByConnectorId(connectorId) ?? 1
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ const endedRequest = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ assert.ok(endedRequest != null)
+ const terminalSamples = endedRequest.meterValue?.flatMap(value => value.sampledValue) ?? []
+ assert.deepStrictEqual(
+ terminalSamples.filter(sample => sample.signedMeterValue != null),
+ [signedTerminalSample]
)
- if (evseStatus != null) {
- evseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as ConnectorStatus['MeterValues']
- }
+ assert.strictEqual(
+ terminalSamples.some(
+ sample => sample.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ),
+ false
+ )
+ })
+ await it('should fail closed and restore terminal state when signed recovery evidence is missing', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = 7
+ connectorStatus.transactionEvseSent = true
+ connectorStatus.transactionIdTokenSent = true
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 10 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 10 }
+ OCPP20ServiceUtils.startEndedMeterValues(mockTracking.station, connectorId, 60_000, 1)
addConfigurationKey(
mockTracking.station,
- `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`,
- 'Energy.Active.Import.Register',
+ buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'true',
undefined,
{ save: false }
)
+ mock.method(OCPP20ServiceUtils, 'buildTransactionMeterValue', () => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(10_000),
+ }))
+ const persistSpy = mock.method(mockTracking.station, 'persistTransactionEventQueues', () =>
+ Promise.resolve()
+ )
- // Act
- await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2)
-
- // Assert
- const txEvents = mockTracking.sentRequests.filter(
- r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1),
+ /Signed terminal energy evidence is unavailable/
)
- assert.strictEqual(txEvents.length, 2)
- const endedPayload = txEvents[1].payload
- assert.strictEqual(endedPayload.stoppedReason, OCPP20ReasonEnumType.DeAuthorized)
- const meterValues = endedPayload.meterValue as OCPP20MeterValue[] | undefined
- assert.notStrictEqual(meterValues, undefined)
- if (meterValues == null) {
- assert.fail('Expected meterValue to be defined in Ended event')
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(persistSpy.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 7)
+ assert.strictEqual(connectorStatus.transactionEvseSent, true)
+ assert.strictEqual(connectorStatus.transactionIdTokenSent, true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, {
+ [baselineKey]: 10,
+ })
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [baselineKey]: 10,
+ })
+ assert.ok(connectorStatus.transactionEndedMeterValuesSetInterval != null)
+ OCPP20ServiceUtils.stopEndedMeterValues(mockTracking.station, connectorId, 1)
+ })
+
+ await it('should remove an offline Ended event and restore active state when persistence fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = 4
+ OCPP20ServiceUtils.startEndedMeterValues(mockTracking.station, connectorId, 60_000, 1)
+ mock.method(mockTracking.station, 'isWebSocketConnectionOpened', () => false)
+ let persistenceCalls = 0
+ mockTracking.station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ return persistenceCalls === 1
+ ? Promise.reject(new Error('offline Ended persistence failed'))
+ : Promise.resolve()
}
- assert.strictEqual(meterValues.length, 1)
- const endedMeterValue = meterValues[0]
- assert.ok(endedMeterValue.timestamp instanceof Date)
- assert.strictEqual(endedMeterValue.sampledValue.length, 1)
- const sampledValue = endedMeterValue.sampledValue[0]
- assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.TRANSACTION_END)
- assert.strictEqual(
- sampledValue.measurand,
- OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1),
+ /offline Ended persistence failed/
)
+
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 4)
+ assert.strictEqual(connectorStatus.transactionEventQueue, undefined)
+ assert.ok(connectorStatus.transactionEndedMeterValuesSetInterval != null)
+ OCPP20ServiceUtils.stopEndedMeterValues(mockTracking.station, connectorId, 1)
})
- await it('should reset connector status after deauthorization', async () => {
- // Arrange
+ for (const online of [false, true]) {
+ await it(`should restore the full ${online ? 'online' : 'offline'} queue after Ended write-ahead failure`, async () => {
+ const connectorId = 1
+ const historicalTransactionId = generateUUID()
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ mock.method(mockTracking.station, 'isWebSocketConnectionOpened', () => online)
+ const priorRequest = buildTransactionEvent(mockTracking.station, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId: historicalTransactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ priorRequest.customData = { payload: '', vendorId: 'test' }
+ const queuedUpdated = {
+ deliveryAttempted: false,
+ request: priorRequest,
+ seqNo: priorRequest.seqNo,
+ timestamp: new Date(1_000),
+ }
+ priorRequest.customData.payload = 'x'.repeat(
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES -
+ Buffer.byteLength(JSON.stringify(queuedUpdated), 'utf8') -
+ 64
+ )
+ const originalQueue = [queuedUpdated]
+ const originalEvent = structuredClone(queuedUpdated)
+ connectorStatus.transactionEventQueue = originalQueue
+ let persistenceCalls = 0
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', () => {
+ persistenceCalls++
+ return persistenceCalls === 1
+ ? Promise.reject(new Error('terminal queue snapshot persistence failed'))
+ : Promise.resolve()
+ })
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1),
+ /terminal queue snapshot persistence failed/
+ )
+
+ assert.strictEqual(connectorStatus.transactionEventQueue, originalQueue)
+ assert.strictEqual(connectorStatus.transactionEventQueue[0], queuedUpdated)
+ assert.deepStrictEqual(queuedUpdated, originalEvent)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ })
+ }
+
+ await it('should not resurrect an acknowledged replay when a concurrent Ended write-ahead fails', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
+ const replayStarted = Promise.withResolvers<undefined>()
+ const releaseReplay = Promise.withResolvers<undefined>()
+ const terminalPersistenceStarted = Promise.withResolvers<undefined>()
+ const rejectTerminalPersistence = Promise.withResolvers<undefined>()
+ const terminalPersistenceFailure = new Error('concurrent terminal write-ahead failed')
setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 100
- connectorStatus.transactionDeauthorized = true
- connectorStatus.transactionDeauthorizedEnergyWh = 50
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId, 1)
+ assert.ok(connectorStatus != null)
+ const replayRequest = buildTransactionEvent(mockTracking.station, {
+ connectorId,
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ transactionId,
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+ })
+ const replayedEvent = {
+ deliveryAttempted: false,
+ request: replayRequest,
+ seqNo: replayRequest.seqNo,
+ timestamp: new Date(1_000),
}
+ connectorStatus.transactionEventQueue = [replayedEvent]
+ const sentEventTypes: OCPP20TransactionEventEnumType[] = []
+ mock.method(
+ mockTracking.station.ocppRequestService,
+ 'requestHandler',
+ async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams
+ sentEventTypes.push(request.eventType)
+ requestParams.onMessageSent?.()
+ replayStarted.resolve(undefined)
+ await releaseReplay.promise
+ requestParams.onResponseReceived?.()
+ return {}
+ }
+ )
+ let terminalPersistenceClaimed = false
+ mock.method(mockTracking.station, 'persistTransactionEventQueues', async () => {
+ const hasEnded = connectorStatus.transactionEventQueue?.some(
+ queuedEvent => queuedEvent.request.eventType === OCPP20TransactionEventEnumType.Ended
+ )
+ if (hasEnded === true && !terminalPersistenceClaimed) {
+ terminalPersistenceClaimed = true
+ terminalPersistenceStarted.resolve(undefined)
+ await rejectTerminalPersistence.promise
+ throw terminalPersistenceFailure
+ }
+ })
- // Act
- await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+ const replay = OCPP20ServiceUtils.sendQueuedTransactionEvents(
+ mockTracking.station,
+ connectorId,
+ 1
+ )
+ await replayStarted.promise
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(
+ mockTracking.station,
+ connectorId,
+ 1,
+ undefined,
+ undefined,
+ transactionId
+ )
+ releaseReplay.resolve(undefined)
+ await replay
+ await terminalPersistenceStarted.promise
+ rejectTerminalPersistence.resolve(undefined)
+ await assert.rejects(stopped, /concurrent terminal write-ahead failed/)
+
+ assert.deepStrictEqual(sentEventTypes, [OCPP20TransactionEventEnumType.Updated])
+ assert.strictEqual(connectorStatus.transactionEventQueue.includes(replayedEvent), false)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.notStrictEqual(connectorStatus.transactionEnding, true)
+ })
- // Assert
- const postStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(postStatus, undefined)
- if (postStatus != null) {
- assert.strictEqual(postStatus.transactionStarted, false)
- assert.strictEqual(postStatus.transactionId, undefined)
- assert.strictEqual(postStatus.transactionDeauthorized, undefined)
- assert.strictEqual(postStatus.transactionDeauthorizedEnergyWh, undefined)
+ await it('should restore an active transaction when the first terminal write-ahead fails', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = 4
+ OCPP20ServiceUtils.startEndedMeterValues(mockTracking.station, connectorId, 60_000, 1)
+ let persistenceCalls = 0
+ mockTracking.station.persistTransactionEventQueues = () => {
+ persistenceCalls++
+ return persistenceCalls === 1
+ ? Promise.reject(new Error('first terminal write-ahead failed'))
+ : Promise.resolve()
}
+
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1),
+ /first terminal write-ahead failed/
+ )
+
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 4)
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue ?? [], [])
+ assert.ok(connectorStatus.transactionEndedMeterValuesSetInterval != null)
+
+ OCPP20ServiceUtils.stopEndedMeterValues(mockTracking.station, connectorId, 1)
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+ assert.strictEqual(mockTracking.sentRequests.length, 1)
})
- await it('should throw if no active transaction', async () => {
+ await it('should restore terminal state after capacity rejection and succeed when capacity is available', async () => {
const connectorId = 1
+ const transactionId = generateUUID()
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionSeqNo = 9
+ connectorStatus.transactionEvseSent = true
+ connectorStatus.transactionIdTokenSent = true
+ connectorStatus.publicKeySentInTransaction = false
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 10
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 10 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 10 }
+ connectorStatus.transactionEndedMeterValues = [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'x'.repeat(Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES),
+ signingMethod: '',
+ },
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(10_000),
+ },
+ ]
+ const originalEndedMeterValues = structuredClone(connectorStatus.transactionEndedMeterValues)
+ OCPP20ServiceUtils.startEndedMeterValues(mockTracking.station, connectorId, 60_000, 1)
await assert.rejects(
- OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
- (error: unknown) => {
- assert.ok(error instanceof OCPPError)
- return true
- }
+ OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1),
+ /TransactionEvent queue capacity exhausted by protected entries/
+ )
+
+ assert.strictEqual(mockTracking.sentRequests.length, 0)
+ assert.strictEqual(connectorStatus.transactionId, transactionId)
+ assert.strictEqual(connectorStatus.transactionStarted, true)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.transactionSeqNo, 9)
+ assert.strictEqual(connectorStatus.transactionEvseSent, true)
+ assert.strictEqual(connectorStatus.transactionIdTokenSent, true)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, false)
+ assert.deepStrictEqual(connectorStatus.transactionEndedMeterValues, originalEndedMeterValues)
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, {
+ [baselineKey]: 10,
+ })
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ [baselineKey]: 10,
+ })
+ assert.ok(connectorStatus.transactionEndedMeterValuesSetInterval != null)
+
+ OCPP20ServiceUtils.stopEndedMeterValues(mockTracking.station, connectorId, 1)
+ connectorStatus.transactionEndedMeterValues = []
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ assert.strictEqual(mockTracking.sentRequests.length, 1)
+ assert.strictEqual(
+ mockTracking.sentRequests[0].payload.eventType,
+ OCPP20TransactionEventEnumType.Ended
)
})
- await it('should not terminate when StopTxOnInvalidId is false', async () => {
- // Arrange
+ await it('should keep historical TxEnded coverage separate from a later carry suffix', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
+ const endedBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ const carryBaselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxUpdatedMeasurands}`
setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 30
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [carryBaselineKey]: 30,
+ [endedBaselineKey]: 10,
}
- OCPP20VariableManager.getInstance().setVariables(mockTracking.station, [
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ [carryBaselineKey]: 10,
+ }
+ connectorStatus.transactionEndedMeterValues = [
{
- attributeType: AttributeEnumType.Actual,
- attributeValue: 'false',
- component: { name: OCPP20ComponentName.TxCtrlr },
- variable: { name: OCPP20RequiredVariableName.StopTxOnInvalidId },
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(1_000),
},
- ])
+ ]
+ mock.method(OCPP20ServiceUtils, 'buildTransactionMeterValue', () => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 30,
+ },
+ ],
+ timestamp: new Date(2_000),
+ }))
- // Act
- await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
- // Assert — only Updated(Deauthorized), no Ended
- const txEvents = mockTracking.sentRequests.filter(
- r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
- )
- assert.strictEqual(txEvents.length, 1)
- assert.strictEqual(txEvents[0].payload.eventType, OCPP20TransactionEventEnumType.Updated)
- assert.strictEqual(
- txEvents[0].payload.triggerReason,
- OCPP20TriggerReasonEnumType.Deauthorized
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [10, 10]
)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
+
+ await it('should preserve historical, carried, and final TxEnded interval energy', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const baselineKey = `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxEndedMeasurands}`
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 12
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 12 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 6 }
+ connectorStatus.transactionEndedMeterValues = [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.KILO_WATT_HOUR },
+ value: 0.006,
+ },
+ ],
+ timestamp: new Date(1_000),
+ },
+ ]
+ mock.method(OCPP20ServiceUtils, 'buildTransactionMeterValue', () => {
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 18
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { [baselineKey]: 18 }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { [baselineKey]: 12 }
+ return {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 6,
+ },
+ ],
+ timestamp: new Date(2_000),
+ }
+ })
- // Transaction should still be active
- const postStatus = mockTracking.station.getConnectorStatus(connectorId)
- if (postStatus != null) {
- assert.strictEqual(postStatus.transactionStarted, true)
- assert.strictEqual(postStatus.transactionId, transactionId)
- }
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
- OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const representedEnergyWh =
+ endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ .map(({ unitOfMeasure, value }) =>
+ unitOfMeasure?.unit === OCPP20UnitEnumType.KILO_WATT_HOUR ? value * 1000 : value
+ ) ?? []
+ assert.deepStrictEqual(representedEnergyWh, [6, 6, 6])
+ assert.strictEqual(
+ representedEnergyWh.reduce((total, energyWh) => total + energyWh, 0),
+ 18
+ )
})
- await it('should track deauth state for deferred termination via periodic meter values', () => {
+ await it('should sum successive terminal meter values before recovering overlapping carries', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- if (connectorStatus == null) {
- assert.fail('connectorStatus should not be undefined')
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ 'AlignedDataCtrlr.AlignedDataMeasurands': 10,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 10,
}
- setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- connectorStatus.transactionEnergyActiveImportRegisterValue = 500
- connectorStatus.transactionDeauthorized = true
- connectorStatus.transactionDeauthorizedEnergyWh = 500
+ connectorStatus.transactionEndedMeterValues = [1_000, 2_000].map(timestamp => ({
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 5,
+ },
+ ],
+ timestamp: new Date(timestamp),
+ }))
- assert.strictEqual(connectorStatus.transactionDeauthorized, true)
- assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, 500)
- assert.strictEqual(connectorStatus.transactionStarted, true)
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [5, 5]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
})
- await it('should propagate error and skip cleanup if Updated event fails', async () => {
+ await it('should recover only unrepresented carry and ignores Transaction.Begin samples', async () => {
const connectorId = 1
const transactionId = generateUUID()
- const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 10,
}
+ connectorStatus.transactionBeginMeterValue = {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_BEGIN,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 10,
+ },
+ ],
+ timestamp: new Date(0),
+ }
+ connectorStatus.transactionEndedMeterValues = [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 5,
+ },
+ ],
+ timestamp: new Date(1_000),
+ },
+ ]
- const originalSend = OCPP20ServiceUtils.sendTransactionEvent.bind(OCPP20ServiceUtils)
- const sendMock = mock.method(OCPP20ServiceUtils, 'sendTransactionEvent', async () => {
- sendMock.mock.restore()
- OCPP20ServiceUtils.sendTransactionEvent = originalSend
- return Promise.reject(new Error('Network failure'))
- })
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
- await assert.rejects(
- OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1),
- (error: unknown) => {
- assert.ok(error instanceof Error)
- assert.strictEqual(error.message, 'Network failure')
- return true
- }
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ context, measurand }) =>
+ measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL &&
+ context !== OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [5, 5]
)
-
- const postStatus = mockTracking.station.getConnectorStatus(connectorId)
- if (postStatus != null) {
- assert.strictEqual(postStatus.transactionStarted, true)
- assert.strictEqual(postStatus.transactionId, transactionId)
- }
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
})
- })
- await describe('requestStopTransaction', async () => {
- let mockTracking: MockStationWithTracking
+ await it('should consume each carry with a baseline only where suffix and recovery intervals overlap', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const deliveryStarted = Promise.withResolvers<OCPP20TransactionEventRequest>()
+ const releaseDelivery = Promise.withResolvers<undefined>()
+ const requestHandlerMock = mock.fn(async (...args: unknown[]): Promise<EmptyObject> => {
+ const request = args[2] as OCPP20TransactionEventRequest
+ const requestParams = args[3] as RequestParams | undefined
+ deliveryStarted.resolve(request)
+ await releaseDelivery.promise
+ requestParams?.onMessageSent?.()
+ requestParams?.onResponseReceived?.()
+ return {}
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ const olderBaselineKey = 'AlignedDataCtrlr.AlignedDataMeasurands'
+ const recentBaselineKey = 'SampledDataCtrlr.TxUpdatedMeasurands'
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 30
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ [olderBaselineKey]: 10.009,
+ [recentBaselineKey]: 30,
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ [olderBaselineKey]: 10.009,
+ [recentBaselineKey]: 10,
+ }
+ connectorStatus.transactionEndedMeterValues = [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 5,
+ },
+ ],
+ timestamp: new Date(1_000),
+ },
+ ]
+
+ const stopped = OCPP20ServiceUtils.requestStopTransaction(station, connectorId, 1)
+ const endedRequest = await deliveryStarted.promise
+ const recoverySample = endedRequest.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .find(
+ ({ context, measurand }) =>
+ context === OCPP20ReadingContextEnumType.TRANSACTION_END &&
+ measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.strictEqual(recoverySample?.value, 20)
+ assert.deepStrictEqual(
+ Object.keys(connectorStatus.transactionEnergyActiveImportIntervalCarry),
+ [recentBaselineKey]
+ )
+ assert.ok(
+ Math.abs(
+ connectorStatus.transactionEnergyActiveImportIntervalCarry[recentBaselineKey] - 0.009
+ ) < 1e-9
+ )
- beforeEach(() => {
- mockTracking = createMockStationWithRequestTracking()
- resetConnectorTransactionState(mockTracking.station)
+ releaseDelivery.resolve(undefined)
+ await stopped
})
- afterEach(() => {
- standardCleanup()
- })
+ const intervalDebtCases = [
+ {
+ baselines: { aligned: 10, periodic: 10 },
+ carries: { aligned: 10, periodic: 10 },
+ description: 'deduplicates fully overlapping interval debts',
+ expectedRecoveryWh: 10,
+ transactionRegisterWh: 10,
+ },
+ {
+ baselines: { aligned: 10, periodic: 15 },
+ carries: { aligned: 10, periodic: 5 },
+ description: 'adds adjacent disjoint interval debts',
+ expectedRecoveryWh: 15,
+ transactionRegisterWh: 15,
+ },
+ {
+ baselines: { aligned: 10, periodic: 15 },
+ carries: { aligned: 10, periodic: 10 },
+ description: 'merges partially overlapping interval debts',
+ expectedRecoveryWh: 15,
+ transactionRegisterWh: 15,
+ },
+ ] as const
+ for (const {
+ baselines,
+ carries,
+ description,
+ expectedRecoveryWh,
+ transactionRegisterWh,
+ } of intervalDebtCases) {
+ await it(description, async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, {
+ energyImport: transactionRegisterWh,
+ transactionId,
+ })
+ const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = { ...baselines }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = { ...carries }
+
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [expectedRecoveryWh]
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
+ }
- await it('should default to RemoteStop triggerReason and Remote stoppedReason', async () => {
- // Arrange
+ await it('should not apply historical interval energy without a position to known debts', async () => {
const connectorId = 1
const transactionId = generateUUID()
+ setupConnectorWithTransaction(mockTracking.station, connectorId, {
+ energyImport: 30,
+ transactionId,
+ })
const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
- setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ aligned: 10,
+ periodic: 30,
+ }
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ aligned: 10,
+ periodic: 10,
}
+ connectorStatus.transactionEndedMeterValues = [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 5,
+ },
+ ],
+ timestamp: new Date(1_000),
+ },
+ ]
- // Act
await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
- // Assert
- const txEvents = mockTracking.sentRequests.filter(
- r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [5, 20]
)
- assert.strictEqual(txEvents.length, 1)
-
- const endedEvent = txEvents[0].payload
- assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
- assert.strictEqual(endedEvent.triggerReason, OCPP20TriggerReasonEnumType.RemoteStop)
- assert.strictEqual(endedEvent.stoppedReason, OCPP20ReasonEnumType.Remote)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
})
- await it('should use custom triggerReason and stoppedReason when provided', async () => {
- // Arrange
- const connectorId = 2
+ await it('should recover disjoint interval debts after connector state persistence restore', async () => {
+ const connectorId = 1
const transactionId = generateUUID()
- const customTriggerReason = OCPP20TriggerReasonEnumType.Authorized
- const customStoppedReason = OCPP20ReasonEnumType.DeAuthorized
+ setupConnectorWithTransaction(mockTracking.station, connectorId, {
+ energyImport: 15,
+ transactionId,
+ })
const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
- assert.notStrictEqual(connectorStatus, undefined)
- setupConnectorWithTransaction(mockTracking.station, connectorId, { transactionId })
- if (connectorStatus != null) {
- connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines = {
+ aligned: 10,
+ periodic: 15,
}
+ connectorStatus.transactionEnergyActiveImportIntervalCarry = {
+ aligned: 10,
+ periodic: 5,
+ }
+ const restoredStatus = prepareConnectorStatus(
+ JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ )
+ connectorStatus.transactionEnergyActiveImportIntervalBaselines =
+ restoredStatus.transactionEnergyActiveImportIntervalBaselines
+ connectorStatus.transactionEnergyActiveImportIntervalCarry =
+ restoredStatus.transactionEnergyActiveImportIntervalCarry
- // Act
- await OCPP20ServiceUtils.requestStopTransaction(
- mockTracking.station,
- connectorId,
- 2,
- customTriggerReason,
- customStoppedReason
+ await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
+
+ const endedEvent = mockTracking.sentRequests.find(
+ ({ command, payload }) =>
+ command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ const intervalSamples = endedEvent?.meterValue
+ ?.flatMap(({ sampledValue }) => sampledValue)
+ .filter(
+ ({ measurand }) => measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ assert.deepStrictEqual(
+ intervalSamples?.map(({ value }) => value),
+ [15]
)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ })
- // Assert
- const txEvents = mockTracking.sentRequests.filter(
- r => r.command === OCPP20RequestCommand.TRANSACTION_EVENT
+ await it('should finalize local state without retrying after Ended exhausts its delivery attempts', async t => {
+ t.mock.timers.enable({ apis: ['setTimeout'] })
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const requestHandlerMock = mock.fn((...args: unknown[]): Promise<never> => {
+ const requestParams = args[3] as RequestParams
+ requestParams.onMessageSent?.()
+ return Promise.reject(new Error('CSMS rejected Ended'))
+ })
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler: requestHandlerMock },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ addConfigurationKey(
+ station,
+ `${OCPP20ComponentName.OCPPCommCtrlr}.${OCPP20RequiredVariableName.MessageAttempts}.TransactionEvent`,
+ '1',
+ undefined,
+ { save: false }
)
- assert.strictEqual(txEvents.length, 1)
+ station.isWebSocketConnectionOpened = () => true
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
- const endedEvent = txEvents[0].payload
- assert.strictEqual(endedEvent.eventType, OCPP20TransactionEventEnumType.Ended)
- assert.strictEqual(endedEvent.triggerReason, customTriggerReason)
- assert.strictEqual(endedEvent.stoppedReason, customStoppedReason)
+ await assert.rejects(
+ OCPP20ServiceUtils.requestStopTransaction(station, connectorId, 1),
+ /CSMS rejected Ended/
+ )
+
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert(connectorStatus != null)
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ t.mock.timers.tick(60_000)
+ await Promise.resolve()
+ assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
+ })
+
+ await it('should leave terminal connector state when persistence fails during Ended cleanup', async () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const { station } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ stationInfo: {
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station.started = false
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionEnding = true
+ mock.method(station, 'saveTransactionEventQueues', () => {
+ throw new OCPPError(ErrorType.GENERIC_ERROR, 'persistence unavailable')
+ })
+
+ await OCPP20ServiceUtils.cleanupEndedTransaction(
+ station,
+ connectorId,
+ connectorStatus,
+ 1,
+ transactionId
+ )
+
+ assert.strictEqual(connectorStatus.transactionId, undefined)
+ assert.strictEqual(connectorStatus.transactionStarted, false)
+ assert.strictEqual(connectorStatus.transactionEnding, undefined)
+ assert.strictEqual(connectorStatus.locked, false)
+ assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
})
})
)
})
+ await it('should keep configured energy unchanged in the transaction begin snapshot', async t => {
+ await withMockTimers(t, ['Date'], () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const transactionBeginAt = new Date('2026-09-08T10:00:00.000Z')
+ t.mock.timers.tick(transactionBeginAt.getTime())
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ const evseStatus = station.getEvseStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ connectorStatus.transactionStart = new Date(transactionBeginAt.getTime() - 3_600_000)
+ connectorStatus.energyActiveImportRegisterValue = 1234
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1234
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxStartedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ undefined,
+ { save: false }
+ )
+
+ const [meterValue] = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
+ station,
+ transactionId
+ )
+
+ assert.strictEqual(meterValue.timestamp.getTime(), transactionBeginAt.getTime())
+ assert.strictEqual(meterValue.sampledValue.length, 1)
+ assert.strictEqual(meterValue.sampledValue[0].value, 1234)
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, 1234)
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 1234)
+ })
+ })
+
+ await it('should baseline transaction energy when the begin snapshot omits energy', async t => {
+ await withMockTimers(t, ['Date'], () => {
+ const connectorId = 1
+ const transactionId = generateUUID()
+ const transactionBeginAt = new Date('2026-09-08T10:00:00.000Z')
+ const pendingStartedAt = new Date(transactionBeginAt.getTime() - 3_600_000)
+ const sampleInterval = 1000
+ t.mock.timers.tick(transactionBeginAt.getTime())
+ setupConnectorWithTransaction(station, connectorId, { transactionId })
+ const connectorStatus = station.getConnectorStatus(connectorId)
+ const evseStatus = station.getEvseStatus(connectorId)
+ assert.ok(connectorStatus != null)
+ assert.ok(evseStatus != null)
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.customValueLimitationMeterValues = true
+ connectorStatus.transactionStart = pendingStartedAt
+ connectorStatus.energyActiveImportRegisterValue = 100
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.VOLTAGE,
+ unit: 'V',
+ value: '230',
+ },
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000000',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxStartedMeasurands
+ ),
+ OCPP20MeasurandEnumType.VOLTAGE,
+ undefined,
+ { save: false }
+ )
+
+ const [beginMeterValue] = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
+ station,
+ transactionId
+ )
+
+ assert.ok(beginMeterValue.sampledValue.length > 0)
+ assert.strictEqual(
+ beginMeterValue.sampledValue.every(
+ sampledValue => sampledValue.measurand === OCPP20MeasurandEnumType.VOLTAGE
+ ),
+ true
+ )
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt?.getTime(),
+ transactionBeginAt.getTime()
+ )
+
+ t.mock.timers.tick(sampleInterval)
+ const txUpdatedMeasurandsKey = buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20RequiredVariableName.TxUpdatedMeasurands
+ )
+ addConfigurationKey(
+ station,
+ txUpdatedMeasurandsKey,
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ undefined,
+ { save: false }
+ )
+ const meterValue = buildMeterValue(
+ station,
+ transactionId,
+ 60_000,
+ txUpdatedMeasurandsKey,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ ) as OCPP20MeterValue
+ const expectedEnergyDelta = Number(
+ (
+ (station.getConnectorMaximumAvailablePower(connectorId) * sampleInterval) /
+ Constants.MS_PER_HOUR
+ ).toFixed(2)
+ )
+ const expectedEnergyRegister = 100 + expectedEnergyDelta
+
+ assert.strictEqual(connectorStatus.energyActiveImportRegisterValue, expectedEnergyRegister)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterValue,
+ expectedEnergyRegister
+ )
+ assert.strictEqual(meterValue.sampledValue[0].value, expectedEnergyRegister)
+ assert.strictEqual(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime(),
+ transactionBeginAt.getTime() + sampleInterval
+ )
+ })
+ })
+
await it('should return empty array when no transaction found for transactionId', () => {
const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
station,
)
})
+ await it('should include aligned signed Begin and End evidence when sampled signing is disabled', async () => {
+ const station = mockTracking.station
+ const evseStatus = station.getEvseStatus(1)
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(evseStatus != null)
+ assert.ok(connectorStatus != null)
+ evseStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as ConnectorStatus['MeterValues']
+ const transactionId = generateUUID()
+ setupConnectorWithTransaction(station, 1, { transactionId })
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 1000
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.TxEndedMeasurands
+ ),
+ OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'true',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.SignReadings
+ ),
+ 'false',
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ TEST_PUBLIC_KEY_HEX,
+ undefined,
+ { save: false }
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'SigningMethod'),
+ SigningMethodEnumType.ECDSA_secp256k1_SHA256,
+ undefined,
+ { save: false }
+ )
+
+ const [beginMeterValue] = OCPP20ServiceUtils.buildTransactionStartedMeterValues(
+ station,
+ transactionId,
+ 1,
+ 1
+ )
+ connectorStatus.transactionBeginMeterValue = beginMeterValue
+ await OCPP20ServiceUtils.requestStopTransaction(station, 1, 1)
+
+ const endedEvent = mockTracking.sentRequests.find(
+ request =>
+ request.command === OCPP20RequestCommand.TRANSACTION_EVENT &&
+ request.payload.eventType === OCPP20TransactionEventEnumType.Ended
+ )?.payload as OCPP20TransactionEventRequest | undefined
+ assert.ok(endedEvent != null)
+ const signedSamples = (endedEvent.meterValue ?? [])
+ .flatMap(meterValue => meterValue.sampledValue)
+ .filter(sampledValue => sampledValue.signedMeterValue != null)
+ assert.ok(signedSamples.length >= 2)
+ assert.deepStrictEqual(
+ new Set(signedSamples.map(sampledValue => sampledValue.context)),
+ new Set([
+ OCPP20ReadingContextEnumType.TRANSACTION_BEGIN,
+ OCPP20ReadingContextEnumType.TRANSACTION_END,
+ ])
+ )
+ })
+
await it('should send Ended event without meter values when TxEndedMeasurands config key is not set', async () => {
// Arrange
const transactionId = generateUUID()
},
websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
})
+ station.ocppRequestService = requestService
return { requestService, station, testableRequestService }
}
// First (FileTransferProtocols) should be rejected (ReadOnly); others accepted
assert.strictEqual(results[0].attributeStatus, SetVariableStatusEnumType.Rejected)
assert.strictEqual(results[0].attributeStatusInfo?.reasonCode, ReasonCodeEnumType.ReadOnly)
- for (const r of results.slice(1)) {
- assert.strictEqual(r.attributeStatus, SetVariableStatusEnumType.Accepted)
+ for (const [index, result] of results.slice(1).entries()) {
+ assert.strictEqual(
+ result.attributeStatus,
+ SetVariableStatusEnumType.Accepted,
+ `update ${String(index + 1)}: ${JSON.stringify(result)}`
+ )
}
})
standardCleanup,
} from '../../helpers/TestLifecycleHelpers.js'
import { TEST_ID_TAG } from '../ChargingStationTestConstants.js'
+import { createConnectorStatus } from '../helpers/StationHelpers.js'
await describe('OCPPConnectorStatusOperations', async () => {
afterEach(() => {
assert.strictEqual(requestHandler.mock.calls.length, 0)
assert.strictEqual(station.getConnectorStatus(1)?.status, ConnectorStatusEnum.Available)
})
+
+ await it('should restore only the specified EVSE when connector IDs repeat', async () => {
+ const { requestHandler, station } = createStationWithRequestHandler({
+ connectorsCount: 2,
+ evseConfiguration: { evsesCount: 2 },
+ ocppVersion: OCPPVersion.VERSION_201,
+ })
+ const evse1Connector = station.getEvseStatus(1)?.connectors.get(1)
+ const evse2 = station.getEvseStatus(2)
+ assert.ok(evse1Connector != null)
+ assert.ok(evse2 != null)
+ const evse2Connector = createConnectorStatus(1, { status: ConnectorStatusEnum.Occupied })
+ evse2.connectors.clear()
+ evse2.connectors.set(1, evse2Connector)
+ evse1Connector.status = ConnectorStatusEnum.Unavailable
+
+ await restoreConnectorStatus(station, 1, evse2Connector, 2)
+
+ assert.strictEqual(evse1Connector.status, ConnectorStatusEnum.Unavailable)
+ assert.strictEqual(evse2Connector.status, ConnectorStatusEnum.Available)
+ assert.strictEqual(requestHandler.mock.calls.length, 1)
+ assert.deepStrictEqual(requestHandler.mock.calls[0].arguments[2], {
+ connectorId: 1,
+ evseId: 2,
+ status: ConnectorStatusEnum.Available,
+ })
+ })
})
})
}
interface PlumbingAccess<T extends { stopped?: boolean }> {
+ activate: (chargingStation: ChargingStation, lifecycleSignal: AbortSignal) => void
createStationState: () => T
getOrCreateStationState: (chargingStation: ChargingStation) => T
+ incomingRequestHandlers: Map<
+ unknown,
+ (
+ chargingStation: ChargingStation,
+ payload: Record<string, never>
+ ) => Promise<Record<string, never>>
+ >
+ isIncomingRequestCommandSupported: (...args: unknown[]) => boolean
resetStationState: (state: T) => void
stationsState: WeakMap<ChargingStation, T>
+ validateIncomingRequestPayload: (...args: unknown[]) => void
}
const asPlumbing = (
assert.strictEqual(createSpy.mock.callCount(), 0)
})
+ await it('should install fresh state when the same station starts a new lifecycle', () => {
+ const first = plumbing.getOrCreateStationState(stationA)
+ service.stop(stationA)
+
+ plumbing.activate(stationA, stationA.lifecycleAbortSignal)
+ const restarted = plumbing.getOrCreateStationState(stationA)
+
+ assert.notStrictEqual(restarted, first)
+ assert.strictEqual(first.stopped, true)
+ assert.strictEqual(restarted.stopped, undefined)
+ assert.strictEqual(plumbing.stationsState.get(stationA), restarted)
+ })
+
+ await it('should bind a nested post-restart CALL to the fresh station state', async () => {
+ const oldState = plumbing.getOrCreateStationState(stationA)
+ const outerCommand = 'OuterInheritedCall' as Parameters<
+ OCPP16IncomingRequestService['incomingRequestHandler']
+ >[2]
+ const nestedCommand = 'NestedRestartedCall' as Parameters<
+ OCPP16IncomingRequestService['incomingRequestHandler']
+ >[2]
+ let freshState: OCPP16StationStateShape | undefined
+ let nestedState: OCPP16StationStateShape | undefined
+ mock.method(plumbing, 'isIncomingRequestCommandSupported', () => true)
+ mock.method(plumbing, 'validateIncomingRequestPayload', () => undefined)
+ mock.method(stationA, 'inAcceptedState', () => true)
+ mock.method(stationA.ocppRequestService, 'sendResponse', () => Promise.resolve())
+ plumbing.incomingRequestHandlers.set(nestedCommand, () => {
+ nestedState = plumbing.getOrCreateStationState(stationA)
+ return Promise.resolve({})
+ })
+ plumbing.incomingRequestHandlers.set(outerCommand, async () => {
+ service.stop(stationA)
+ plumbing.activate(stationA, stationA.lifecycleAbortSignal)
+ freshState = plumbing.stationsState.get(stationA)
+ await service.incomingRequestHandler(stationA, 'nested-call', nestedCommand, {})
+ return {}
+ })
+
+ await service.incomingRequestHandler(stationA, 'outer-call', outerCommand, {})
+
+ assert.ok(freshState != null)
+ assert.notStrictEqual(freshState, oldState)
+ assert.strictEqual(oldState.stopped, true)
+ assert.strictEqual(nestedState, freshState)
+ })
+
await it('should invoke resetStationState exactly once with the current state on stop()', () => {
const state = plumbing.getOrCreateStationState(stationA)
const resetSpy: ReturnType<typeof mock.fn> = mock.method(plumbing, 'resetStationState')
import assert from 'node:assert/strict'
import { afterEach, describe, it } from 'node:test'
+import { ChargingStation } from '../../../src/charging-station/ChargingStation.js'
+import { OCPP20ServiceUtils } from '../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
import {
AuthContext,
AuthenticationMethod,
stopRunningTransactions,
stopTransactionOnConnector,
} from '../../../src/charging-station/ocpp/OCPPServiceOperations.js'
-import { type OCPP20TransactionEventRequest, OCPPVersion } from '../../../src/types/index.js'
+import {
+ OCPP16ChargePointStatus,
+ OCPP16MeterValueMeasurand,
+ OCPP16MeterValueUnit,
+ type OCPP20TransactionEventRequest,
+ OCPPVersion,
+} from '../../../src/types/index.js'
import {
createStationWithRequestHandler,
setupConnectorWithTransaction,
standardCleanup,
} from '../../helpers/TestLifecycleHelpers.js'
import { createMockChargingStation } from '../helpers/StationHelpers.js'
+import { createMeterValuesTemplate } from './1.6/OCPP16TestUtils.js'
import {
createMockAuthorizationResult,
createMockAuthService,
})
await describe('stopRunningTransactions', async () => {
- await it('should call stopTransactionOnConnector sequentially for each OCPP 1.6 connector with active transaction', async () => {
+ await it('should call stopTransactionOnConnector for each OCPP 1.6 connector with an active transaction', async () => {
const sentCommands: string[] = []
const { requestHandler, station } = createStationWithRequestHandler({
connectorsCount: 2,
assert.strictEqual(stopCalls.length, 2)
})
+ await it('should isolate a malformed strict connector and continue the healthy stop and shutdown status phase', async () => {
+ const attemptedTransactionIds: number[] = []
+ const unavailableConnectorIds: number[] = []
+ const { requestHandler, station } = createStationWithRequestHandler({
+ connectorsCount: 2,
+ stationInfo: {
+ beginEndMeterValues: true,
+ ocppStrictCompliance: true,
+ ocppVersion: OCPPVersion.VERSION_16,
+ outOfOrderEndMeterValues: false,
+ },
+ })
+ requestHandler.mock.mockImplementation((...args: unknown[]) => {
+ if (args[1] === 'StatusNotification') {
+ const request = args[2] as { connectorId: number; status: OCPP16ChargePointStatus }
+ if (request.status === OCPP16ChargePointStatus.Unavailable) {
+ unavailableConnectorIds.push(request.connectorId)
+ }
+ return {}
+ }
+ if (args[1] !== 'StopTransaction') {
+ return {}
+ }
+ const transactionId = (args[2] as { transactionId: number }).transactionId
+ attemptedTransactionIds.push(transactionId)
+ return { idTagInfo: { status: 'Accepted' } }
+ })
+ setupConnectorWithTransaction(station, 1, { transactionId: 101 })
+ setupConnectorWithTransaction(station, 2, { transactionId: 102 })
+ const healthyConnectorStatus = station.getConnectorStatus(2)
+ assert.ok(healthyConnectorStatus != null)
+ healthyConnectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ const stationInternals = station as unknown as {
+ internalStopMessageSequence: () => void
+ lifecycleAbortSignal: AbortSignal
+ stopAlignedMeterValues: () => void
+ }
+ stationInternals.internalStopMessageSequence = () => undefined
+ stationInternals.lifecycleAbortSignal = new AbortController().signal
+ stationInternals.stopAlignedMeterValues = () => undefined
+ const stopMessageSequence = (
+ ChargingStation.prototype as unknown as {
+ stopMessageSequence: (reason?: undefined, stopTransactions?: boolean) => Promise<void>
+ }
+ ).stopMessageSequence
+
+ await assert.doesNotReject(stopMessageSequence.call(station, undefined, true))
+
+ assert.deepStrictEqual(attemptedTransactionIds, [102])
+ assert.deepStrictEqual(
+ unavailableConnectorIds.sort((a, b) => a - b),
+ [1, 2]
+ )
+ })
+
+ await it('should reissue StopTransaction for a stale Finishing OCPP 1.6 connector', async () => {
+ const { requestHandler, station } = createStationWithRequestHandler()
+ requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+ Promise.resolve({ idTagInfo: { status: 'Accepted' } })
+ )
+ setupConnectorWithTransaction(station, 1, { transactionId: 101 })
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus == null) {
+ assert.fail('Expected connector to be defined')
+ }
+ connectorStatus.status = OCPP16ChargePointStatus.Finishing
+
+ await stopRunningTransactions(station)
+
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(call => call.arguments[1] === 'StopTransaction').length,
+ 1
+ )
+ assert.strictEqual(
+ requestHandler.mock.calls.filter(call => call.arguments[1] === 'StatusNotification').length,
+ 0
+ )
+ })
+
await it('should call requestStopTransaction in parallel for OCPP 2.0 connectors', async () => {
const sentPayloads: { command: string; transactionId?: string }[] = []
const { requestHandler, station } = createStationWithRequestHandler({
ocppVersion: OCPPVersion.VERSION_20,
})
requestHandler.mock.mockImplementation(async (...args: unknown[]) => {
- const payload = args[2] as Record<string, unknown>
+ const payload = args[2] as OCPP20TransactionEventRequest
sentPayloads.push({
command: args[1] as string,
- transactionId: payload.transactionId as string | undefined,
+ transactionId: payload.transactionInfo.transactionId,
})
return Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
})
ocppVersion: OCPPVersion.VERSION_20,
})
requestHandler.mock.mockImplementation(async (...args: unknown[]) => {
- const payload = args[2] as Record<string, unknown>
+ const payload = args[2] as OCPP20TransactionEventRequest
sentPayloads.push({
command: args[1] as string,
- transactionId: payload.transactionId as string | undefined,
+ transactionId: payload.transactionInfo.transactionId,
})
return Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
})
evseConfiguration: { evsesCount: 1 },
ocppVersion: OCPPVersion.VERSION_20,
})
+ station.started = true
+ station.isStopping = () => false
requestHandler.mock.mockImplementation(async (..._args: unknown[]) => Promise.resolve({}))
const connectorStatus = station.getConnectorStatus(1)
assert.notStrictEqual(connectorStatus, undefined)
await flushQueuedTransactionMessages(station)
assert.strictEqual(connectorStatus.transactionEventQueue.length, 0)
+ setupConnectorWithTransaction(station, 1, {
+ transactionId: '550e8400-e29b-41d4-a716-446655440000',
+ })
+ connectorStatus.transactionRestored = true
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt = new Date(0)
+ const resumedAfter = Date.now()
+
+ await flushQueuedTransactionMessages(station)
+
+ assert.strictEqual(connectorStatus.transactionRestored, undefined)
+ assert.ok(
+ connectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt.getTime() >= resumedAfter
+ )
+ assert.ok(connectorStatus.transactionUpdatedMeterValuesSetInterval != null)
+ assert.ok(connectorStatus.transactionEndedMeterValuesSetInterval != null)
+ OCPP20ServiceUtils.pauseTransactionMeterValues(station)
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+
+ await flushQueuedTransactionMessages(station)
+
+ const resumedConnectorStatus = station.getConnectorStatus(1)
+ assert.ok(resumedConnectorStatus != null)
+ assert.strictEqual(resumedConnectorStatus.transactionRestored, undefined)
+ assert.ok(resumedConnectorStatus.transactionUpdatedMeterValuesSetInterval != null)
+ assert.ok(resumedConnectorStatus.transactionEndedMeterValuesSetInterval != null)
+ OCPP20ServiceUtils.stopUpdatedMeterValues(station, 1)
+ OCPP20ServiceUtils.stopEndedMeterValues(station, 1)
+ })
+
+ await it('should not resume restored transaction timers while station is stopped or stopping', async () => {
+ for (const lifecycleState of ['stopped', 'stopping'] as const) {
+ const { station } = createStationWithRequestHandler({
+ evseConfiguration: { evsesCount: 1 },
+ ocppVersion: OCPPVersion.VERSION_20,
+ })
+ station.started = lifecycleState === 'stopping'
+ station.isStopping = () => lifecycleState === 'stopping'
+ setupConnectorWithTransaction(station, 1, {
+ transactionId: `tx-${lifecycleState}`,
+ })
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.transactionRestored = true
+
+ await flushQueuedTransactionMessages(station)
+
+ assert.strictEqual(connectorStatus.transactionRestored, true)
+ assert.strictEqual(connectorStatus.transactionUpdatedMeterValuesSetInterval, undefined)
+ assert.strictEqual(connectorStatus.transactionEndedMeterValuesSetInterval, undefined)
+ }
})
})
* - buildMeterValue — throws when transactionId not found
* - getSampledValueTemplate — EVSE-level templates take priority over connector-level
* - getSampledValueTemplate — merges connector templates when no EVSE-level templates
+ * - clock-aligned register phase suppression — effective OCPP 2.0 output identity
*/
import assert from 'node:assert/strict'
import type { ChargingStation } from '../../../src/charging-station/index.js'
-import { addConfigurationKey } from '../../../src/charging-station/index.js'
-import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import { addConfigurationKey, buildConfigKey } from '../../../src/charging-station/index.js'
import {
+ buildClockAlignedConnectorMeterValue,
+ buildMeterValue,
+} from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ CurrentType,
MeterValueContext,
+ MeterValueLocation,
MeterValueMeasurand,
+ MeterValuePhase,
+ MeterValueUnit,
+ OCPP20ComponentName,
+ OCPP20OptionalVariableName,
OCPPVersion,
type SampledValueTemplate,
StandardParametersKey,
assert.ok(Array.isArray(meterValue.sampledValue))
})
+ await it('should project OCPP 1.6 DC inlet register energy', () => {
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.conversionEfficiency = 0.8
+ station.stationInfo.currentOutType = CurrentType.DC
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 800
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 800
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location: MeterValueLocation.INLET,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ ] as unknown as SampledValueTemplate[]
+
+ const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID, 0)
+
+ assert.strictEqual(meterValue.sampledValue[0]?.value, '1000')
+ })
+
await it('should throw when transactionId not found', () => {
assert.throws(
() => buildMeterValue(station, 999, 0),
'should have sampled values from connector templates'
)
})
+
+ await it('should treat physical DC energy as output-side when advancing the station main register', () => {
+ const advanceAtLocation = (location: MeterValueLocation): number => {
+ const { station: testStation } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_201 },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ assert.ok(testStation.stationInfo != null)
+ testStation.stationInfo.conversionEfficiency = 0.8
+ testStation.stationInfo.currentOutType = CurrentType.DC
+ const connectorStatus = testStation.getConnectorStatus(1)
+ assert.ok(connectorStatus != null)
+ connectorStatus.energyActiveImportRegisterValue = 0
+ connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+ connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING
+ connectorStatus.MeterValues = [
+ {
+ fluctuationPercent: 0,
+ location,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '1000',
+ },
+ ] as unknown as SampledValueTemplate[]
+
+ buildMeterValue(testStation, TEST_TRANSACTION_ID_STRING, 3_600_000)
+
+ return (
+ testStation.getEvseStatus(0)?.connectors.get(0)?.energyActiveImportRegisterValue ?? -1
+ )
+ }
+
+ assert.strictEqual(advanceAtLocation(MeterValueLocation.INLET), 1250)
+ assert.strictEqual(advanceAtLocation(MeterValueLocation.OUTLET), 1250)
+ })
+
+ await it('should suppress register phases by effective OCPP 2.0 output identity', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20OptionalVariableName.RegisterValuesWithoutPhases
+ ),
+ 'true',
+ undefined,
+ { overwrite: true }
+ )
+ const sensorA = { channel: { label: 'main', number: 1 }, vendorId: 'sensor-a' }
+ const reorderedSensorA = Object.assign(
+ {},
+ { vendorId: 'sensor-a' },
+ { channel: Object.assign({}, { number: 1 }, { label: 'main' }) }
+ )
+ const templates = [
+ {
+ context: MeterValueContext.SAMPLE_PERIODIC,
+ customData: reorderedSensorA,
+ format: 'Raw',
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L1_N,
+ },
+ {
+ context: MeterValueContext.TRANSACTION_BEGIN,
+ customData: sensorA,
+ format: 'SignedData',
+ location: MeterValueLocation.OUTLET,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L2_N,
+ unit: MeterValueUnit.WATT_HOUR,
+ },
+ {
+ context: MeterValueContext.TRANSACTION_END,
+ customData: sensorA,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L3_N,
+ },
+ {
+ customData: sensorA,
+ location: MeterValueLocation.INLET,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L1_N,
+ },
+ {
+ customData: sensorA,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L2_N,
+ unit: MeterValueUnit.KILO_WATT_HOUR,
+ },
+ {
+ customData: { vendorId: 'sensor-b' },
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: MeterValuePhase.L3_N,
+ },
+ ] as unknown as SampledValueTemplate[]
+
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ station,
+ {
+ connectorId: 1,
+ energyRegisterWhOverride: 6000,
+ evseId: 1,
+ sampledValueBaseline: [],
+ sampledValueTemplates: templates,
+ },
+ 60_000,
+ undefined,
+ MeterValueContext.SAMPLE_CLOCK
+ )
+
+ assert.strictEqual(meterValue.sampledValue.length, 4)
+ assert.ok(
+ meterValue.sampledValue.every(
+ sample =>
+ sample.context === MeterValueContext.SAMPLE_CLOCK &&
+ sample.phase == null &&
+ !('format' in sample)
+ )
+ )
+ assert.deepStrictEqual(
+ meterValue.sampledValue
+ .map(sample =>
+ [sample.customData?.vendorId, sample.location, sample.unitOfMeasure?.unit].join('|')
+ )
+ .sort(),
+ [
+ `sensor-a|${MeterValueLocation.INLET}|${MeterValueUnit.WATT_HOUR}`,
+ `sensor-a|${MeterValueLocation.OUTLET}|${MeterValueUnit.KILO_WATT_HOUR}`,
+ `sensor-a|${MeterValueLocation.OUTLET}|${MeterValueUnit.WATT_HOUR}`,
+ `sensor-b|${MeterValueLocation.OUTLET}|${MeterValueUnit.WATT_HOUR}`,
+ ].sort()
+ )
+ })
})
// Builder output tests — version-parameterized
/**
* @file Tests for OCPPSignedMeterValueUtils
- * @description Unit tests for PublicKeyWithSignedMeterValueEnumType and shouldIncludePublicKey helper
+ * @description Unit tests for signed meter-value configuration and public-key delivery ownership
*/
import assert from 'node:assert/strict'
import { describe, it } from 'node:test'
import {
+ claimPublicKeyDelivery,
deriveSigningMethodFromPublicKeyHex,
+ releasePublicKeyDelivery,
+ retainPublicKeyDelivery,
shouldIncludePublicKey,
validateSigningPrerequisites,
} from '../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js'
import {
+ type ConnectorStatus,
PublicKeyWithSignedMeterValueEnumType,
SigningMethodEnumType,
} from '../../../src/types/index.js'
})
})
+ await it('should ignore stale delivery owners and changed transactions', () => {
+ const connectorStatus = {
+ availability: 'Operative',
+ MeterValues: [],
+ publicKeySentInTransaction: false,
+ transactionId: 'transaction-a',
+ } as unknown as ConnectorStatus
+ const firstOwner = claimPublicKeyDelivery(
+ connectorStatus,
+ 'transaction-a',
+ { generation: 1 },
+ true
+ )
+ const secondOwner = claimPublicKeyDelivery(
+ connectorStatus,
+ 'transaction-a',
+ { generation: 2 },
+ true
+ )
+
+ assert.strictEqual(releasePublicKeyDelivery(firstOwner), false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ retainPublicKeyDelivery(secondOwner)
+ assert.strictEqual(releasePublicKeyDelivery(firstOwner), false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ const replacementOwner = claimPublicKeyDelivery(
+ connectorStatus,
+ 'transaction-a',
+ { generation: 3 },
+ true
+ )
+ connectorStatus.transactionId = 'transaction-b'
+ assert.strictEqual(releasePublicKeyDelivery(replacementOwner), false)
+ assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+ })
+
await describe('deriveSigningMethodFromPublicKeyHex', async () => {
await it('should derive secp256k1 from OCA spec §5.3 public key', () => {
assert.strictEqual(
import { afterEach, describe, it } from 'node:test'
import type { ChargingStation } from '../../src/charging-station/index.js'
-import type { ConnectorStatus, EvseStatus } from '../../src/types/index.js'
+import type {
+ ConnectorStatus,
+ EvseStatus,
+ OCPP20TransactionEventRequest,
+} from '../../src/types/index.js'
-import { AvailabilityType } from '../../src/types/index.js'
+import transactionEventRequestSchema from '../../src/assets/json-schemas/ocpp/2.0/TransactionEventRequest.json' with { type: 'json' }
+import {
+ prepareConnectorStatus,
+ preparePersistedTransactionEventQueue,
+} from '../../src/charging-station/HelpersConnectorStatus.js'
+import { createAjv } from '../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+ boundTransactionEventQueue,
+ getTransactionEventQueueBytes,
+} from '../../src/charging-station/TransactionEventQueueUtils.js'
+import {
+ AvailabilityType,
+ OCPP20ConnectorStatusEnumType,
+ OCPP20LocationEnumType,
+ OCPP20MeasurandEnumType,
+ OCPP20PhaseEnumType,
+ OCPP20ReadingContextEnumType,
+ OCPP20TransactionEventEnumType,
+ OCPP20TriggerReasonEnumType,
+ OCPP20UnitEnumType,
+} from '../../src/types/index.js'
import {
buildATGEntries,
buildChargingStationAutomaticTransactionGeneratorConfiguration,
evses: Map<number, EvseStatus>
}
+const validateTransactionEvent = createAjv().compile(transactionEventRequestSchema)
+
+const prepareTestConnectorStatus = (
+ connectorStatus: ConnectorStatus,
+ numberOfPhases = 3,
+ inletToOutputEfficiency = 1
+): ConnectorStatus => {
+ const persistedQueue: unknown = connectorStatus.transactionEventQueue
+ if (Array.isArray(persistedQueue)) {
+ for (const candidate of persistedQueue) {
+ if (candidate === null || typeof candidate !== 'object' || Array.isArray(candidate)) continue
+ const queuedEvent = candidate as { ownerConnectorId?: number; ownerEvseId?: number }
+ queuedEvent.ownerEvseId ??= 1
+ queuedEvent.ownerConnectorId ??= 1
+ }
+ }
+ return preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(connectorStatus, numberOfPhases, inletToOutputEfficiency),
+ request =>
+ validateTransactionEvent(
+ JSON.parse(JSON.stringify(request)) as OCPP20TransactionEventRequest
+ ),
+ 1,
+ 1,
+ numberOfPhases,
+ inletToOutputEfficiency
+ )
+}
+
await describe('ChargingStationConfigurationUtils', async () => {
let testStation: ChargingStation | undefined
MeterValues: [],
transactionEndedMeterValues: [{ sampledValue: [], timestamp: new Date() }],
transactionEndedMeterValuesSetInterval: interval2,
+ transactionEnding: true,
transactionEventQueue: [],
transactionUpdatedMeterValuesSetInterval: interval1,
} as unknown as ConnectorStatus)
assert.strictEqual(result.length, 2)
for (const [, connector] of result) {
assert.ok(!('transactionEndedMeterValues' in connector))
+ assert.ok(!('transactionEnding' in connector))
assert.ok(!('transactionEndedMeterValuesSetInterval' in connector))
assert.ok(!('transactionUpdatedMeterValuesSetInterval' in connector))
assert.ok(!('transactionEventQueue' in connector))
internals.connectors.set(1, {
availability: AvailabilityType.Operative,
bootStatus: 'Available',
+ energyActiveImportIntervalBaselines: { 'station:AlignedDataCtrlr.Measurands': 12 },
MeterValues: [],
transactionEventQueue: undefined,
transactionId: 42,
assert.strictEqual(result.length, 1)
assert.strictEqual(result[0][0], 1)
assert.strictEqual(result[0][1].availability, AvailabilityType.Operative)
+ assert.deepStrictEqual(result[0][1].energyActiveImportIntervalBaselines, {
+ 'station:AlignedDataCtrlr.Measurands': 12,
+ })
assert.strictEqual(result[0][1].transactionId, 42)
assert.strictEqual(result[0][1].transactionStarted, true)
})
+ await it('should persist a non-EVSE transaction queue and only durable interval state', () => {
+ const { station } = createMockChargingStation({ connectorsCount: 0 })
+ testStation = station
+ const internals = station as unknown as MockStationInternals
+ internals.connectors.clear()
+ const eventTimestamp = new Date('2026-08-31T12:00:00.000Z')
+ const queuedTimestamp = new Date('2026-08-31T12:00:01.000Z')
+ internals.connectors.set(1, {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ transactionEnergyActiveImportIntervalBaselines: {
+ 'SampledDataCtrlr.TxEndedMeasurands': 10,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 20,
+ },
+ transactionEnergyActiveImportIntervalCarry: {
+ 'SampledDataCtrlr.TxEndedMeasurands': 9,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 2,
+ },
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000001' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: queuedTimestamp,
+ },
+ ],
+ transactionId: '00000000-0000-4000-8000-000000000001',
+ })
+
+ const connectorStatus = buildConnectorsStatus(station)[0][1]
+
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalBaselines, {
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 20,
+ })
+ assert.deepStrictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, {
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 2,
+ })
+ assert.strictEqual(connectorStatus.transactionEventQueue?.length, 1)
+ const restoredConnectorStatus = prepareTestConnectorStatus(
+ JSON.parse(JSON.stringify(connectorStatus)) as ConnectorStatus
+ )
+ assert.ok(restoredConnectorStatus.transactionEventQueue?.[0].timestamp instanceof Date)
+ assert.ok(restoredConnectorStatus.transactionEventQueue[0].request.timestamp instanceof Date)
+ })
+
await it('should preserve non-sequential connector IDs', () => {
const { station } = createMockChargingStation({ connectorsCount: 0 })
testStation = station
const evseConnectors = new Map<number, ConnectorStatus>()
evseConnectors.set(1, {
availability: AvailabilityType.Operative,
+ energyActiveImportIntervalBaselines: { 'station:AlignedDataCtrlr.Measurands': 15 },
+ locked: true,
MeterValues: [],
+ postTransactionDelayTransactionId: 'ended-transaction',
+ status: OCPP20ConnectorStatusEnumType.Occupied,
+ transactionEnergyActiveImportIntervalBaselines: {
+ 'SampledDataCtrlr.TxEndedMeasurands': 10,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 20,
+ },
+ transactionEnergyActiveImportIntervalCarry: {
+ 'SampledDataCtrlr.TxEndedMeasurands': 9,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 2,
+ },
transactionEventQueue: [],
transactionUpdatedMeterValuesSetInterval: undefined,
})
internals.evses.set(1, {
availability: AvailabilityType.Operative,
connectors: evseConnectors,
+ energyActiveImportIntervalBaseline: 20,
+ energyActiveImportRegisterLastUpdatedAt: new Date(),
+ energyActiveImportRegisterValue: 25,
})
const result = buildEvsesStatus(station)
const evse1 = result[1][1]
assert.ok('connectorsStatus' in evse1)
assert.ok(!('connectors' in evse1))
+ assert.ok(!('energyActiveImportRegisterLastUpdatedAt' in evse1))
+ assert.strictEqual(evse1.energyActiveImportIntervalBaseline, 20)
+ assert.strictEqual(evse1.energyActiveImportRegisterValue, 25)
const connectorsStatus = evse1.connectorsStatus as [number, ConnectorStatus][]
assert.strictEqual(connectorsStatus.length, 1)
assert.strictEqual(connectorsStatus[0][0], 1)
+ assert.strictEqual(connectorsStatus[0][1].locked, false)
+ assert.strictEqual(connectorsStatus[0][1].status, OCPP20ConnectorStatusEnumType.Unavailable)
+ assert.deepStrictEqual(connectorsStatus[0][1].energyActiveImportIntervalBaselines, {
+ 'station:AlignedDataCtrlr.Measurands': 15,
+ })
+ assert.deepStrictEqual(
+ connectorsStatus[0][1].transactionEnergyActiveImportIntervalBaselines,
+ {
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 20,
+ }
+ )
+ assert.deepStrictEqual(connectorsStatus[0][1].transactionEnergyActiveImportIntervalCarry, {
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 2,
+ })
+ assert.strictEqual('postTransactionDelayTransactionId' in connectorsStatus[0][1], false)
+ const runtimeConnector = evseConnectors.get(1)
+ assert.ok(runtimeConnector)
+ runtimeConnector.transactionId = 'replacement-transaction'
+ runtimeConnector.transactionStarted = true
+ const replacementConnector = (
+ buildEvsesStatus(station)[1][1].connectorsStatus as [number, ConnectorStatus][]
+ )[0][1]
+ assert.strictEqual(replacementConnector.locked, true)
+ assert.strictEqual(replacementConnector.status, OCPP20ConnectorStatusEnumType.Occupied)
+ assert.strictEqual('postTransactionDelayTransactionId' in replacementConnector, false)
})
- await it('should strip internal fields from evse connectors', () => {
+ await it('should strip ephemeral fields and preserve queued transaction events', () => {
const { station } = createMockChargingStation({
connectorsCount: 1,
evseConfiguration: { evsesCount: 1 },
const internals = station as unknown as MockStationInternals
internals.evses.clear()
+ const eventTimestamp = new Date('2026-08-31T12:00:00.000Z')
+ const queuedTimestamp = new Date('2026-08-31T12:00:01.000Z')
const evseConnectors = new Map<number, ConnectorStatus>()
evseConnectors.set(1, {
availability: AvailabilityType.Operative,
MeterValues: [],
transactionEndedMeterValues: [{ sampledValue: [], timestamp: new Date() }],
transactionEndedMeterValuesSetInterval: undefined,
- transactionEventQueue: [],
+ transactionEnding: true,
+ transactionEnergyActiveImportRegisterLastUpdatedAt: eventTimestamp,
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000001' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: queuedTimestamp,
+ },
+ ],
+ transactionId: '00000000-0000-4000-8000-000000000001',
+ transactionStart: eventTimestamp,
+ transactionStartedExhaustedTransactionId: '00000000-0000-4000-8000-000000000001',
+ transactionStarting: true,
transactionUpdatedMeterValuesSetInterval: undefined,
})
assert.strictEqual(connectorsStatus[0][0], 1)
const connectorStatus = connectorsStatus[0][1]
assert.ok(!('transactionEndedMeterValues' in connectorStatus))
+ assert.ok(!('transactionEnding' in connectorStatus))
+ assert.ok(!('transactionStarting' in connectorStatus))
assert.ok(!('transactionEndedMeterValuesSetInterval' in connectorStatus))
assert.ok(!('transactionUpdatedMeterValuesSetInterval' in connectorStatus))
- assert.ok(!('transactionEventQueue' in connectorStatus))
+ assert.strictEqual(
+ connectorStatus.transactionStartedExhaustedTransactionId,
+ '00000000-0000-4000-8000-000000000001'
+ )
+ assert.deepStrictEqual(connectorStatus.transactionEventQueue, [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000001' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: queuedTimestamp,
+ },
+ ])
+ const persistedConnectorStatus = JSON.parse(
+ JSON.stringify(connectorStatus)
+ ) as ConnectorStatus
+ const restoredConnectorStatus = prepareTestConnectorStatus(persistedConnectorStatus)
+ assert.ok(restoredConnectorStatus.transactionEventQueue?.[0].timestamp instanceof Date)
+ assert.ok(restoredConnectorStatus.transactionEventQueue[0].request.timestamp instanceof Date)
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue[0].deliveryAttempted, false)
+ assert.ok(
+ restoredConnectorStatus.transactionEnergyActiveImportRegisterLastUpdatedAt instanceof Date
+ )
+ assert.ok(restoredConnectorStatus.transactionStart instanceof Date)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionStartedExhaustedTransactionId,
+ '00000000-0000-4000-8000-000000000001'
+ )
+ })
+
+ await it('should preserve OCPP 1.6 public-key ownership without a queue validator', () => {
+ const connectorStatus: ConnectorStatus = {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [],
+ transactionId: 42,
+ transactionStarted: true,
+ }
+
+ const restoredConnectorStatus = preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(connectorStatus),
+ undefined,
+ 1,
+ 1
+ )
+
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue, undefined)
+ assert.strictEqual(restoredConnectorStatus.publicKeySentInTransaction, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ })
+
+ await it('should discard malformed persisted transaction queue entries', () => {
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z')
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ null,
+ {},
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: null,
+ signedMeterData: 'signed-data',
+ signingMethod: 'ECDSA-secp256r1-SHA256',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: eventTimestamp.toISOString(),
+ },
+ ],
+ seqNo: 4,
+ timestamp: eventTimestamp.toISOString(),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000004' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 4,
+ timestamp: eventTimestamp.toISOString(),
+ },
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: { malformed: true },
+ seqNo: 5,
+ timestamp: eventTimestamp.toISOString(),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000004' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 5,
+ timestamp: eventTimestamp.toISOString(),
+ },
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 6,
+ timestamp: 'bogus',
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000006' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 6,
+ timestamp: eventTimestamp.toISOString(),
+ },
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 0,
+ timestamp: eventTimestamp.toISOString(),
+ transactionInfo: { transactionId: '00000000-0000-4000-8000-000000000007' },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 7,
+ timestamp: eventTimestamp.toISOString(),
+ },
+ ],
+ transactionId: '00000000-0000-4000-8000-000000000004',
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.publicKeySentInTransaction, true)
+ })
+
+ await it('should discard persisted Ended payloads with invalid schema fields', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000044'
+ const timestamp = '2026-09-01T12:00:00.000Z'
+ const validSample = {
+ context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+ phase: OCPP20PhaseEnumType.L1_N,
+ unitOfMeasure: { unit: OCPP20UnitEnumType.WATT_HOUR },
+ value: 1,
+ }
+ const validRequest = (): OCPP20TransactionEventRequest => ({
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ meterValue: [
+ {
+ sampledValue: [validSample],
+ timestamp: new Date(timestamp),
+ },
+ ],
+ seqNo: 1,
+ timestamp: new Date(timestamp),
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ })
+ const requestWithSample = (sample: object): object => ({
+ ...validRequest(),
+ meterValue: [
+ {
+ sampledValue: [{ ...validSample, ...sample }],
+ timestamp: new Date(timestamp),
+ },
+ ],
+ })
+ const invalidRequests = [
+ {
+ name: 'stopped reason',
+ request: {
+ ...validRequest(),
+ transactionInfo: { stoppedReason: 'Invalid', transactionId },
+ },
+ },
+ {
+ name: 'charging state',
+ request: {
+ ...validRequest(),
+ transactionInfo: { chargingState: 'Invalid', transactionId },
+ },
+ },
+ { name: 'sample context', request: requestWithSample({ context: 'Invalid' }) },
+ { name: 'sample measurand', request: requestWithSample({ measurand: 'Invalid' }) },
+ { name: 'sample phase', request: requestWithSample({ phase: 'Invalid' }) },
+ {
+ name: 'sample unit',
+ request: requestWithSample({ unitOfMeasure: { unit: 42 } }),
+ },
+ ]
+
+ for (const { name, request } of invalidRequests) {
+ const restoredConnectorStatus = prepareTestConnectorStatus({
+ transactionEventQueue: [
+ {
+ request,
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [], name)
+ }
+ })
+
+ await it('should reopen key ownership only when an invalid active carrier is removed', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000043'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ { signedMeterValue: { publicKey: 'active-public-key' }, value: 1 },
+ ],
+ timestamp,
+ },
+ ],
+ seqNo: 1,
+ timestamp: 'invalid',
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should reject unsafe queue sequence numbers and rebuild the active counter', () => {
+ const activeTransactionId = '00000000-0000-4000-8000-000000000040'
+ const historicalTransactionId = '00000000-0000-4000-8000-000000000041'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const event = (transactionId: string, seqNo: number, requestSeqNo = seqNo) => ({
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: requestSeqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo,
+ timestamp,
+ })
+ const connectorStatus = {
+ transactionEventQueue: [
+ event(activeTransactionId, -1),
+ event(activeTransactionId, Number.MAX_SAFE_INTEGER + 1),
+ event(activeTransactionId, 7, Number.MAX_SAFE_INTEGER + 1),
+ event(activeTransactionId, 6),
+ event(historicalTransactionId, 99),
+ ],
+ transactionId: activeTransactionId,
+ transactionSeqNo: Number.POSITIVE_INFINITY,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(
+ restoredConnectorStatus.transactionEventQueue?.map(queuedEvent => [
+ queuedEvent.request.transactionInfo.transactionId,
+ queuedEvent.seqNo,
+ ]),
+ [
+ [activeTransactionId, 6],
+ [historicalTransactionId, 99],
+ ]
+ )
+ assert.strictEqual(restoredConnectorStatus.transactionSeqNo, 6)
+
+ for (const invalidSeqNo of [-1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) {
+ const withoutActiveHistory = prepareTestConnectorStatus({
+ transactionEventQueue: [event(historicalTransactionId, 3)],
+ transactionId: activeTransactionId,
+ transactionSeqNo: invalidSeqNo,
+ } as unknown as ConnectorStatus)
+ assert.strictEqual(withoutActiveHistory.transactionSeqNo, undefined)
+ }
+ })
+
+ await it('should reject an active non-terminal MAX sequence while retaining terminal and historical MAX events', () => {
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const activeTransactionId = '00000000-0000-4000-8000-000000000140'
+ const event = (
+ transactionId: string,
+ seqNo: number,
+ eventType: OCPP20TransactionEventEnumType
+ ) => ({
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: {
+ eventType,
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo,
+ timestamp,
+ })
+
+ assert.throws(
+ () =>
+ prepareTestConnectorStatus({
+ transactionEventQueue: [
+ event(
+ activeTransactionId,
+ Number.MAX_SAFE_INTEGER,
+ OCPP20TransactionEventEnumType.Updated
+ ),
+ ],
+ transactionId: activeTransactionId,
+ transactionSeqNo: Number.MAX_SAFE_INTEGER,
+ transactionStarted: true,
+ } as unknown as ConnectorStatus),
+ /cannot allocate a safe sequence number/
+ )
+
+ const terminal = prepareTestConnectorStatus({
+ transactionEventQueue: [
+ event(activeTransactionId, Number.MAX_SAFE_INTEGER, OCPP20TransactionEventEnumType.Ended),
+ ],
+ transactionId: activeTransactionId,
+ transactionSeqNo: Number.MAX_SAFE_INTEGER,
+ transactionStarted: true,
+ } as unknown as ConnectorStatus)
+ assert.strictEqual(terminal.transactionEventQueue?.[0].seqNo, Number.MAX_SAFE_INTEGER)
+ assert.strictEqual(terminal.transactionEnding, true)
+
+ const historical = prepareTestConnectorStatus({
+ transactionEventQueue: [
+ event(
+ '00000000-0000-4000-8000-000000000141',
+ Number.MAX_SAFE_INTEGER,
+ OCPP20TransactionEventEnumType.Ended
+ ),
+ ],
+ } as unknown as ConnectorStatus)
+ assert.strictEqual(historical.transactionEventQueue?.[0].seqNo, Number.MAX_SAFE_INTEGER)
+ assert.strictEqual(historical.transactionSeqNo, undefined)
+ })
+
+ await it('should validate canonical queue ownership and migrate only attested legacy cohorts', () => {
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const transactionId = '00000000-0000-4000-8000-000000000150'
+ const request = (
+ eventType: OCPP20TransactionEventEnumType,
+ seqNo: number,
+ evse?: { connectorId?: number; id: number }
+ ) => ({
+ eventType,
+ ...(evse != null && { evse }),
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ })
+ const hydrateLegacy = (connectorStatus: ConnectorStatus, evseId = 1): ConnectorStatus =>
+ preparePersistedTransactionEventQueue(
+ prepareConnectorStatus(connectorStatus),
+ candidate =>
+ validateTransactionEvent(
+ JSON.parse(JSON.stringify(candidate)) as OCPP20TransactionEventRequest
+ ),
+ evseId,
+ 1
+ )
+
+ const crossedOwner = hydrateLegacy({
+ transactionEventQueue: [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 2,
+ request: request(OCPP20TransactionEventEnumType.Started, 0, { id: 1 }),
+ seqNo: 0,
+ timestamp,
+ },
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: request(OCPP20TransactionEventEnumType.Updated, 1),
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(crossedOwner.transactionEventQueue, [])
+ assert.strictEqual(crossedOwner.transactionSeqNo, undefined)
+
+ const sameConnectorDifferentEvse = hydrateLegacy({
+ transactionEventQueue: [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 2,
+ request: request(OCPP20TransactionEventEnumType.Updated, 1),
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(sameConnectorDifferentEvse.transactionEventQueue, [])
+
+ const contradictoryPayload = hydrateLegacy({
+ transactionEventQueue: [
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: request(OCPP20TransactionEventEnumType.Started, 0, { id: 2 }),
+ seqNo: 0,
+ timestamp,
+ },
+ {
+ ownerConnectorId: 1,
+ ownerEvseId: 1,
+ request: request(OCPP20TransactionEventEnumType.Updated, 1),
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(contradictoryPayload.transactionEventQueue, [])
+
+ const migrated = hydrateLegacy({
+ transactionEventQueue: [
+ {
+ request: request(OCPP20TransactionEventEnumType.Started, 0, { id: 1 }),
+ seqNo: 0,
+ timestamp,
+ },
+ {
+ request: request(OCPP20TransactionEventEnumType.Updated, 1),
+ seqNo: 1,
+ timestamp,
+ },
+ ],
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(
+ migrated.transactionEventQueue?.map(({ ownerConnectorId, ownerEvseId }) => ({
+ ownerConnectorId,
+ ownerEvseId,
+ })),
+ [
+ { ownerConnectorId: 1, ownerEvseId: 1 },
+ { ownerConnectorId: 1, ownerEvseId: 1 },
+ ]
+ )
+
+ const unattested = hydrateLegacy({
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ {
+ request: request(OCPP20TransactionEventEnumType.Updated, 7),
+ seqNo: 7,
+ timestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus)
+ assert.deepStrictEqual(unattested.transactionEventQueue, [])
+ assert.strictEqual(unattested.transactionSeqNo, undefined)
+ assert.strictEqual(unattested.publicKeySentInTransaction, false)
+ })
+
+ await it('should keep the first valid persisted event for each structured identity', () => {
+ const activeTransactionId = '00000000-0000-4000-8000-000000000030'
+ const otherTransactionId = '00000000-0000-4000-8000-000000000031'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const event = (
+ transactionId: string,
+ seqNo: number,
+ marker: string,
+ consumption: number
+ ) => ({
+ request: {
+ customData: { marker, vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo,
+ timestamp,
+ transactionEnergyActiveImportIntervalConsumption: { periodic: consumption },
+ })
+ const connectorStatus = {
+ transactionEventQueue: [
+ event(activeTransactionId, 4, 'first', 10),
+ event(otherTransactionId, 4, 'other-transaction', 20),
+ event(activeTransactionId, 4, 'duplicate', 999),
+ event(activeTransactionId, 5, 'next-sequence', 30),
+ ],
+ transactionId: activeTransactionId,
+ transactionSeqNo: 1,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+ const queue = restoredConnectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+
+ assert.deepStrictEqual(
+ queue.map(queuedEvent => queuedEvent.request.customData?.marker),
+ ['first', 'other-transaction', 'next-sequence']
+ )
+ assert.deepStrictEqual(queue[0].transactionEnergyActiveImportIntervalConsumption, {
+ periodic: 10,
+ })
+ assert.strictEqual(queue[0].timestamp instanceof Date, true)
+ assert.strictEqual(queue[0].request.timestamp instanceof Date, true)
+ assert.strictEqual(restoredConnectorStatus.transactionSeqNo, 5)
+ assert.strictEqual(
+ boundTransactionEventQueue(restoredConnectorStatus).bytes,
+ getTransactionEventQueueBytes(queue)
+ )
+ })
+
+ await it('should retain the attempted duplicate for an identical persisted event', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000042'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const event = (marker: string, deliveryAttempted: boolean) => ({
+ deliveryAttempted,
+ request: {
+ customData: { marker, vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 4,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 4,
+ timestamp,
+ })
+ const connectorStatus = {
+ transactionEventQueue: [event('unattempted', false), event('attempted', true)],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionEventQueue[0].request.customData?.marker,
+ 'attempted'
+ )
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue[0].deliveryAttempted, true)
+ })
+
+ await it('should allow a valid persisted identity after a malformed occurrence', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000032'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const connectorStatus = {
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 6,
+ timestamp: 'invalid',
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 6,
+ timestamp,
+ },
+ {
+ request: {
+ customData: { marker: 'valid', vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 6,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 6,
+ timestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionEventQueue[0].request.customData?.marker,
+ 'valid'
+ )
+ assert.strictEqual(restoredConnectorStatus.transactionSeqNo, 6)
+ })
+
+ await it('should preserve a duplicate public key only on a compatible retained target', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000033'
+ const timestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const signedMeterValue = (publicKey: string) => [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: 'ECDSA-secp256r1-SHA256',
+ },
+ value: 1,
+ },
+ ],
+ timestamp,
+ },
+ ]
+ const queuedEvent = (
+ eventType: OCPP20TransactionEventEnumType,
+ meterValue?: ReturnType<typeof signedMeterValue>
+ ) => ({
+ request: {
+ eventType,
+ ...(meterValue != null && { meterValue }),
+ seqNo: 1,
+ timestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp,
+ })
+ const transferable = prepareTestConnectorStatus({
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ queuedEvent(OCPP20TransactionEventEnumType.Updated, signedMeterValue('')),
+ queuedEvent(OCPP20TransactionEventEnumType.Updated, signedMeterValue('public-key')),
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus)
+
+ assert.strictEqual(transferable.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ transferable.transactionEventQueue[0].request.meterValue?.[0].sampledValue[0]
+ .signedMeterValue?.publicKey,
+ 'public-key'
+ )
+ assert.strictEqual(transferable.publicKeySentInTransaction, true)
+
+ const withoutTarget = prepareTestConnectorStatus({
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ queuedEvent(OCPP20TransactionEventEnumType.Started),
+ queuedEvent(OCPP20TransactionEventEnumType.Ended, signedMeterValue('public-key')),
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus)
+
+ assert.strictEqual(withoutTarget.transactionEventQueue?.length, 1)
+ assert.strictEqual(
+ withoutTarget.transactionEventQueue[0].request.eventType,
+ OCPP20TransactionEventEnumType.Started
+ )
+ assert.strictEqual(withoutTarget.publicKeySentInTransaction, false)
+ })
+
+ await it('should advance the active transaction sequence past restored queued events', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000004'
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const connectorStatus = {
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 4,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 4,
+ timestamp: eventTimestamp,
+ },
+ ],
+ transactionId,
+ transactionSeqNo: 3,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionSeqNo, 4)
+ })
+
+ await it('should bound an oversized persisted transaction queue', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000004'
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const connectorStatus = {
+ transactionEventQueue: Array.from(
+ { length: Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH + 1 },
+ (_, seqNo) => ({
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo,
+ timestamp: eventTimestamp,
+ })
+ ),
+ transactionId,
+ transactionSeqNo: 3,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ const queue = restoredConnectorStatus.transactionEventQueue
+ assert.ok(queue != null)
+ assert.ok(queue.length <= Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH)
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(queue), 'utf8') <=
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+ assert.strictEqual(
+ restoredConnectorStatus.transactionSeqNo,
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH
+ )
+ assert.strictEqual(queue[0].seqNo, 0)
+ assert.strictEqual(queue.at(-1)?.seqNo, Constants.MAX_TRANSACTION_EVENT_QUEUE_LENGTH)
+ })
+ await it('should hydrate byte-oversized lifecycle cores without evicting any cohort', () => {
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const queuedEvent = (
+ eventType: OCPP20TransactionEventEnumType,
+ transactionId: string,
+ seqNo: number,
+ payloadBytes: number
+ ) => ({
+ request: {
+ customData: { payload: 'x'.repeat(payloadBytes), vendorId: 'test' },
+ eventType,
+ seqNo,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason:
+ eventType === OCPP20TransactionEventEnumType.Ended
+ ? OCPP20TriggerReasonEnumType.StopAuthorized
+ : OCPP20TriggerReasonEnumType.Authorized,
+ },
+ seqNo,
+ timestamp: eventTimestamp,
+ })
+ const oversizedStartedTransactionId = '00000000-0000-4000-8000-000000000020'
+ const oldestCompletedTransactionId = '00000000-0000-4000-8000-000000000021'
+ const newestCompletedTransactionId = '00000000-0000-4000-8000-000000000022'
+ const connectorStatus = {
+ transactionEventQueue: [
+ queuedEvent(
+ OCPP20TransactionEventEnumType.Started,
+ oversizedStartedTransactionId,
+ 0,
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ ),
+ queuedEvent(
+ OCPP20TransactionEventEnumType.Ended,
+ oldestCompletedTransactionId,
+ 1,
+ 642_000
+ ),
+ queuedEvent(
+ OCPP20TransactionEventEnumType.Ended,
+ newestCompletedTransactionId,
+ 2,
+ 642_000
+ ),
+ ],
+ } as unknown as ConnectorStatus
+ assert.ok(
+ Buffer.byteLength(JSON.stringify(connectorStatus.transactionEventQueue?.[0]), 'utf8') >
+ Constants.MAX_TRANSACTION_EVENT_QUEUE_BYTES
+ )
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+ assert.strictEqual(restoredConnectorStatus, connectorStatus)
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue?.map(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId
+ ),
+ [oversizedStartedTransactionId, oldestCompletedTransactionId, newestCompletedTransactionId]
+ )
+ assert.deepStrictEqual(
+ connectorStatus.transactionEventQueue.map(({ request }) => [
+ request.eventType,
+ request.seqNo,
+ request.triggerReason,
+ ]),
+ [
+ [OCPP20TransactionEventEnumType.Started, 0, OCPP20TriggerReasonEnumType.Authorized],
+ [OCPP20TransactionEventEnumType.Ended, 1, OCPP20TriggerReasonEnumType.StopAuthorized],
+ [OCPP20TransactionEventEnumType.Ended, 2, OCPP20TriggerReasonEnumType.StopAuthorized],
+ ]
+ )
+ })
+
+ await it('should preserve a historical signing key when mandatory update endpoints exceed the cap', () => {
+ const activeTransactionId = '00000000-0000-4000-8000-000000000008'
+ const historicalTransactionId = '00000000-0000-4000-8000-000000000007'
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const signedMeterValue = (publicKey: string) => [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey,
+ signedMeterData: 'signed-data',
+ signingMethod: 'ECDSA-secp256r1-SHA256',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: eventTimestamp,
+ },
+ ]
+ const connectorStatus = {
+ transactionEventQueue: Array.from({ length: 4 }, (_, seqNo) => ({
+ request: {
+ customData: { payload: 'x'.repeat(300_000), vendorId: 'test' },
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ ...(seqNo < 2 && {
+ meterValue: signedMeterValue(seqNo === 0 ? 'historical-public-key' : ''),
+ }),
+ seqNo,
+ timestamp: eventTimestamp,
+ transactionInfo: {
+ transactionId: seqNo < 2 ? historicalTransactionId : activeTransactionId,
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo,
+ timestamp: eventTimestamp,
+ })),
+ transactionId: activeTransactionId,
+ transactionStarted: true,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+ const historicalReplacement = restoredConnectorStatus.transactionEventQueue?.find(
+ queuedEvent => queuedEvent.request.transactionInfo.transactionId === historicalTransactionId
+ )
+
+ assert.strictEqual(
+ historicalReplacement?.request.meterValue?.[0].sampledValue[0].signedMeterValue?.publicKey,
+ 'historical-public-key'
+ )
+ })
+
+ await it('should release a reserved signing key when its persisted event is discarded', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000007'
+ const eventTimestamp = new Date('2026-09-01T12:00:00.000Z').toISOString()
+ const connectorStatus = {
+ publicKeySentInTransaction: true,
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ signedMeterValue: {
+ encodingMethod: 'OCMF',
+ publicKey: 'public-key',
+ signedMeterData: 'signed-data',
+ signingMethod: 'ECDSA-secp256r1-SHA256',
+ },
+ value: 1,
+ },
+ ],
+ timestamp: 'bogus',
+ },
+ ],
+ seqNo: 7,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 7,
+ timestamp: eventTimestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.publicKeySentInTransaction, false)
+ })
+
+ await it('should migrate persisted station interval baselines to physical state', () => {
+ const connectorStatus = {
+ energyActiveImportIntervalBaselines: {
+ invalid: -1,
+ 'station:AlignedDataCtrlr.Measurands': 30,
+ },
+ transactionEnergyActiveImportIntervalBaselines: {
+ 'AlignedDataCtrlr.Measurands': 20,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 10,
+ 'station:AlignedDataCtrlr.Measurands': 40,
+ 'station:invalid': Number.NaN,
+ },
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(restoredConnectorStatus.energyActiveImportIntervalBaselines, {
+ 'station:AlignedDataCtrlr.Measurands': 30,
+ })
+ assert.deepStrictEqual(
+ restoredConnectorStatus.transactionEnergyActiveImportIntervalBaselines,
+ {
+ 'AlignedDataCtrlr.Measurands': 20,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 10,
+ }
+ )
+ assert.strictEqual(
+ prepareTestConnectorStatus(restoredConnectorStatus),
+ restoredConnectorStatus
+ )
+ assert.deepStrictEqual(restoredConnectorStatus.energyActiveImportIntervalBaselines, {
+ 'station:AlignedDataCtrlr.Measurands': 30,
+ })
+ })
+
+ await it('should not downgrade an active transaction restored with an exhausted Started marker', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000033'
+ const eventTimestamp = '2026-09-01T12:00:00.000Z'
+ const connectorStatus = {
+ availability: AvailabilityType.Operative,
+ locked: true,
+ MeterValues: [],
+ status: OCPP20ConnectorStatusEnumType.Occupied,
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ },
+ ],
+ transactionId,
+ transactionStart: eventTimestamp,
+ transactionStarted: true,
+ transactionStartedExhaustedTransactionId: transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, true)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ assert.strictEqual(restoredConnectorStatus.status, OCPP20ConnectorStatusEnumType.Occupied)
+ assert.strictEqual(restoredConnectorStatus.locked, true)
+ assert.ok(restoredConnectorStatus.transactionStart instanceof Date)
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue?.length, 1)
+ })
+
+ await it('should clear exhausted Started ownership when its only queued event is malformed', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000034'
+ const connectorStatus = {
+ availability: AvailabilityType.Inoperative,
+ idTagAuthorized: true,
+ locked: true,
+ MeterValues: [],
+ status: OCPP20ConnectorStatusEnumType.Occupied,
+ transactionEventQueue: [
+ {
+ request: { transactionInfo: { transactionId } },
+ seqNo: 1,
+ timestamp: '2026-09-01T12:00:00.000Z',
+ },
+ ],
+ transactionId,
+ transactionIdTag: 'orphan-id-tag',
+ transactionStart: '2026-09-01T12:00:00.000Z',
+ transactionStarted: false,
+ transactionStartedExhaustedTransactionId: transactionId,
+ transactionStarting: true,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.deepStrictEqual(restoredConnectorStatus.transactionEventQueue, [])
+ assert.strictEqual(restoredConnectorStatus.transactionId, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionIdTag, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionStart, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, false)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ assert.strictEqual(restoredConnectorStatus.availability, AvailabilityType.Inoperative)
+ })
+
+ await it('should restore Ended ownership when it remains after exhausted Started', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000035'
+ const eventTimestamp = '2026-09-01T12:00:00.000Z'
+ const connectorStatus = {
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ },
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ },
+ ],
+ transactionId,
+ transactionStart: eventTimestamp,
+ transactionStarted: false,
+ transactionStartedExhaustedTransactionId: transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionId, transactionId)
+ assert.strictEqual(restoredConnectorStatus.transactionStarted, false)
+ assert.strictEqual(restoredConnectorStatus.transactionStarting, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionEnding, true)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, true)
+ assert.strictEqual(
+ restoredConnectorStatus.transactionStartedExhaustedTransactionId,
+ transactionId
+ )
+ assert.deepStrictEqual(
+ restoredConnectorStatus.transactionEventQueue?.map(event => event.request.eventType),
+ [OCPP20TransactionEventEnumType.Ended]
+ )
+ })
+
+ await it('should not restore Ended ownership from malformed or unrelated queued events', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000036'
+ const eventTimestamp = '2026-09-01T12:00:00.000Z'
+ const candidates = [
+ {
+ request: { transactionInfo: { transactionId } },
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ },
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Ended,
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ transactionInfo: {
+ transactionId: '00000000-0000-4000-8000-000000000037',
+ },
+ triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+ },
+ seqNo: 2,
+ timestamp: eventTimestamp,
+ },
+ ]
+
+ for (const candidate of candidates) {
+ const restoredConnectorStatus = prepareTestConnectorStatus({
+ availability: AvailabilityType.Operative,
+ MeterValues: [],
+ transactionEventQueue: [candidate],
+ transactionId,
+ transactionStart: eventTimestamp,
+ transactionStarted: false,
+ } as unknown as ConnectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionEnding, undefined)
+ assert.strictEqual(restoredConnectorStatus.transactionRestored, false)
+ }
+ })
+
+ await it('should discard invalid or stale exhausted Started ownership markers', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000031'
+ const malformed = {
+ transactionId,
+ transactionStartedExhaustedTransactionId: 31,
+ } as unknown as ConnectorStatus
+ const stale = {
+ transactionId,
+ transactionStartedExhaustedTransactionId: '00000000-0000-4000-8000-000000000032',
+ } as unknown as ConnectorStatus
+ const oversized = {
+ transactionId: 'x'.repeat(37),
+ transactionStartedExhaustedTransactionId: 'x'.repeat(37),
+ } as unknown as ConnectorStatus
+
+ assert.strictEqual(
+ prepareTestConnectorStatus(malformed).transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ assert.strictEqual(
+ prepareTestConnectorStatus(stale).transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ assert.strictEqual(
+ prepareTestConnectorStatus(oversized).transactionStartedExhaustedTransactionId,
+ undefined
+ )
+ })
+
+ await it('should reconstruct mixed-cadence legacy Updated interval energy from its payload', () => {
+ const transactionId = '00000000-0000-4000-8000-000000000030'
+ const eventTimestamp = '2026-09-01T12:00:00.000Z'
+ const connectorStatus = {
+ transactionEventQueue: [
+ {
+ request: {
+ eventType: OCPP20TransactionEventEnumType.Updated,
+ meterValue: [
+ {
+ sampledValue: [
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_CLOCK,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase: 'L1-N',
+ unitOfMeasure: { unit: OCPP20UnitEnumType.KILO_WATT_HOUR },
+ value: 2,
+ },
+ {
+ context: OCPP20ReadingContextEnumType.SAMPLE_PERIODIC,
+ location: OCPP20LocationEnumType.Inlet,
+ measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ phase: 'L1-N',
+ unitOfMeasure: { unit: OCPP20UnitEnumType.KILO_WATT_HOUR },
+ value: 2,
+ },
+ ],
+ timestamp: eventTimestamp,
+ },
+ ],
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ transactionInfo: { transactionId },
+ triggerReason: OCPP20TriggerReasonEnumType.MeterValueClock,
+ },
+ seqNo: 1,
+ timestamp: eventTimestamp,
+ },
+ ],
+ transactionId,
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus, 3, 0.8)
+
+ assert.deepStrictEqual(
+ restoredConnectorStatus.transactionEventQueue?.[0]
+ .transactionEnergyActiveImportIntervalConsumption,
+ {
+ 'AlignedDataCtrlr.Measurands': 4800,
+ 'SampledDataCtrlr.TxUpdatedMeasurands': 4800,
+ }
+ )
+ })
+
+ await it('should discard a non-array persisted transaction queue', () => {
+ const connectorStatus = {
+ transactionEventQueue: { unexpected: true },
+ } as unknown as ConnectorStatus
+
+ const restoredConnectorStatus = prepareTestConnectorStatus(connectorStatus)
+
+ assert.strictEqual(restoredConnectorStatus.transactionEventQueue, undefined)
})
await it('should preserve connector IDs across serialization', () => {