}
const templateMeterValues = templateEvse.MeterValues
const liveEvseStatus = this.evses.get(evseId)
+ if (!restorePersistedTransactions && liveEvseStatus != null) {
+ // Preserve live baselines and pending delivery ownership across hot reloads.
+ liveEvseStatus.MeterValues = clone(templateMeterValues ?? [])
+ continue
+ }
this.evses.set(evseId, {
...(evseStatus as EvseStatus),
connectors: new Map<number, ConnectorStatus>(
- connEntries.map(([connectorId, connectorStatus]) => {
- const liveConnectorStatus = liveEvseStatus?.connectors.get(connectorId)
- return [
- connectorId,
- !restorePersistedTransactions && liveConnectorStatus != null
- ? liveConnectorStatus
- : prepareConnectorStatus(
- connectorStatus,
- normalizationPhaseCount,
- inletToOutputEfficiency,
- restorePersistedTransactions
- ),
- ]
- })
+ connEntries.map(([connectorId, connectorStatus]) => [
+ connectorId,
+ prepareConnectorStatus(
+ connectorStatus,
+ normalizationPhaseCount,
+ inletToOutputEfficiency,
+ restorePersistedTransactions
+ ),
+ ])
),
MeterValues: clone(templateMeterValues ?? []),
})
responseTimeoutMs,
skipBufferingOnError: true,
},
- lifecycleAbortSignal
+ lifecycleAbortSignal,
+ undefined,
+ !deliveryAttemptedByThisInvocation && queuedEvent.deliveryAttempted === true
)
} finally {
if (
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 === 'pre-send-failed' ||
(error.outcome === 'exhausted' && !error.confirmedRejected && !error.definitelyUnsent)
: true
if (preserveForReplay) {
* @param requestParams - Transport behavior overrides
* @param lifecycleAbortSignal - Lifecycle generation governing this delivery
* @param deliveryIsCurrent - Whether the delivery still owns its transaction context
+ * @param initiallyAmbiguousSentAttempt - Whether a previous replay generation already attempted this request ambiguously
* @returns The TransactionEvent response
*/
private static async sendBuiltTransactionEvent (
request: OCPP20TransactionEventRequest,
requestParams: RequestParams = {},
lifecycleAbortSignal?: AbortSignal,
- deliveryIsCurrent?: () => boolean
+ deliveryIsCurrent?: () => boolean,
+ initiallyAmbiguousSentAttempt?: boolean
): Promise<OCPP20TransactionEventResponse> {
const maximumAttempts = OCPP20ServiceUtils.readBoundedVariableAsInteger(
chargingStation,
'Default'
)
)
- let hadAmbiguousSentAttempt = false
+ let hadAmbiguousSentAttempt = initiallyAmbiguousSentAttempt === true
let hadConfirmedRejectedAttempt = false
for (let attempt = 1; attempt <= maximumAttempts; attempt++) {
if (isAbortSignalAborted(lifecycleAbortSignal) || deliveryIsCurrent?.() === false) {
rawValue,
preferBaseline
)
- const value = roundTo(
- physicalValue / resolveMeterValueUnitDivider(measurand, template.unit as string | undefined),
- 2
- )
+ const unitValue =
+ physicalValue / resolveMeterValueUnitDivider(measurand, template.unit as string | undefined)
+ const value =
+ measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ ? truncateTransactionIntervalValue(unitValue)
+ : roundTo(unitValue, 2)
expanded.push(buildVersionedSampledValue(template, value, context))
}
return applyClockAlignedVoltageControls(
energyValueTimeScale,
true
)
+ let emittedIntervalEnergyWh: number | undefined
if (energyMeasurand != null) {
const transactionEnergyBeforeBuild =
connectorStatus?.transactionEnergyActiveImportRegisterValue ?? 0
)
}
}
- 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
- )
- )
- }
+ if (commitState && identity.transactionId != null && !deferEnergyInterval) {
+ emittedIntervalEnergyWh = intervalEnergyValue
}
const connectorMaximumAvailablePower = chargingStation.getConnectorMaximumAvailablePower(
connectorId,
snapshotEnergyRegisterWhOverride
)
}
+ if (
+ connectorStatus != null &&
+ emittedIntervalEnergyWh != null &&
+ meterValue.sampledValue.some(
+ sample => sample.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL
+ )
+ ) {
+ recordTransactionIntervalEmission(
+ connectorStatus,
+ meterValue,
+ intervalBaselineKey,
+ emittedIntervalEnergyWh,
+ chargingStation.getNumberOfPhases(),
+ resolveInletToOutputEfficiency(
+ chargingStation.stationInfo?.currentOutType,
+ chargingStation.stationInfo?.conversionEfficiency,
+ evseId
+ )
+ )
+ }
// Transactional snapshots defer this flag until their request is delivered.
// Other transactional builds preserve the existing eager-commit behavior.
if (
assert.strictEqual(liveConnector.transactionRestored, undefined)
})
+ await it('should preserve the live EVSE baseline during same-template reload', () => {
+ const result = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ })
+ station = result.station
+ const liveConnector = station.getConnectorStatus(1)
+ const liveEvse = station.getEvseStatus(1)
+ assert.ok(liveConnector != null && liveEvse != null)
+ liveConnector.energyActiveImportRegisterValue = 120
+ liveEvse.energyActiveImportIntervalBaseline = 100
+ const initializeFromFile = (
+ ChargingStation.prototype as unknown as {
+ initializeConnectorsOrEvsesFromFile: (
+ configuration: unknown,
+ stationTemplate: unknown,
+ persistentConfiguration?: boolean,
+ restorePersistedTransactions?: boolean
+ ) => void
+ }
+ ).initializeConnectorsOrEvsesFromFile
+
+ initializeFromFile.call(
+ station,
+ {
+ evsesStatus: [
+ [
+ 1,
+ {
+ availability: 'Operative',
+ connectorsStatus: [
+ [1, { availability: 'Operative', energyActiveImportRegisterValue: 80 }],
+ ],
+ energyActiveImportIntervalBaseline: 80,
+ },
+ ],
+ ],
+ },
+ { Evses: { 1: {} } },
+ undefined,
+ false
+ )
+
+ assert.strictEqual(station.getEvseStatus(1), liveEvse)
+ assert.strictEqual(station.getEvseStatus(1)?.energyActiveImportIntervalBaseline, 100)
+ assert.strictEqual(station.getConnectorStatus(1), liveConnector)
+ assert.strictEqual(liveConnector.energyActiveImportRegisterValue, 120)
+ })
+
await it('should remove persisted EVSE meter templates absent from the current template', () => {
const result = createMockChargingStation({
connectorsCount: 1,
assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
})
+ await it(
+ 'should replay a retained eager Updated before completing Ended',
+ { timeout: 2000 },
+ async () => {
+ // Arrange
+ const transactionId = generateUUID()
+ const deliveredEvents: OCPP20TransactionEventEnumType[] = []
+ let failBeforeSend = true
+ const requestHandler = mock.fn((...args: unknown[]): Promise<EmptyObject> => {
+ if (args[1] !== OCPP20RequestCommand.TRANSACTION_EVENT) return Promise.resolve({})
+ if (failBeforeSend) {
+ return Promise.reject(
+ new OCPPError(ErrorType.GENERIC_ERROR, 'Local pre-send failure')
+ )
+ }
+ const request = args[2] as OCPP20TransactionEventRequest
+ const params = args[3] as RequestParams
+ deliveredEvents.push(request.eventType)
+ params.onMessageSent?.()
+ params.onResponseReceived?.()
+ return Promise.resolve({})
+ })
+ const { station } = createMockChargingStation({
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ ocppRequestService: { requestHandler },
+ stationInfo: { ocppStrictCompliance: true, ocppVersion: OCPPVersion.VERSION_201 },
+ })
+ station.isWebSocketConnectionOpened = () => true
+ station.inAcceptedState = () => true
+ station.started = true
+ setupConnectorWithTransaction(station, 1, { transactionId })
+
+ // Act: retaining the event must release its immediate delivery waiter.
+ await OCPP20ServiceUtils.sendTransactionEvent(
+ station,
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TriggerReasonEnumType.MeterValueClock,
+ 1,
+ transactionId,
+ {
+ meterValue: [
+ {
+ sampledValue: [
+ { measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER, value: 10 },
+ ],
+ timestamp: new Date(1000),
+ },
+ ],
+ },
+ undefined,
+ undefined,
+ undefined,
+ true
+ )
+ failBeforeSend = false
+ await OCPP20ServiceUtils.requestStopTransaction(station, 1)
+
+ // Assert: the retained predecessor and terminal event can both progress.
+ assert.deepStrictEqual(deliveredEvents, [
+ OCPP20TransactionEventEnumType.Updated,
+ OCPP20TransactionEventEnumType.Ended,
+ ])
+ assert.strictEqual(station.getConnectorStatus(1)?.transactionEventQueue?.length ?? 0, 0)
+ }
+ )
+
await it('should preserve rejected signed interval energy without mutating its evidence', async () => {
const connectorId = 1
const transactionId = generateUUID()
const transactionId = generateUUID()
let online = false
let attempt = 0
+ let replayOutcome: 'accepted' | 'local' | 'rejected' = 'local'
const attemptedSequenceNumbers: number[] = []
let successorPayload: OCPP20TransactionEventRequest | undefined
const ambiguousFailure = new OCPPError(
throw ambiguousFailure
})
}
- if (payload.seqNo === 0) {
- requestParams.onTransportError?.(localFailure, false)
+ if (payload.seqNo === 0 && replayOutcome !== 'accepted') {
+ if (replayOutcome === 'rejected') {
+ requestParams.onMessageSent?.()
+ requestParams.onError?.(localFailure, true)
+ } else {
+ requestParams.onTransportError?.(localFailure, false)
+ }
return Promise.reject(localFailure)
}
successorPayload = payload
)
assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
assert.strictEqual(connectorStatus.publicKeySentInTransaction, true)
+
+ const retainedRequest = JSON.stringify(connectorStatus.transactionEventQueue[0].request)
+ for (const outcome of ['local', 'rejected'] as const) {
+ replayOutcome = outcome
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ assert.strictEqual(successorPayload, undefined)
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 2)
+ assert.strictEqual(
+ JSON.stringify(connectorStatus.transactionEventQueue[0].request),
+ retainedRequest
+ )
+ assert.strictEqual(connectorStatus.transactionEnergyActiveImportIntervalCarry, undefined)
+ }
+ replayOutcome = 'accepted'
+ await OCPP20ServiceUtils.sendQueuedTransactionEvents(station, connectorId)
+ assert.deepStrictEqual(attemptedSequenceNumbers, [0, 0, 0, 0, 0, 0, 0, 1])
+ assert.strictEqual(connectorStatus.transactionEventQueue.length, 0)
})
await it('should scale TransactionEvent retry delays by preceding transmissions', async () => {
MeterValueUnit,
OCPP20ComponentName,
OCPP20OptionalVariableName,
+ OCPP20RequiredVariableName,
OCPPVersion,
type SampledValueTemplate,
StandardParametersKey,
assert.strictEqual(advanceAtLocation(MeterValueLocation.OUTLET), 1250)
})
+ await it('should not re-emit DC inlet interval energy without new consumption', () => {
+ assert.ok(station.stationInfo != null)
+ station.stationInfo.currentOutType = CurrentType.DC
+ station.stationInfo.conversionEfficiency = 0.8
+ const connector = station.getConnectorStatus(1)
+ assert.ok(connector != null)
+ connector.energyActiveImportRegisterValue = 100
+ connector.transactionEnergyActiveImportRegisterValue = 100
+ connector.MeterValues = [
+ {
+ location: MeterValueLocation.INLET,
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ unit: MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ] as unknown as SampledValueTemplate[]
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.Measurands),
+ MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
+ undefined,
+ { overwrite: true }
+ )
+ const values = [1000, 2000].map(timestamp => {
+ const meterValue = buildClockAlignedConnectorMeterValue(
+ station,
+ {
+ connectorId: 1,
+ evseId: 1,
+ timestamp: new Date(timestamp),
+ transactionId: TEST_TRANSACTION_ID_STRING,
+ },
+ 0,
+ buildConfigKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20RequiredVariableName.Measurands
+ ),
+ MeterValueContext.SAMPLE_CLOCK
+ )
+ return meterValue.sampledValue.map(sample => sample.value)
+ })
+ assert.deepStrictEqual(values, [[125], [0]])
+ })
+
await it('should suppress register phases by effective OCPP 2.0 output identity', () => {
addConfigurationKey(
station,