]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): preserve metering owners and replay certainty (#2119)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 17 Sep 2026 19:05:57 +0000 (21:05 +0200)
committerGitHub <noreply@github.com>
Thu, 17 Sep 2026 19:05:57 +0000 (21:05 +0200)
- 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

src/charging-station/ChargingStation.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
tests/charging-station/ChargingStation-Lifecycle.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts

index a057a283972459bdef4157ed5198133c56b0c9cc..92b5fb66389f5d7d649e40850927225c927ddfee 100644 (file)
@@ -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<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 ?? []),
         })
index eecc6c4fc89dd6064a105fd11e37af241cad39e5..e2bdb63d9619a9bda93e06f00af37a8186cee669 100644 (file)
@@ -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<OCPP20TransactionEventResponse> {
     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) {
index 6508713bbf99c58886bdba46c91b6d81af815507..b7e3977cc21ea71449fbace0aca6a925b2448680 100644 (file)
@@ -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 (
index 904a3987366d6ee8fd9de37fa1a14516a4ee5197..8ce43b449b565e01034254af1764bb3f5fccdf82 100644 (file)
@@ -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,
index 2c2c61bdd095e7d9d8c9d3a4002c204a13116a51..bab684d2954030b7c09e8cf85bc9c3c8c6604ab1 100644 (file)
@@ -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<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()
@@ -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 () => {
index c9d32dbdefbff8fad79b901ca3ba95f943560429..05c258ce91c8587fe13a89680d2e6150d6d71ae8 100644 (file)
@@ -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,