From: Jérôme Benoit Date: Thu, 17 Sep 2026 19:05:57 +0000 (+0200) Subject: fix(ocpp): preserve metering owners and replay certainty (#2119) X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=5ce0ccb6f43c7c46ea541df4cf8569746649fe5f;p=e-mobility-charging-stations-simulator.git fix(ocpp): preserve metering owners and replay certainty (#2119) - settle retained eager Updated waiters before a pre-send break - seed queued replay ambiguity from persisted delivery history - keep live EVSE owner and baselines across same-template reload - account interval energy from final projected samples, truncating serialized interval rounding while retaining remainder carry --- diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index a057a283..92b5fb66 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -3136,23 +3136,23 @@ export class ChargingStation extends EventEmitter { } 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( - 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 ?? []), }) diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index eecc6c4f..e2bdb63d 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -4949,7 +4949,9 @@ export class OCPP20ServiceUtils { responseTimeoutMs, skipBufferingOnError: true, }, - lifecycleAbortSignal + lifecycleAbortSignal, + undefined, + !deliveryAttemptedByThisInvocation && queuedEvent.deliveryAttempted === true ) } finally { if ( @@ -5023,17 +5025,13 @@ export class OCPP20ServiceUtils { 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) { @@ -5694,6 +5692,7 @@ export class OCPP20ServiceUtils { * @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 ( @@ -5701,7 +5700,8 @@ export class OCPP20ServiceUtils { request: OCPP20TransactionEventRequest, requestParams: RequestParams = {}, lifecycleAbortSignal?: AbortSignal, - deliveryIsCurrent?: () => boolean + deliveryIsCurrent?: () => boolean, + initiallyAmbiguousSentAttempt?: boolean ): Promise { const maximumAttempts = OCPP20ServiceUtils.readBoundedVariableAsInteger( chargingStation, @@ -5730,7 +5730,7 @@ export class OCPP20ServiceUtils { 'Default' ) ) - let hadAmbiguousSentAttempt = false + let hadAmbiguousSentAttempt = initiallyAmbiguousSentAttempt === true let hadConfirmedRejectedAttempt = false for (let attempt = 1; attempt <= maximumAttempts; attempt++) { if (isAbortSignalAborted(lifecycleAbortSignal) || deliveryIsCurrent?.() === false) { diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index 6508713b..b7e3977c 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -1857,10 +1857,12 @@ const expandClockAlignedSnapshotSamples = ( 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( @@ -2476,6 +2478,7 @@ const buildIdentifiedMeterValue = ( energyValueTimeScale, true ) + let emittedIntervalEnergyWh: number | undefined if (energyMeasurand != null) { const transactionEnergyBeforeBuild = connectorStatus?.transactionEnergyActiveImportRegisterValue ?? 0 @@ -2613,24 +2616,8 @@ const buildIdentifiedMeterValue = ( ) } } - 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, @@ -2681,6 +2668,26 @@ const buildIdentifiedMeterValue = ( 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 ( diff --git a/tests/charging-station/ChargingStation-Lifecycle.test.ts b/tests/charging-station/ChargingStation-Lifecycle.test.ts index 904a3987..8ce43b44 100644 --- a/tests/charging-station/ChargingStation-Lifecycle.test.ts +++ b/tests/charging-station/ChargingStation-Lifecycle.test.ts @@ -3811,6 +3811,55 @@ await describe('ChargingStation Lifecycle', async () => { 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, diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 2c2c61bd..bab684d2 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -546,6 +546,73 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { 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 => { + 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() @@ -3242,6 +3309,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { 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( @@ -3265,8 +3333,13 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { 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 @@ -3368,6 +3441,23 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => { ) 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 () => { diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts index c9d32dbd..05c258ce 100644 --- a/tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts +++ b/tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts @@ -31,6 +31,7 @@ import { MeterValueUnit, OCPP20ComponentName, OCPP20OptionalVariableName, + OCPP20RequiredVariableName, OCPPVersion, type SampledValueTemplate, StandardParametersKey, @@ -245,6 +246,50 @@ await describe('buildMeterValue', async () => { 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,