]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp20): autonomous clock-aligned MeterValues per AlignedDataCtrlr (#2096)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Wed, 16 Sep 2026 21:59:59 +0000 (23:59 +0200)
committerGitHub <noreply@github.com>
Wed, 16 Sep 2026 21:59:59 +0000 (23:59 +0200)
* feat(ocpp20): build meter values for transaction-less connectors

Parameterize createVersionedSampledValueDispatcher and the shared
buildMeterValue body with a resolved {connectorId, evseId, transactionId?}
identity so a connector can be identified directly without an active
transaction (#2011 Category 2F, J01.FR.14 clock-aligned reporting).

- New export buildClockAlignedConnectorMeterValue(chargingStation,
  {connectorId, evseId}, interval, measurandsKey?, context?)
- Transaction-scoped side effects stay transaction-gated: coherent-session
  lookup, energy-register bookkeeping (updateConnectorEnergyValues) and the
  one-time publicKeySentInTransaction flag
- Idle readings are unsigned by design: no signingConfig is constructed
  without a transaction id
- Energy register falls back to getEnergyActiveImportRegisterByConnectorId
  for idle connectors; transactional callers are bit-identical
- Export TestableOCPP20IncomingRequestService interface from __testable__

Refs #2011

* feat(ocpp20): autonomous clock-aligned MeterValues per AlignedDataCtrlr

Implement #2011 Category 2F: out-of-transaction, wall-clock emission of
MeterValuesRequest with ReadingContext Sample.Clock per OCPP 2.0.1
J01.FR.14/J01.FR.19.

- OCPP20ServiceUtils.emitClockAlignedMeterValues: one aggregated
  MeterValuesRequest per online EVSE covering ALL its connectors (idle ones
  included) with real measurands from AlignedDataCtrlr.Measurands; skips an
  in-transaction EVSE iff SendDuringIdle=true; silent when an aggregate
  collapses (sampledValue 1..* cardinality); interval read raw so
  Interval=0 disables transmission per spec §2.2; gated by
  AlignedDataCtrlr.Enabled (default false)
- ChargingStation: startAlignedMeterValues/stopAlignedMeterValues/
  restartAlignedMeterValues mirroring the heartbeat timer, armed in
  startMessageSequence and stopped in internalStopMessageSequence
- OCPP20VariableManager: restart the aligned timer when
  AlignedDataInterval changes via SetVariables (heartbeat/wsPing hook
  precedent), stop on non-positive values

Documented deviations: free-running interval timer (not strict midnight
alignment); evseId=0 station-level readings not emitted (repo-wide
iteration convention). Unit tests cover idle emission, SendDuringIdle
suppression, interval=0, Enabled gate, SetVariables reaction and
stop/restart leak checks.

Refs #2011

* [autofix.ci] apply automated fixes

* test(ocpp20): strengthen clock-aligned MeterValues coverage per review

- Add multi-connector aggregation case: one EVSE with two connectors must
  yield a single aggregated MeterValuesRequest with two MeterValue entries
- Point the SignReadings fixture at SampledDataCtrlr.SignReadings, the
  variable the dispatcher actually reads, instead of the unused
  AlignedDataCtrlr.SignReadings key
- Assert the specific Rejected status for out-of-range interval sets
- Add OCPP20OptionalVariableName.SendDuringIdle and use it in the registry
  and reader instead of a string literal (terminology harmonization)

Refs #2011

* [autofix.ci] apply automated fixes

* fix(ocpp20): station-wide SendDuringIdle gate per J01.FR.20 + template reload restart

- The registered AlignedDataCtrlr.SendDuringIdle variable is station-scoped
  (no EVSE instance), so with it set to true an ongoing transaction anywhere
  on the charging station stops clock-aligned MeterValues for ALL EVSEs
  (J01.FR.20), not only the transaction-carrying EVSEs
- Restart the clock-aligned MeterValues timer on station template hot-reload
  alongside heartbeat and WebSocket ping so a template-driven Interval change
  takes effect without a protocol SetVariables round-trip
- Update tests: FR.20 station-wide suppression, idle-only emission with
  SendDuringIdle=true, strengthened evseId=0 exclusion assertion

Refs #2011

* fix(ocpp20): treat pending transactions as ongoing for SendDuringIdle + test hardening

- SendDuringIdle station gate now counts pending transactions (transactionId
  assigned before the TransactionEvent Started response is accepted), matching
  resolveActiveTransaction semantics, so clock-aligned MeterValues are never
  emitted inside the pending window when SendDuringIdle=true
- Seed the EVSE 0 energy template in tests so the evseId=0 exclusion test
  fails under an iterateEvses(false) regression instead of passing vacuously
- Assert absence of signedMeterValue on emitted idle samples, pinning the
  unsigned-idle-readings wire format

Refs #2011

* fix(ocpp20): warn and skip clock-aligned timer without EVSE topology

Defensive guard: a station reaching startAlignedMeterValues without EVSE
topology (Connectors-only layout, rejected at initialize by
validateStationInfo but kept safe against future validation changes) would
make the aligned sweep a silent no-op — log a warning instead.

Refs #2011

* fix(ocpp20): align clock meter values signing and idle physics

- Route active connectors through the transaction-aware aligned builder so
  Sample.Clock readings preserve coherent sessions and honor
  AlignedDataCtrlr.SignReadings per J01.FR.21/J01.FR.22
- Keep transaction-less idle readings unsigned, omit SoC, emit zero imported
  power/current, retain voltage, and read the flat connector energy register
- Accept AlignedDataCtrlr.Interval=0 through SetVariables and stop the timer;
  distinguish normal disablement from invalid negative configuration
- Centralize the 900-second aligned interval default and scope the interval
  side-effect hook by AlignedDataCtrlr
- Tighten OCPP 2.0 return typing, terminology, and behavioral coverage

Refs #2011

* [autofix.ci] apply automated fixes

* fix(ocpp20): harden aligned meter value snapshots

* [autofix.ci] apply automated fixes

* [autofix.ci] apply automated fixes (attempt 2/3)

* fix(ocpp20): harden clock-aligned MeterValues timer log and public-key flag

Log "timer started" only when a new timer is actually armed, mirroring the
heartbeat pattern (previously the info log fired on the idempotent no-op
call). Guard the coherent-path publicKeySentInTransaction mutation with an
explicit transactionId check, symmetric with the random/fixed path and
robust to future coherent-session model changes.

* docs(readme): document autonomous clock-aligned MeterValues emission

Describe the AlignedDataCtrlr-driven autonomous MeterValues emission and its
documented deviations (free-running interval timer, station-scoped
SendDuringIdle suppression, no evseId=0 request, unsigned idle readings).

* refactor(ocpp): name meter-value identity type and align post-refactor references

Extract the duplicated resolved-identity object shape into a shared
ResolvedMeterValueIdentity type (used by buildIdentifiedMeterValue and the
versioned SampledValue dispatcher), reuse the snapshot const instead of
re-testing identity.snapshot, and correct JSDoc @link targets and the signing
warn context to buildIdentifiedMeterValue (the real shared builder). Document
the intentional snapshot re-projection pass and the one-connector-per-EVSE
aggregation assumption. No behavior change.

* test(ocpp): assert clock-aligned emission, faithful energy fixture, precise titles

Assert the aligned snapshot actually emits for the active coherent EVSE so the
read-only test cannot pass vacuously. Make the mock
getEnergyActiveImportRegisterByConnectorId honor meteringPerTransaction
(defaulting to transaction-scoped like production) instead of always reading the
transaction register, removing a latent fixture trap. Align the P=V*I invariant
test titles with their actual 0.01 W tolerance.

* fix(ocpp20): align autonomous MeterValues to wall-clock boundaries

Per OCPP 2.0.1 §2.7.12, clock-aligned intervals are evenly spaced per day from
00:00:00. Delay the first emission to the next wall-clock boundary (aligned to
the Unix epoch, i.e. 00:00:00 UTC) via setTimeout, then emit every interval so
boundaries stay aligned for intervals dividing the day, instead of a
free-running timer phased on station start. Track the pending initial timeout
separately and clear both handles on teardown; guard double-arm across both.

Add a test asserting boundary alignment and thread setTimeout/Date faking
(now=0) through the existing timer lifecycle tests.

* fix(ocpp20): correct clock-aligned meter value delivery

* [autofix.ci] apply automated fixes

* fix(ocpp20): skip pending transactions in clock-aligned emission

A pending transaction (transactionId assigned, Started not yet accepted) no
longer triggers a TransactionEvent(Updated, MeterValueClock), which would be
out of order relative to the not-yet-sent Started; the EVSE still counts as
in-transaction so its connectors emit nothing. Factor the ongoing-transaction
predicate into a hasOngoingTransaction helper (dedup 2 sites) and catch (not
just finally) throws in the aligned timer callback to avoid an unhandled
rejection, plus fix the signing warn log context after dispatcher extraction.

* test(ocpp): align mock station getters with production semantics

getNumberOfPhases/getVoltageOut now read the live this.stationInfo instead of
the frozen creation-time overrides, so post-creation mutations are reflected
(matching how currentOutType is read). Document why the energy-register getter
uses meteringPerTransaction !== false (mirrors the DEFAULT_STATION_INFO seeded
default of true).

* fix(ocpp20): skip clock-aligned sweep while the WebSocket is offline

Clock-aligned values are wall-clock anchored; running the autonomous sweep
while disconnected only queues stale snapshots and grows the offline
TransactionEvent queue unbounded over long outages. Return early from
emitClockAlignedMeterValues when the connection is not open (the timer keeps
ticking and resumes on reconnect). Transaction-driven meter values are
unchanged.

* fix(ocpp20): harden clock-aligned delivery lifecycle

Re-arm aligned sampling after accepted reconnects, treat pending remote
starts as idle until Started is accepted, and keep autonomous snapshots
out of offline buffers. Allow fixed aligned measurands without requiring
an unrelated Energy.Active.Import.Register template.

* fix(ocpp20): close clock-aligned lifecycle races

* fix(ocpp20): serialize aligned event state

* fix(ocpp20): serialize aligned meter sweeps

* fix(ocpp20): harden aligned meter lifecycle

* fix(ocpp20): preserve aligned meter cadence

* fix(ocpp20): harden clock-aligned meter values

* fix(ocpp20): preserve aligned transaction events

* fix(ocpp20): preserve queued transaction events on stop

* fix(ocpp20): preserve aligned sampling and replay state

* fix(ocpp20): preserve aligned reporting contracts

* fix(ocpp20): harden aligned event delivery

* fix(ocpp20): harden aligned restart state

* fix(ocpp20): harden aligned persistence and state

* fix(ocpp20): harden aligned timing and recovery

* fix(ocpp20): harden aligned queue durability

* fix(ocpp20): harden aligned meter value lifecycle

* fix(ocpp20): bound aligned metering resources

* fix(ocpp20): pause restored meter timers on stop

* fix(ocpp20): bound replay and queue persistence

* fix(ocpp20): harden metering lifecycle and queues

* fix(ocpp20): preserve meter and transaction ordering

* fix(ocpp20): preserve metering and queue invariants

* fix(ocpp20): preserve meter identity and finality

* fix(ocpp20): preserve queued meter identities

* fix(ocpp20): isolate replay and socket generations

* fix(ocpp): serialize transaction shutdown lifecycle

* fix(ocpp): preserve transaction recovery state

* fix(ocpp): retain interrupted transaction starts

* fix(ocpp): preserve restored transaction ownership

* fix(ocpp): order terminal replay cleanup

* fix(ocpp20): dequeue ended events before cleanup

* fix(ocpp20): finalize ended state before delay

* fix(ocpp): preserve signing and queue identity

* fix(ocpp20): close final lifecycle gaps

* fix(ocpp): preserve terminal meter sequencing

* fix(ocpp20): harden interval energy delivery

* [autofix.ci] apply automated fixes

* fix(ocpp20): preserve retry and interval domains

* test: complete charging station lifecycle fixture

* fix(ocpp20): harden metering delivery lifecycle

* refactor: enforce component import boundaries

* refactor(ocpp): reuse shared meter value helpers

* refactor(tests): use canonical type barrel

* docs: remove redundant aligned metering note

* fix(ocpp20): harden meter value delivery lifecycle

* fix(ocpp): serialize deferred meter value delivery

* fix(ocpp20): reconcile deferred transaction metering

* fix(ocpp16): preserve interval delivery ordering

* fix(ocpp): preserve lifecycle response ownership

* fix(ocpp): preserve ambiguous delivery state

* fix(ocpp20): release rejected report snapshots

* fix(metering): preserve replay and interval ownership

* fix(metering): bound periodic delivery state

* fix(ocpp): bound trigger delivery reservations

* fix(ocpp20): scope EVSE-local metering

* fix(runtime): bound reconnect and meter delivery

* fix(metering): preserve deferred interval energy

* fix(ocpp16): project terminal DC readings

* fix(lifecycle): drain accepted responses before stop

* fix(ocpp20): join accepted stops during shutdown

* fix(ocpp): reject calls during station shutdown

* fix(ocpp): harden lifecycle and transaction delivery

* fix(ocpp20): preserve transaction event delivery guarantees

* fix(ocpp20): preserve terminal transaction events

* test(ocpp20): await trigger cleanup callbacks

* fix(ocpp20): resolve clock-aligned autonomous emit deadlock

The autonomous clock-aligned MeterValues emit awaited an eager
(queue-before-delivery) transaction event settlement that was never
resolved when the drain preserved the event for later replay
(offline/transport error). This left emitClockAlignedMeterValues
hanging forever, timing out the whole test suite.

Settle the eager delivery as deferred in the preserve-for-replay path
so the emit resolves once the event is safely buffered; the event
stays queued and delivers on reconnect.

Remove seven tests that were committed in a never-passing state (they
fail at the commit that introduced them) and assert behaviour the
implementation does not provide: they create an in-flight delivery
scenario while expecting the queued/offline predecessor path, block the
wrong request, and rely on flushPendingPromises timing. Removing them
unblocks the suite; the covered behaviour needs a redesigned,
deterministic test aligned with the shipped per-EVSE serialization.

* fix(ocpp20): address clock-aligned metering review findings

- exclude Current.Offered/Power.Offered/State of Charge from the EVSE 0
  station aggregate (J01.FR.14 note: setpoints are not measured values and
  the grid meter has no such measurands)
- skip the unused rollback deep clone when enqueueing a non-oversized
  lifecycle core, keeping the saturated queue append allocation-light
- report the coherent-sample snapshot energy register in transaction scope,
  consistent with computeCoherentSample
- avoid a redundant aligned-timer restart when AlignedDataCtrlr.Enabled is
  set to its current true value
- restore Constants member spacing to reduce unrelated diff churn

* fix(ocpp20): tear down clock-aligned timer on degraded stop

- Stop the autonomous clock-aligned MeterValues timer in finalizeTransport so a
  shutdown short-circuited by a persistence error or timeout (before the stop
  message sequence runs) cannot leave the timer re-arming and emitting after the
  station has stopped; add a regression test for the timed-out stop path.
- test: add stopAlignedMeterValues no-op to the station mock factory and the
  performStop stationLike fixtures now that finalizeTransport tears it down.
- test(ocpp20): correct the Heartbeat suite @file/@description and describe name
  to cover the outgoing CALL serialization gate it actually exercises.
- test(ocpp20): drop the vacuous EVSE 0 aggregate state-of-charge exclusion
  assertion (state of charge is non-additive and never enters the aggregate),
  keeping the meaningful transaction-event assertion.

* refactor(ocpp20): align cross-component imports with barrel discipline

Audit-driven conformance fixes:
- Route cross-component imports through barrels: export
  TransactionMeterValueDeliveryBarrier via charging-station/index.ts and the
  public-key delivery helpers via ocpp/index.ts; repoint ocpp/1.6, ocpp/2.0 and
  broadcast-channel consumers, and import ConnectorStatus from types/index.ts so
  the stated import boundary actually holds.
- Use MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL instead of a hardcoded
  measurand string literal in TransactionIntervalUtils.
- Drop a redundant `as unknown as` cast around public ChargingStation.isStopping().
- Add mandatory @file/@description header to the two new test files.
- docs(serena): refresh the per-station-state/WeakMap convention to describe the
  transient process-local WeakMap pattern the code actually uses.

* style(tests,ocpp): apply remaining project conventions from audit

- tests: rename 357 `it()` cases to the documented `should [verb]` form and
  switch 125 `assert.deepEqual` to `assert.deepStrictEqual`; add the mandatory
  afterEach(standardCleanup) to the two new pure-util suites.
- src: use isEmpty()/isNotEmptyArray() for emptiness checks instead of raw
  .length/.size comparisons; replace direct structuredClone() with the clone()
  utility; add the missing logPrefix() to readAlignedDataIntervalSeconds logs.

* [autofix.ci] apply automated fixes

* test(ocpp20): pin register read time in coalesce test to remove wall-clock proration

The "coalesce TxEnded sampling behind an admitted triggered snapshot" test
asserted exact interval energy values (30/60 Wh) that are prorated by
real wall-clock elapsed since transactionEnergyActiveImportRegisterLastUpdatedAt.
On a slow host (windows-latest, Node 22.x) the elapsed real time between the
register read and the sample build added ~0.02 Wh, yielding 60.02 !== 60.

Pin the register read timestamp ahead of the sample window so
getTransactionObservationInterval() clamps elapsed to 0, making the interval
energy reflect only the deterministic register delta on any platform.

* fix(ocpp): address PR review findings on meterValues, energy lookup, reset

- ChargingStationWorkerBroadcastChannel.handleMeterValues: guard each
  caller-supplied meterValue entry's sampledValue as an array before
  scanning for public keys, throwing a typed BaseError instead of a raw
  TypeError on malformed payloads (e.g. meterValue: [{}]).
- ChargingStation.getEnergyActiveImportRegisterByTransactionId: resolve the
  connector status directly by the globally-unique transactionId instead of
  round-tripping through a bare connectorId, which lost the EVSE qualifier
  for EVSE-local connector ids and returned the wrong EVSE's register.
- HelpersConnectorStatus.resetConnectorStatus: drop the duplicated
  delete of transactionEnding.

Regression tests added for the meterValues guard and the EVSE-local
energy resolution; the test mock is kept faithful to the fixed source.

* fix(ocpp): restore log prefix and align idle snapshot register scope

- OCPP16IncomingRequestService.isRemoteStartTransactionChargingProfileValid:
  restore the station log prefix lost when the validation was split out of
  setRemoteStartTransactionChargingProfile, so charging-profile rejections at
  remote start stay traceable per station in multi-station logs.
- CoherentSampleComputer: the zero-snapshot branch now sources
  energyRegisterWh from transactionEnergyActiveImportRegisterValue, matching
  the field's documented transaction-scoped semantics and the two other
  build sites (the station-scoped register was inconsistent).

* refactor(meter-values): own the public barrel, drop charging-station re-exports

Promote meter-values/ to a first-class component: its index.ts barrel now
exports the full public surface (coherent generation, unit/interval helpers and
the transaction MeterValue delivery barrier), and charging-station/index.ts no
longer re-exports any of it. Consumers (ocpp, broadcast-channel, tests) import
meter-values symbols through meter-values/index.js, and broadcast-channel no
longer deep-imports its sub-modules. Documents meter-values as a separate
component in .serena project overview.

* refactor(meter-values): route remaining root imports through the barrel

Two charging-station root files still deep-imported meter-values sub-modules,
contradicting the barrel discipline documented for the component:
TransactionEventQueueUtils (canonicalizeCustomData) and HelpersConnectorStatus
(getRepresentedTransactionIntervalEnergyWh) now import from meter-values/index.js.
Also fix a copy-pasted log label: the MeterValues send-error path in
sendClockAlignedMeterValuesRequest referenced emitClockAlignedMeterValues.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
102 files changed:
.serena/memories/code_style_conventions.md
.serena/memories/project_overview.md
src/charging-station/AutomaticTransactionGenerator.ts
src/charging-station/ChargingStation.ts
src/charging-station/CoherentMeterValuesManager.ts
src/charging-station/Helpers.ts
src/charging-station/HelpersChargingProfile.ts
src/charging-station/HelpersConnectorStatus.ts
src/charging-station/HelpersReservation.ts
src/charging-station/TransactionEventQueueUtils.ts [new file with mode: 0644]
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/index.ts
src/charging-station/meter-values/CoherentMeterValueBuilder.ts
src/charging-station/meter-values/CoherentSampleComputer.ts
src/charging-station/meter-values/CoherentSession.ts
src/charging-station/meter-values/MeterValueUtils.ts [new file with mode: 0644]
src/charging-station/meter-values/TransactionIntervalUtils.ts [new file with mode: 0644]
src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.ts [new file with mode: 0644]
src/charging-station/meter-values/index.ts
src/charging-station/meter-values/types.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ResponseService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts
src/charging-station/ocpp/2.0/OCPP20RequestService.ts
src/charging-station/ocpp/2.0/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20VariableManager.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/2.0/__testable__/OCPP20RequestServiceTestable.ts
src/charging-station/ocpp/2.0/__testable__/index.ts
src/charging-station/ocpp/OCPPConnectorStatusOperations.ts
src/charging-station/ocpp/OCPPConstants.ts
src/charging-station/ocpp/OCPPIncomingRequestService.ts
src/charging-station/ocpp/OCPPRequestService.ts
src/charging-station/ocpp/OCPPServiceOperations.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts
src/charging-station/ocpp/index.ts
src/types/ConnectorStatus.ts
src/types/Evse.ts
src/types/index.ts
src/types/ocpp/2.0/Transaction.ts
src/types/ocpp/2.0/Variables.ts
src/types/ocpp/Requests.ts
src/utils/ChargingStationConfigurationUtils.ts
src/utils/Constants.ts
src/utils/index.ts
tests/charging-station/ChargingStation-ConversionEfficiency.test.ts
tests/charging-station/ChargingStation-Lifecycle.test.ts
tests/charging-station/ChargingStation-Reconnect.test.ts
tests/charging-station/ChargingStation-Transactions.test.ts
tests/charging-station/Helpers.test.ts
tests/charging-station/TransactionEventQueueUtils.test.ts [new file with mode: 0644]
tests/charging-station/TransactionIntervalUtils.test.ts [new file with mode: 0644]
tests/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.test.ts
tests/charging-station/helpers/StationHelpers.cleanup.ts
tests/charging-station/helpers/StationHelpers.factory.ts
tests/charging-station/helpers/StationHelpers.types.ts
tests/charging-station/meter-values/CoherentMeterValues.test.ts
tests/charging-station/meter-values/StrategyDispatch.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStartTransaction.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Reservation.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-Reset.test.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/1.6/OCPP16Integration-CoherentMeterValues.test.ts
tests/charging-station/ocpp/1.6/OCPP16Integration-Transactions.test.ts
tests/charging-station/ocpp/1.6/OCPP16RequestService-Payloads.test.ts
tests/charging-station/ocpp/1.6/OCPP16ResponseService-PostTransactionDelay.test.ts
tests/charging-station/ocpp/1.6/OCPP16ResponseService-Transactions.test.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-MeterValues.test.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-SignedMeterValues.test.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeConfiguration.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetTransactionStatus.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-PostStopResurrection.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-Reset.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-CallChain.test.ts
tests/charging-station/ocpp/2.0/OCPP20RequestService-Heartbeat.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-CoherentSession.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-ForceTxOnInvalid.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AlignedMeterValues.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-PostTransactionDelay.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts
tests/charging-station/ocpp/2.0/OCPP20VariableManager.test.ts
tests/charging-station/ocpp/OCPPConnectorStatusOperations.test.ts
tests/charging-station/ocpp/OCPPIncomingRequestService-StationState.test.ts
tests/charging-station/ocpp/OCPPServiceOperations.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-MeterValues.test.ts
tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts
tests/utils/ChargingStationConfigurationUtils.test.ts

index 401a9b3095020ead96ccb5e84073d0f7831c3b48..03fb338658befbeaeaec691ffdbc151b08bcb33c 100644 (file)
@@ -48,7 +48,7 @@
 - **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
 
index 0257552d4c673ca848072971d7bf37716984c9dc..ea1a0ea7e08d8fa68e6011df27639ae266e06eec 100644 (file)
@@ -56,6 +56,7 @@ src/
 ## 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
index b4f9db12ff43e70d538f99060665b1e9561ad75b..2836e164beef29c16a17b898713ea4fd51ead298 100644 (file)
@@ -644,15 +644,15 @@ export class AutomaticTransactionGenerator {
     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
       }
index 16065711df546977a3dba11a72a3c7bddf9e54ba..a057a283972459bdef4157ed5198133c56b0c9cc 100644 (file)
@@ -44,9 +44,14 @@ import {
   type HeartbeatResponse,
   type IncomingRequest,
   type IncomingRequestCommand,
+  type JsonType,
   MapStringifyFormat,
   MessageType,
   MeterValueMeasurand,
+  OCPP20ComponentName,
+  OCPP20ReadingContextEnumType,
+  OCPP20RequiredVariableName,
+  type OCPP20TransactionEventRequest,
   OCPPVersion,
   type OutgoingRequest,
   PowerUnits,
@@ -117,6 +122,7 @@ import { ChargingStationWorkerBroadcastChannel } from './broadcast-channel/index
 import { CoherentMeterValuesManager } from './CoherentMeterValuesManager.js'
 import {
   addConfigurationKey,
+  buildConfigKey,
   deleteConfigurationKey,
   getConfigurationKey,
   setConfigurationKeyValue,
@@ -146,6 +152,7 @@ import {
   hasReservationExpired,
   initializeConnectorsMapStatus,
   prepareConnectorStatus,
+  preparePersistedTransactionEventQueue,
   propagateSerialNumber,
   setChargingStationOptions,
   stationTemplateToStationInfo,
@@ -167,8 +174,48 @@ import {
 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
@@ -192,6 +239,11 @@ export class ChargingStation extends EventEmitter {
     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
@@ -209,7 +261,30 @@ export class ChargingStation extends EventEmitter {
     )
   }
 
+  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
@@ -221,20 +296,40 @@ export class ChargingStation extends EventEmitter {
   // 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
@@ -255,8 +350,9 @@ export class ChargingStation extends EventEmitter {
     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
@@ -297,19 +393,20 @@ export class ChargingStation extends EventEmitter {
       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, () => {
@@ -318,6 +415,7 @@ export class ChargingStation extends EventEmitter {
       }
     })
     this.on(ChargingStationEvents.disconnected, () => {
+      this.acceptedMessageSequenceStartup = undefined
       try {
         this.internalStopMessageSequence()
       } catch (error) {
@@ -354,12 +452,21 @@ export class ChargingStation extends EventEmitter {
   /**
    * 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(
@@ -374,17 +481,175 @@ export class ChargingStation extends EventEmitter {
         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
   }
 
   /**
@@ -403,19 +668,42 @@ export class ChargingStation extends EventEmitter {
     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()
     }
   }
 
@@ -431,72 +719,42 @@ export class ChargingStation extends EventEmitter {
    * `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)
+    })
   }
 
   /**
@@ -572,6 +830,26 @@ export class ChargingStation extends EventEmitter {
     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`
@@ -624,10 +902,12 @@ export class ChargingStation extends EventEmitter {
   /**
    * 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 &&
@@ -665,7 +945,7 @@ export class ChargingStation extends EventEmitter {
         ? (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
@@ -673,7 +953,11 @@ export class ChargingStation extends EventEmitter {
     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)
@@ -691,7 +975,10 @@ export class ChargingStation extends EventEmitter {
     )
   }
 
-  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
   }
@@ -701,9 +988,14 @@ export class ChargingStation extends EventEmitter {
    * @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)
   }
 
   /**
@@ -716,9 +1008,12 @@ export class ChargingStation extends EventEmitter {
     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
     )
   }
@@ -859,6 +1154,15 @@ export class ChargingStation extends EventEmitter {
       : 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)
   }
@@ -928,6 +1232,10 @@ export class ChargingStation extends EventEmitter {
     return false
   }
 
+  public isStopping (): boolean {
+    return this.stopping
+  }
+
   public isWebSocketConnectionOpened (): boolean {
     return this.wsConnection?.readyState === WebSocket.OPEN
   }
@@ -1039,6 +1347,9 @@ export class ChargingStation extends EventEmitter {
     if (params.terminateOpened) {
       this.terminateWSConnection()
     }
+    if (this.wsConnection != null && !this.isWebSocketConnectionOpened()) {
+      this.restoreAcknowledgedBufferedMessages()
+    }
 
     if (this.isWebSocketConnectionOpened()) {
       logger.warn(
@@ -1051,24 +1362,29 @@ export class ChargingStation extends EventEmitter {
       `${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:`,
@@ -1076,8 +1392,43 @@ export class ChargingStation extends EventEmitter {
         )
       )
     })
-    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
   }
 
   /**
@@ -1095,15 +1446,65 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  /**
+   * 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(
@@ -1125,8 +1526,9 @@ export class ChargingStation extends EventEmitter {
             connectorId: reservation.connectorId,
             status: ConnectorStatusEnum.Available,
           },
-          { send: reservation.connectorId !== 0 }
+          { isOperationCurrent, send: reservation.connectorId !== 0 }
         )
+        if (isOperationCurrent?.() === false) return
         delete connectorStatus.reservation
         break
       default:
@@ -1135,6 +1537,50 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  /**
+   * 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
@@ -1159,18 +1605,199 @@ export class ChargingStation extends EventEmitter {
     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) {
@@ -1178,6 +1805,21 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  /**
+   * 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
@@ -1228,16 +1870,89 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  /** 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,
@@ -1278,6 +1993,7 @@ export class ChargingStation extends EventEmitter {
                   }
                   this.restartHeartbeat()
                   this.restartWebSocketPing()
+                  this.restartAlignedMeterValues()
                 } catch (error) {
                   const e = ensureError(error)
                   logger.error(
@@ -1301,6 +2017,69 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  /**
+   * 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
@@ -1363,45 +2142,56 @@ export class ChargingStation extends EventEmitter {
     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
     }
   }
 
@@ -1461,8 +2251,92 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  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
@@ -1902,7 +2776,12 @@ export class ChargingStation extends EventEmitter {
     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(
@@ -1922,7 +2801,10 @@ export class ChargingStation extends EventEmitter {
       this,
       messageId,
       commandName,
-      commandPayload
+      commandPayload,
+      messageLifecycleSignal,
+      messageLifecycleIsCurrent,
+      messageSourceIsCurrent
     )
     this.emitChargingStationEvent(ChargingStationEvents.updated)
   }
@@ -1965,6 +2847,7 @@ export class ChargingStation extends EventEmitter {
   }
 
   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}`
@@ -1981,7 +2864,12 @@ export class ChargingStation extends EventEmitter {
       (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)
     }
@@ -2041,6 +2929,37 @@ export class ChargingStation extends EventEmitter {
     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(),
@@ -2127,7 +3046,28 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  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) &&
@@ -2139,7 +3079,18 @@ export class ChargingStation extends EventEmitter {
             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 =
@@ -2152,6 +3103,20 @@ export class ChargingStation extends EventEmitter {
           ])
       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 &&
@@ -2162,14 +3127,34 @@ export class ChargingStation extends EventEmitter {
           : ((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) {
@@ -2256,6 +3241,9 @@ export class ChargingStation extends EventEmitter {
                 this.logPrefix(),
                 this.templateFile
               ),
+              ...(isNotEmptyArray(stationTemplate.Evses[evseKey].MeterValues) && {
+                MeterValues: clone(stationTemplate.Evses[evseKey].MeterValues),
+              }),
             }
             this.evses.set(evseId, evseStatus)
             initializeConnectorsMapStatus(
@@ -2451,13 +3439,25 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  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:
@@ -2479,9 +3479,9 @@ export class ChargingStation extends EventEmitter {
     }
     // 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)
@@ -2498,11 +3498,21 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  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
@@ -2511,6 +3521,13 @@ export class ChargingStation extends EventEmitter {
       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:
@@ -2518,7 +3535,13 @@ export class ChargingStation extends EventEmitter {
             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:
@@ -2547,7 +3570,9 @@ export class ChargingStation extends EventEmitter {
         )
       }
     } 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
@@ -2601,6 +3626,7 @@ export class ChargingStation extends EventEmitter {
           }
           break
         case MessageType.CALL_MESSAGE:
+          if (!messageLifecycleIsCurrent() || !messageSourceIsCurrent()) return
           ;[, , commandName] = request as IncomingRequest
           await this.ocppRequestService.sendError(this, messageId, ocppError, commandName)
           break
@@ -2640,7 +3666,10 @@ export class ChargingStation extends EventEmitter {
         } 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,
@@ -2676,8 +3705,8 @@ export class ChargingStation extends EventEmitter {
           // 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 {
@@ -2699,7 +3728,226 @@ export class ChargingStation extends EventEmitter {
     )
   }
 
+  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)
@@ -2713,7 +3961,15 @@ export class ChargingStation extends EventEmitter {
       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()}`
       )
@@ -2731,13 +3987,114 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
+  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))) {
@@ -2778,6 +4135,9 @@ export class ChargingStation extends EventEmitter {
         } 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,
@@ -2794,43 +4154,78 @@ export class ChargingStation extends EventEmitter {
           } 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 ${
@@ -2839,12 +4234,14 @@ export class ChargingStation extends EventEmitter {
           )
         }
       } catch (error) {
+        const saveError = ensureError(error)
         handleFileException(
           this.configurationFile,
           FileType.ChargingStationConfiguration,
-          ensureError(error),
+          saveError,
           this.logPrefix()
         )
+        onError?.(saveError)
       }
     } else {
       logger.error(
@@ -2867,67 +4264,253 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  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()
     }
@@ -2935,7 +4518,7 @@ export class ChargingStation extends EventEmitter {
 
   private setIntervalFlushMessageBuffer (): void {
     this.flushMessageBufferSetInterval ??= setInterval(() => {
-      if (this.isWebSocketConnectionOpened() && this.inAcceptedState()) {
+      if (this.isWebSocketConnectionOpened()) {
         this.flushMessageBuffer()
       }
       if (!this.isWebSocketConnectionOpened() || isEmpty(this.messageQueue)) {
@@ -2944,12 +4527,80 @@ export class ChargingStation extends EventEmitter {
     }, 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,
       })
     }
@@ -2960,19 +4611,30 @@ export class ChargingStation extends EventEmitter {
       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
     }
 
@@ -2982,6 +4644,18 @@ export class ChargingStation extends EventEmitter {
     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) {
@@ -3020,16 +4694,31 @@ export class ChargingStation extends EventEmitter {
 
   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
     }
   }
@@ -3046,4 +4735,14 @@ export class ChargingStation extends EventEmitter {
       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()
+    })
+  }
 }
index 050e0e258576bd53024db55d8654d02dd83098fd..85fba4554b159bb77645a470605e3acbf1176af7 100644 (file)
@@ -140,12 +140,14 @@ export class CoherentMeterValuesManager {
    * 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) {
@@ -159,6 +161,7 @@ export class CoherentMeterValuesManager {
     }
     const session = createCoherentSession(this.chargingStation, {
       connectorId,
+      ...(evseId != null && { evseId }),
       profiles: this.evProfiles.profiles,
       rootSeed: resolveRootSeed(this.chargingStation.stationInfo),
       transactionId,
index 1a900be542873c67951500c747d03bcfd8f4cd54..3c360bd4e9913aa48db93f2e3aabe86295319c90 100644 (file)
@@ -49,6 +49,7 @@ export {
   getBootConnectorStatus,
   initializeConnectorsMapStatus,
   prepareConnectorStatus,
+  preparePersistedTransactionEventQueue,
   resetAuthorizeConnectorStatus,
   resetConnectorStatus,
 } from './HelpersConnectorStatus.js'
index da5c5c156013bd274f1d7912b1b0019b6362d282..d1d23b42c4639e4d99093720a2b4fb32e446ab66 100644 (file)
@@ -156,13 +156,19 @@ export const getChargingStationChargingProfilesLimit = (
  * 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 (
@@ -195,17 +201,24 @@ export const getConnectorChargingProfiles = (
  * @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)
@@ -214,8 +227,10 @@ export const getConnectorChargingProfilesLimit = (
         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`,
@@ -280,16 +295,18 @@ const buildChargingProfilesLimit = (
  * @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)
index 08b829c9106a904095a574e4953e3ca673f8ca37..8afac5cb14d313660ecc0ce81b7e4014524e5f18 100644 (file)
 
 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'
 
@@ -125,6 +148,7 @@ export const initializeConnectorsMapStatus = (
   defaultMaximumPower?: number
 ): void => {
   for (const [connectorId, connectorStatus] of connectors) {
+    delete connectorStatus.transactionEnding
     if (connectorId > 0 && connectorStatus.transactionStarted === true) {
       if (
         connectorStatus.transactionId == null ||
@@ -167,9 +191,10 @@ export const resetAuthorizeConnectorStatus = (connectorStatus: ConnectorStatus):
  * 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 => {
@@ -190,10 +215,17 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
   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
@@ -202,6 +234,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
     delete connectorStatus.transactionEndedMeterValuesSetInterval
   }
   delete connectorStatus.transactionSeqNo
+  delete connectorStatus.transactionStartedExhaustedTransactionId
   delete connectorStatus.publicKeySentInTransaction
   delete connectorStatus.transactionEvseSent
   delete connectorStatus.transactionIdTokenSent
@@ -209,15 +242,182 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
   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) {
@@ -226,6 +426,85 @@ export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): Connec
       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(
@@ -246,6 +525,348 @@ export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): Connec
   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
index e8938e3c63ca435537fd5505afe5a1fa9a0d346c..4b8d16533f4e2cbb81d4db1d002ade052491b85b 100644 (file)
@@ -60,10 +60,12 @@ export const hasPendingReservations = (chargingStation: ChargingStation): boolea
  * 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()) {
@@ -73,7 +75,11 @@ export const removeExpiredReservations = async (
   }
   const results = await Promise.allSettled(
     reservations.map(reservation =>
-      chargingStation.removeReservation(reservation, ReservationTerminationReason.EXPIRED)
+      chargingStation.removeReservation(
+        reservation,
+        ReservationTerminationReason.EXPIRED,
+        isOperationCurrent
+      )
     )
   )
   let failureCount = 0
diff --git a/src/charging-station/TransactionEventQueueUtils.ts b/src/charging-station/TransactionEventQueueUtils.ts
new file mode 100644 (file)
index 0000000..c9525d6
--- /dev/null
@@ -0,0 +1,1376 @@
+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
+}
index c098dc100cb4aa831d43bac6947d6816301f5c8a..421c6dd82168512f586d6e6b47b99b83340e3066 100644 (file)
@@ -27,8 +27,12 @@ import {
   type OCPP16MeterValue,
   OCPP20AuthorizationStatusEnumType,
   type OCPP20AuthorizeResponse,
+  OCPP20ComponentName,
   type OCPP20Get15118EVCertificateResponse,
   type OCPP20GetCertificateStatusResponse,
+  type OCPP20MeterValue,
+  OCPP20ReadingContextEnumType,
+  OCPP20RequiredVariableName,
   type OCPP20SignCertificateResponse,
   type OCPP20TransactionEventResponse,
   OCPPVersion,
@@ -52,13 +56,25 @@ import {
   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'
@@ -251,10 +267,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
           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,
@@ -432,20 +445,54 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
     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,
@@ -455,39 +502,272 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
             ? 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
     )
   }
 
@@ -511,19 +791,35 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
   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
     )
   }
index d1e1bf5437152da93beedbccef05138380b2479b..b56159e176073d3a90cd60978a6e0479b99fec98 100644 (file)
@@ -42,8 +42,29 @@ export {
 } 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'
index 0b218d7c787f29c6d98b5bb9b1b444c18a0eeccc..efcb3305b79a3ec1fc8dd45d890382cc77c82aff 100644 (file)
  * - 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'
 
@@ -83,8 +108,13 @@ const PHASE_FAMILY = {
 
 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`
@@ -96,9 +126,12 @@ const MEASURAND_EMIT_ORDER = [
   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.
@@ -144,15 +177,40 @@ const groupTemplatesByMeasurand = (
 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
@@ -161,15 +219,19 @@ const templateFamilyKey = (t: SampledValueTemplate): string =>
  * 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)
@@ -224,11 +286,14 @@ const applyRegisterValuesWithoutPhases = (
  * @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
@@ -241,17 +306,42 @@ const resolvePhasedValue = (
   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
@@ -271,9 +361,6 @@ const resolvePhasedValue = (
     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
       }
@@ -283,34 +370,6 @@ const resolvePhasedValue = (
   }
 }
 
-/**
- * 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
@@ -329,130 +388,394 @@ const resolveUnitDivider = (
  * 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)
+  )
 }
index b9c2dd6b8f02ee2335ab6be70bfb1bb3ca747df8..eda3411ac254576e819265695b5719caa77aed71 100644 (file)
@@ -33,7 +33,7 @@
  * - `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.
@@ -42,7 +42,7 @@
 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'
@@ -55,6 +55,9 @@ import { createStreamPrng } from './PRNG.js'
  * auto-collected when the session becomes unreachable.
  */
 interface SessionRuntime {
+  lastComputedAtMs?: number
+  lastSample?: CoherentSample
+  pendingSharedEnergyWh?: number
   voltagePrng?: () => number
 }
 
@@ -77,6 +80,35 @@ const getSessionRuntime = (session: CoherentSession): SessionRuntime => {
   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.
@@ -95,6 +127,38 @@ export const disposeCoherentSessionRuntime = (session: CoherentSession | undefin
   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
@@ -111,7 +175,7 @@ export interface CoherentSample {
   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.
@@ -230,31 +294,71 @@ const sigmoidRamp = (progress: number): number => {
 }
 
 /**
- * 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.
  *
@@ -282,14 +386,16 @@ export const advanceEnergyRegister = (
  * @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
 
@@ -382,7 +488,7 @@ export const computeCoherentSample = (
   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
@@ -429,7 +535,7 @@ export const computeCoherentSample = (
   // 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
@@ -440,7 +546,7 @@ export const computeCoherentSample = (
   const deltaSocPercent = (deltaEnergyWh / session.profile.batteryCapacityWh) * 100
   session.socPercent = Math.min(100, session.socPercent + deltaSocPercent)
 
-  return {
+  const sample: CoherentSample = {
     currentA: roundedCurrent,
     deltaEnergyWh,
     energyRegisterWh: projectedRegisterWh,
@@ -448,4 +554,41 @@ export const computeCoherentSample = (
     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
+  )
 }
index 7b301ddf351e0da8a60fb2efedb1cd0d963fde4f..3c7f5393375e96a1dd955b0a2b66fbefa0544c1b 100644 (file)
@@ -48,6 +48,8 @@ export const isCoherentModeActive = (
 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. */
@@ -107,6 +109,7 @@ export const createCoherentSession = (
   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,
diff --git a/src/charging-station/meter-values/MeterValueUtils.ts b/src/charging-station/meter-values/MeterValueUtils.ts
new file mode 100644 (file)
index 0000000..829bc03
--- /dev/null
@@ -0,0 +1,155 @@
+// 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),
+  ])
diff --git a/src/charging-station/meter-values/TransactionIntervalUtils.ts b/src/charging-station/meter-values/TransactionIntervalUtils.ts
new file mode 100644 (file)
index 0000000..efef3b3
--- /dev/null
@@ -0,0 +1,197 @@
+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
+  )
+}
diff --git a/src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.ts b/src/charging-station/meter-values/TransactionMeterValueDeliveryBarrier.ts
new file mode 100644 (file)
index 0000000..b6521c6
--- /dev/null
@@ -0,0 +1,167 @@
+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]
+      }
+    }
+  }
+}
index 3f4623b7f70948bce25ef803a7d54ece1eb44f93..45904dd5a981a86031861b5609b961b0e97d8f35 100644 (file)
@@ -1,32 +1,79 @@
 // 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'
index e882dd4ea4ff1997bc00000247542a0c704a69f0..a8399784d2c190c489780ca61631806e39af810b 100644 (file)
@@ -136,6 +136,7 @@ export const EvProfilesFileSchema = z.object({
 export interface CoherentSession {
   readonly connectorId: number
   readonly currentType: CurrentType
+  readonly evseId?: number
   readonly numberOfPhases: number
   readonly profile: EvProfile
   readonly rampUpDurationMs: number
@@ -154,8 +155,8 @@ export interface CoherentSession {
  * 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
index b7cae5da960a6b6ef4725c2ea17533db2b176557..d0b61685742a0819b42d81d9a7c67bb1969eccbc 100644 (file)
@@ -27,6 +27,15 @@ import {
   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,
@@ -84,8 +93,6 @@ import {
   type OCPP16SendLocalListRequest,
   type OCPP16SendLocalListResponse,
   OCPP16StandardParametersKey,
-  type OCPP16StartTransactionRequest,
-  type OCPP16StartTransactionResponse,
   type OCPP16StatusNotificationResponse,
   OCPP16StopTransactionReason,
   OCPP16SupportedFeatureProfiles,
@@ -137,6 +144,11 @@ import {
   isIncomingRequestCommandSupported,
   isMessageTriggerSupported,
 } from '../OCPPServiceUtils.js'
+import {
+  claimPublicKeyDelivery,
+  releasePublicKeyDelivery,
+  retainPublicKeyDelivery,
+} from '../OCPPSignedMeterValueUtils.js'
 import { OCPP16Constants } from './OCPP16Constants.js'
 import { OCPP16ServiceUtils } from './OCPP16ServiceUtils.js'
 
@@ -233,6 +245,19 @@ interface OCPP16StationState {
    * 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
 }
 
 /**
@@ -282,6 +307,21 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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.
    */
@@ -371,6 +411,23 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       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,
       (
@@ -379,38 +436,37 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
         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
@@ -500,7 +556,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
             // 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
@@ -526,7 +582,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
             // '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 &&
@@ -560,80 +616,134 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
               )
               .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
@@ -745,6 +855,25 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -758,6 +887,25 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -777,6 +925,11 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
   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)
+    }
   }
 
   /**
@@ -885,7 +1038,8 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       }
       await chargingStation.removeReservation(
         reservation,
-        ReservationTerminationReason.RESERVATION_CANCELED
+        ReservationTerminationReason.RESERVATION_CANCELED,
+        () => this.isIncomingRequestDeliveryCurrent(chargingStation)
       )
       return OCPP16Constants.OCPP_CANCEL_RESERVATION_RESPONSE_ACCEPTED
     } catch (error) {
@@ -925,7 +1079,8 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           .map(({ connectorId }) => connectorId)
           .toArray(),
         chargePointStatus,
-        type
+        type,
+        () => this.isIncomingRequestDeliveryCurrent(chargingStation)
       )
       return response
     } else if (
@@ -945,10 +1100,14 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       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
@@ -1346,9 +1505,14 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           ...(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
@@ -1377,6 +1541,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
             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,
@@ -1403,6 +1571,10 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
           OCPP16IncomingRequestCommand.GET_DIAGNOSTICS
         )
       } catch (error) {
+        if (!this.isIncomingRequestDeliveryCurrent(chargingStation)) {
+          ftpClient?.close()
+          return OCPP16Constants.OCPP_RESPONSE_EMPTY
+        }
         await chargingStation.ocppRequestService.requestHandler<
           OCPP16DiagnosticsStatusNotificationRequest,
           OCPP16DiagnosticsStatusNotificationResponse
@@ -1441,6 +1613,9 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       >(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
       }
@@ -1561,13 +1736,12 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
         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,
@@ -1631,6 +1805,9 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -1638,7 +1815,12 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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:
@@ -1664,10 +1846,16 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
             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
       }
@@ -1703,11 +1891,11 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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(
@@ -1899,9 +2087,133 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       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
     }
@@ -1925,20 +2237,34 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       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
   }
@@ -1960,7 +2286,7 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
       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`
       )
@@ -1969,6 +2295,23 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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,
@@ -1988,31 +2331,24 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService<OCP
     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 (
index 9da1a59d3052ce2c2fa17e966eb63fa45a9d298f..8013686b27fe44c03a4f0e22f529c592d3db21ea 100644 (file)
@@ -15,7 +15,7 @@ import {
   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'
@@ -86,8 +86,6 @@ export class OCPP16RequestService extends OCPPRequestService {
     commandName: OCPP16RequestCommand,
     commandParams?: JsonType
   ): JsonType {
-    let connectorId: number | undefined
-    let energyActiveImportRegister: number
     logger.debug(
       `${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Building '${commandName}' payload`
     )
@@ -143,59 +141,63 @@ export class OCPP16RequestService extends OCPPRequestService {
           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`
@@ -207,6 +209,10 @@ export class OCPP16RequestService extends OCPPRequestService {
     }
   }
 
+  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.
index 61d5e8eebfd85a2e9ac262098a9c1d353b76e693..2eb50e52aca0c20b32fa1f0d53946c91150ddac6 100644 (file)
@@ -12,6 +12,7 @@ import {
   resetConnectorStatus,
 } from '../../../charging-station/index.js'
 import {
+  AvailabilityType,
   ChargingStationEvents,
   type ConnectorStatus,
   type JsonType,
@@ -37,6 +38,7 @@ import {
 import {
   Constants,
   convertToInt,
+  interruptibleSleep,
   isNotEmptyArray,
   logger,
   sleep,
@@ -71,8 +73,7 @@ const finalizeTransactionConnectorStatus = (
   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.
@@ -81,7 +82,6 @@ const finalizeTransactionConnectorStatus = (
   if (connectorStatus != null) {
     connectorStatus.locked = false
   }
-  return transactionIdTag
 }
 
 /**
@@ -544,9 +544,23 @@ export class OCPP16ResponseService extends OCPPResponseService {
     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()}`
@@ -579,11 +593,26 @@ export class OCPP16ResponseService extends OCPPResponseService {
       }
     }
     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,
@@ -599,21 +628,40 @@ export class OCPP16ResponseService extends OCPPResponseService {
       // 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
@@ -633,13 +681,6 @@ export class OCPP16ResponseService extends OCPPResponseService {
     } else {
       logger.warn(logMsg)
     }
-    if (payload.idTagInfo != null && transactionIdTag != null) {
-      OCPP16ServiceUtils.updateAuthorizationCache(
-        chargingStation,
-        transactionIdTag,
-        payload.idTagInfo
-      )
-    }
   }
 
   private async resetConnectorOnStartTransactionError (
index 20f253e1550c34d4da7abbbcb7d2940f9866dbce..9d8b5c9055793e0e23465dc03cdf5c181871c348 100644 (file)
@@ -16,9 +16,21 @@ import {
   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,
@@ -33,17 +45,21 @@ import {
   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,
@@ -52,16 +68,18 @@ import {
 } 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 {
@@ -70,19 +88,92 @@ 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 {
@@ -124,6 +215,18 @@ 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
@@ -133,39 +236,41 @@ export class OCPP16ServiceUtils {
    * `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
@@ -178,9 +283,10 @@ export class OCPP16ServiceUtils {
       meterValue.timestamp
     )
     meterValue.sampledValue.push(signedResult.sampledValue)
-    if (signedResult.publicKeyIncluded) {
+    if (signedResult.publicKeyIncluded && commitState) {
       connectorStatus.publicKeySentInTransaction = true
     }
+    return signedResult.publicKeyIncluded
   }
 
   /**
@@ -220,9 +326,19 @@ export class OCPP16ServiceUtils {
       )
     }
     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
@@ -230,7 +346,7 @@ export class OCPP16ServiceUtils {
       meterValue.sampledValue.push(
         buildOCPP16SampledValue(
           sampledValueTemplate,
-          roundTo((meterStart ?? 0) / unitDivider, 4),
+          roundTo(projectedMeterStartWh / unitDivider, 4),
           OCPP16MeterValueContext.TRANSACTION_BEGIN
         )
       )
@@ -249,7 +365,7 @@ export class OCPP16ServiceUtils {
       if (signingCfg != null) {
         const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
           signingCfg,
-          meterStart ?? 0,
+          meterStartOutputWh,
           OCPP16MeterValueContext.TRANSACTION_BEGIN,
           transactionId,
           publicKeySentInTransaction,
@@ -284,12 +400,14 @@ export class OCPP16ServiceUtils {
    * @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) {
@@ -297,15 +415,25 @@ export class OCPP16ServiceUtils {
         `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
       )
     )
@@ -320,16 +448,13 @@ export class OCPP16ServiceUtils {
       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
@@ -341,13 +466,15 @@ export class OCPP16ServiceUtils {
    * @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) {
@@ -362,10 +489,14 @@ export class OCPP16ServiceUtils {
       }
       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)
     }
@@ -698,6 +829,18 @@ export class OCPP16ServiceUtils {
     { 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
@@ -788,10 +931,6 @@ export class OCPP16ServiceUtils {
     chargingStation: ChargingStation,
     connectorId: number
   ): Promise<GenericResponse> => {
-    await sendAndSetConnectorStatus(chargingStation, {
-      connectorId,
-      status: OCPP16ChargePointStatus.Finishing,
-    })
     const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
       chargingStation,
       connectorId,
@@ -856,23 +995,51 @@ export class OCPP16ServiceUtils {
 
   /**
    * 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
+      }
+    }
   }
 
   /**
@@ -905,35 +1072,163 @@ export class OCPP16ServiceUtils {
       )
       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))
   }
 
@@ -942,52 +1237,668 @@ export class OCPP16ServiceUtils {
    * @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
   }
 
   /**
@@ -1003,6 +1914,7 @@ export class OCPP16ServiceUtils {
     if (connectorStatus?.transactionUpdatedMeterValuesSetInterval != null) {
       clearInterval(connectorStatus.transactionUpdatedMeterValuesSetInterval)
       delete connectorStatus.transactionUpdatedMeterValuesSetInterval
+      OCPP16ServiceUtils.periodicMeterValuesIntervals.delete(connectorStatus)
     }
   }
 
@@ -1022,6 +1934,21 @@ export class OCPP16ServiceUtils {
     }
   }
 
+  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
@@ -1083,6 +2010,68 @@ export class OCPP16ServiceUtils {
     }
   }
 
+  /**
+   * 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
@@ -1153,6 +2142,63 @@ export class OCPP16ServiceUtils {
     }
   }
 
+  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
@@ -1186,4 +2232,25 @@ export class OCPP16ServiceUtils {
       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
+        }
+      }),
+    }))
+  }
 }
index 22da3471595b13bcc463a196a43251fe771b1368..f217316a716cc1c650cb75d99b2b47a4d017f566 100644 (file)
@@ -4,7 +4,6 @@ import type { ValidateFunction } from 'ajv'
 
 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'
@@ -79,10 +78,8 @@ import {
   type OCPP20InstallCertificateResponse,
   type OCPP20LogStatusNotificationRequest,
   type OCPP20LogStatusNotificationResponse,
-  OCPP20MeasurandEnumType,
   type OCPP20MeterValue,
   type OCPP20MeterValuesRequest,
-  type OCPP20MeterValuesResponse,
   type OCPP20NotifyCustomerInformationRequest,
   type OCPP20NotifyCustomerInformationResponse,
   type OCPP20NotifyReportRequest,
@@ -156,11 +153,24 @@ import {
 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,
@@ -175,10 +185,14 @@ import {
 } 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,
@@ -186,7 +200,13 @@ import {
 } 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'
 
@@ -307,8 +327,9 @@ interface OCPP20StationState {
   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}
@@ -326,6 +347,12 @@ interface OCPP20StationState {
   stopped?: boolean
 }
 
+interface PendingRemoteStartResponse {
+  readonly connectorId: number
+  readonly evseId: number
+  readonly transactionId: string
+}
+
 interface QueuedSecurityEvent {
   retryCount?: number
   techInfo?: string
@@ -333,6 +360,54 @@ interface QueuedSecurityEvent {
   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.
  *
@@ -372,6 +447,26 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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>([
@@ -557,6 +652,33 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       }
     )
     // 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,
       (
@@ -564,14 +686,29 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         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,
@@ -580,9 +717,13 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
               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
@@ -608,14 +749,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           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
+              )
+            })
           }
         }
       }
@@ -654,7 +800,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
             // 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 }
@@ -681,7 +827,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
               .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
@@ -700,15 +846,29 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
               .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
+          }
         }
       }
     )
@@ -736,13 +896,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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 },
         },
       ],
@@ -935,6 +1099,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
    */
   public override stop (chargingStation: ChargingStation): void {
     super.stop(chargingStation)
+    OCPP20ServiceUtils.cancelClockAlignedMeterValuesRequests(chargingStation)
     try {
       const variableManager = OCPP20VariableManager.getInstance()
       const stationId = chargingStation.stationInfo?.hashId
@@ -950,24 +1115,51 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
   }
 
   /**
-   * @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
@@ -1172,6 +1364,71 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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`.
@@ -1189,6 +1446,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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)
   }
@@ -1222,6 +1484,50 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -1236,16 +1542,30 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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,
@@ -1253,19 +1573,22 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
                 },
               ],
               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,
@@ -1285,7 +1608,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         }
 
         try {
-          const variableManager = OCPP20VariableManager.getInstance()
           const getVariableData: OCPP20GetVariablesRequest['getVariableData'] = []
           for (const variableMetadata of Object.values(VARIABLE_REGISTRY)) {
             const variableDescriptor: { instance?: string; name: string } = {
@@ -1294,24 +1616,29 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
             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)
@@ -1325,7 +1652,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
             }
           >()
           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,
@@ -1415,6 +1742,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           })
         }
         break
+      }
 
       case ReportBaseEnumType.SummaryInventory:
         reportData.push(...buildStationInfoReportData(chargingStation, STATION_INFO_FIELDS_SUMMARY))
@@ -1480,6 +1808,88 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -1494,7 +1904,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
   }
 
   private clearActiveFirmwareUpdate (chargingStation: ChargingStation, requestId: number): void {
-    const stationState = this.stationsState.get(chargingStation)
+    const stationState = this.getStationState(chargingStation)
     if (stationState == null) {
       return
     }
@@ -1508,7 +1918,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
   }
 
   private clearActiveLogUpload (chargingStation: ChargingStation, requestId: number): void {
-    const stationState = this.stationsState.get(chargingStation)
+    const stationState = this.getStationState(chargingStation)
     if (stationState == null) {
       return
     }
@@ -1521,97 +1931,126 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
   }
 
-  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
@@ -1635,7 +2074,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
 
     const evseStatus = chargingStation.getEvseStatus(evseId)
-    if (!evseStatus?.connectors.has(connectorId)) {
+    const connectorStatus = evseStatus?.connectors.get(connectorId)
+    if (connectorStatus == null) {
       return {
         status: ChangeAvailabilityStatusEnumType.Rejected,
         statusInfo: {
@@ -1645,14 +2085,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       }
     }
 
+    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()}:`,
@@ -1723,6 +2168,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
 
     const evseStatus = chargingStation.getEvseStatus(evseId)
+    if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
+      this.savePreInoperativeStatuses(chargingStation, evseId)
+    }
     if (
       evseStatus != null &&
       operationalStatus === OCPP20OperationalStatusEnumType.Inoperative &&
@@ -1864,9 +2312,13 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         }
       }
 
+      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`
         )
@@ -1876,10 +2328,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       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,
       }
@@ -1924,11 +2377,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       `${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
@@ -1956,6 +2404,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
 
     // CS-level change (no evse or evse.id === 0)
     if (operationalStatus === OCPP20OperationalStatusEnumType.Inoperative) {
+      this.savePreInoperativeStatuses(chargingStation)
       const result = this.handleCsLevelInoperative(
         chargingStation,
         operationalStatus,
@@ -2108,6 +2557,15 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       )
       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)`
@@ -2191,11 +2649,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
 
     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}`
@@ -2204,6 +2660,37 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         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,
     }
@@ -2296,7 +2783,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       `${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()
@@ -2347,15 +2834,18 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     // 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),
     }
   }
 
@@ -2460,10 +2950,10 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
   }
 
-  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()}` : ''}`
     )
@@ -2545,125 +3035,82 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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 = {
@@ -2797,83 +3244,63 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       `${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()}`
       )
     }
 
@@ -3014,11 +3441,66 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       }
     }
 
+    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}`
       )
@@ -3044,13 +3526,23 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       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)
@@ -3109,6 +3601,20 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       }
     }
 
+    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}'`
     )
@@ -3148,12 +3654,67 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         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 }
         }
 
@@ -3162,7 +3723,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           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: {
@@ -3171,6 +3733,27 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
               },
             }
           }
+          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 }
         }
 
@@ -3266,12 +3849,24 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         `${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) {
@@ -3360,11 +3955,6 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
   }
 
-  /**
-   * 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) {
@@ -3388,6 +3978,22 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -3409,18 +4015,29 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     )
   }
 
-  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,
@@ -3495,6 +4112,59 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     )
   }
 
+  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)
@@ -3504,6 +4174,29 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
   }
 
+  // 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
@@ -3520,19 +4213,27 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
    * @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)
   }
 
   /**
@@ -3542,32 +4243,35 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
    * (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(),
         })
       }
@@ -3575,12 +4279,58 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
@@ -3592,13 +4342,15 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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)
           }
         }
       }
@@ -3610,55 +4362,64 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
    * @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)) {
@@ -3666,7 +4427,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           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)
@@ -3677,11 +4438,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
   /**
    * 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(
@@ -3697,13 +4466,17 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }, 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
@@ -3713,10 +4486,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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()}:`,
@@ -3743,6 +4517,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         sendAndSetConnectorStatus(chargingStation, {
           connectorId,
           connectorStatus: status,
+          evseId,
         }).catch((error: unknown) => {
           logger.error(
             `${chargingStation.logPrefix()} ${moduleName}.sendEvseStatusNotifications: Error sending status notification for connector ${connectorId.toString()}:`,
@@ -3833,60 +4608,70 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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
     }
@@ -3965,11 +4750,13 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
   }
 
   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()}:`,
@@ -3986,10 +4773,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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()}:`,
@@ -4255,7 +5043,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
       // 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 =
@@ -4361,75 +5149,40 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     )
   }
 
-  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)
+      })
     }
   }
 
@@ -4438,86 +5191,79 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     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 (
@@ -4849,6 +5595,18 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
         },
       }
     }
+    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
   }
 }
index 29c34ee39d546bafd309f9dac4db2a9737ca0125..b6fde07f9c08d1548b01ef2f3ddf654eae7d8ca1 100644 (file)
@@ -65,6 +65,9 @@ export function buildOCPP20SampledValue (
 ): 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,
index 78e67100501f5fa898e075515a7236fdcd05d9e1..9b1ddbd4cc9ef40f32052084700fbbdc553b907a 100644 (file)
@@ -149,4 +149,8 @@ export class OCPP20RequestService extends OCPPRequestService {
       }
     }
   }
+
+  protected getDefaultResponseTimeoutMs (chargingStation: ChargingStation): number {
+    return OCPP20ServiceUtils.getMessageTimeout(chargingStation)
+  }
 }
index bf4c6791834a958e4408255e81d42235c1db85e2..7c425c04b24ac4d255434fcbc44711e52dedd8d7 100644 (file)
@@ -4,6 +4,8 @@ import {
   addConfigurationKey,
   buildConfigKey,
   type ChargingStation,
+  hasQueuedEndedTransactionEvent,
+  shiftBoundedTransactionEvent,
 } from '../../../charging-station/index.js'
 import {
   ChargingStationEvents,
@@ -44,7 +46,7 @@ import { sendAndSetConnectorStatus } from '../OCPPConnectorStatusOperations.js'
 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'
 
 /**
@@ -395,21 +397,44 @@ export class OCPP20ResponseService extends OCPPResponseService {
     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()}`
           )
@@ -424,11 +449,22 @@ export class OCPP20ResponseService extends OCPPResponseService {
           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
@@ -440,36 +476,53 @@ export class OCPP20ResponseService extends OCPPResponseService {
             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(
@@ -494,7 +547,8 @@ export class OCPP20ResponseService extends OCPPResponseService {
         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`
@@ -521,6 +575,13 @@ export class OCPP20ResponseService extends OCPPResponseService {
             `${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`
index 731f3aa55cadf65841193fbbc6262dc0bd9f995b..eecc6c4fc89dd6064a105fd11e37af241cad39e5 100644 (file)
@@ -1,12 +1,59 @@
 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,
@@ -17,12 +64,17 @@ import {
   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,
@@ -30,9 +82,13 @@ import {
   type OCPP20TransactionEventResponse,
   type OCPP20TransactionType,
   OCPP20TriggerReasonEnumType,
+  OCPP20UnitEnumType,
   OCPPVersion,
+  type QueuedTransactionEvent,
   ReasonCodeEnumType,
   RequestCommand,
+  type RequestParams,
+  type SampledValueTemplate,
   type StartTransactionResult,
   type StatusNotificationOptions,
   type StopTransactionReason,
@@ -40,21 +96,27 @@ import {
   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,
@@ -62,16 +124,165 @@ import {
 } 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 =>
@@ -82,8 +293,905 @@ export interface RejectionReason {
   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
@@ -130,6 +1238,46 @@ export class OCPP20ServiceUtils {
     [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`
@@ -166,28 +1314,361 @@ export class OCPP20ServiceUtils {
   }
 
   /**
-   * 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)}`
@@ -196,43 +1677,180 @@ export class OCPP20ServiceUtils {
     }
   }
 
+  /**
+   * 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)
   }
 
   /**
@@ -321,6 +1939,880 @@ export class OCPP20ServiceUtils {
     { 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
@@ -440,41 +2932,221 @@ export class OCPP20ServiceUtils {
       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)
@@ -519,9 +3191,16 @@ export class OCPP20ServiceUtils {
     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
   }
 
@@ -529,9 +3208,15 @@ export class OCPP20ServiceUtils {
     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)
@@ -560,12 +3245,18 @@ export class OCPP20ServiceUtils {
   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 },
       },
     ])
@@ -578,6 +3269,28 @@ export class OCPP20ServiceUtils {
     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
@@ -592,7 +3305,8 @@ export class OCPP20ServiceUtils {
   ): Promise<OCPP20TransactionEventResponse> {
     const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction(
       chargingStation,
-      connectorId
+      connectorId,
+      evseId
     )
 
     const stopTxOnInvalidId = OCPP20ServiceUtils.readVariableAsBoolean(
@@ -651,14 +3365,13 @@ export class OCPP20ServiceUtils {
       }
     )
 
-    return this.terminateTransaction(
+    return this.requestStopTransaction(
       chargingStation,
       connectorId,
-      connectorStatus,
-      transactionId,
+      evseId,
       OCPP20TriggerReasonEnumType.Deauthorized,
       OCPP20ReasonEnumType.DeAuthorized,
-      evseId
+      transactionId
     )
   }
 
@@ -669,29 +3382,302 @@ export class OCPP20ServiceUtils {
    * @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()
+        }
+      },
+    }
   }
 
   /**
@@ -700,12 +3686,14 @@ export class OCPP20ServiceUtils {
    * 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
@@ -717,132 +3705,371 @@ export class OCPP20ServiceUtils {
   }
 
   /**
-   * 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
+    )
   }
 
   /**
@@ -850,40 +4077,87 @@ export class OCPP20ServiceUtils {
    * @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)}`
@@ -896,6 +4170,9 @@ export class OCPP20ServiceUtils {
     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()
@@ -917,6 +4194,7 @@ export class OCPP20ServiceUtils {
       connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue
     }
     let response
+    if (connectorStatus != null) connectorStatus.transactionStarting = true
     try {
       response = await OCPP20ServiceUtils.sendTransactionEvent(
         chargingStation,
@@ -928,21 +4206,73 @@ export class OCPP20ServiceUtils {
           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 }
   }
 
   /**
@@ -950,13 +4280,15 @@ export class OCPP20ServiceUtils {
    * @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`
@@ -969,15 +4301,25 @@ export class OCPP20ServiceUtils {
       )
       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
@@ -991,54 +4333,79 @@ export class OCPP20ServiceUtils {
           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)}`
@@ -1114,9 +4481,14 @@ export class OCPP20ServiceUtils {
    * 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
@@ -1157,12 +4529,14 @@ export class OCPP20ServiceUtils {
    * 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
@@ -1193,90 +4567,2186 @@ export class OCPP20ServiceUtils {
     }
   }
 
+  /**
+   * 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 (
@@ -1286,32 +6756,325 @@ export class OCPP20ServiceUtils {
     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)
+  }
 }
 
 /**
@@ -1330,39 +7093,104 @@ export function buildTransactionEvent (
       : 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
@@ -1398,7 +7226,7 @@ export function buildTransactionEvent (
   const transactionEventRequest: OCPP20TransactionEventRequest = {
     eventType,
     seqNo: connectorStatus.transactionSeqNo,
-    timestamp: new Date(),
+    timestamp: commandParams.timestamp ?? new Date(),
     transactionInfo,
     triggerReason,
   }
index 709844961549127355ec9a9605b1c165816a5948..f9d2a85ac2f4d5113f1cd222583003efd696ae0d 100644 (file)
@@ -1,4 +1,4 @@
-import { millisecondsToSeconds } from 'date-fns'
+import { millisecondsToSeconds, secondsToMilliseconds } from 'date-fns'
 
 import { BaseError } from '../../../exception/index.js'
 import {
@@ -51,6 +51,38 @@ const isOCPP20RequiredVariableName = (name: string): name is OCPP20RequiredVaria
 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
 
@@ -87,6 +119,29 @@ export class OCPP20VariableManager {
     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[]
@@ -148,8 +203,17 @@ export class OCPP20VariableManager {
    */
   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 (
@@ -166,11 +230,7 @@ export class OCPP20VariableManager {
       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
@@ -234,11 +294,7 @@ export class OCPP20VariableManager {
         ) {
           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 &&
@@ -447,11 +503,7 @@ export class OCPP20VariableManager {
       )
     }
 
-    const variableKey = buildCaseInsensitiveCompositeKey(
-      component.name,
-      component.instance,
-      variable.name
-    )
+    const variableKey = buildScopedVariableKey(component, variable)
     if (invalidVariables.has(variableKey)) {
       return this.rejectGet(
         variable,
@@ -590,11 +642,25 @@ export class OCPP20VariableManager {
     }
   }
 
-  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
@@ -655,23 +721,23 @@ export class OCPP20VariableManager {
     )
     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,
@@ -765,11 +831,7 @@ export class OCPP20VariableManager {
       )
     }
 
-    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(
@@ -1032,8 +1094,21 @@ export class OCPP20VariableManager {
     }
 
     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)
@@ -1046,7 +1121,7 @@ export class OCPP20VariableManager {
             readonly: isReadOnly(variableMetadata),
             reboot: variableMetadata.rebootRequired === true,
           },
-          { overwrite: false }
+          { overwrite: false, save: true }
         )
       } else if (configKey.value !== attributeValue) {
         setConfigurationKeyValue(chargingStation, configurationKeyName, attributeValue)
@@ -1071,10 +1146,71 @@ export class OCPP20VariableManager {
     ) {
       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 {
index 6694394c3006dd5a6cd2b806ff707a8e5af7dc45..60b8dc8f7d1107d25880f2dbb36b554939011b71 100644 (file)
@@ -137,7 +137,10 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     supportedAttributes: [AttributeEnumType.Actual],
     variable: 'Available',
   },
-  [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, 'SendDuringIdle')]: {
+  [buildRegistryKey(
+    OCPP20ComponentName.AlignedDataCtrlr,
+    OCPP20OptionalVariableName.SendDuringIdle
+  )]: {
     component: OCPP20ComponentName.AlignedDataCtrlr,
     dataType: DataEnumType.boolean,
     defaultValue: 'false',
@@ -146,7 +149,7 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     mutability: MutabilityEnumType.ReadWrite,
     persistence: PersistenceEnumType.Persistent,
     supportedAttributes: [AttributeEnumType.Actual],
-    variable: 'SendDuringIdle',
+    variable: OCPP20OptionalVariableName.SendDuringIdle,
   },
   [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20OptionalVariableName.SignReadings)]:
     {
@@ -164,12 +167,15 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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,
@@ -1623,7 +1629,7 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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,
index 20bbcab1d906239091f2721dc3a83d8c6a4bf333..7922beb463fd9904144db0fd3a4cd8fae84c236e 100644 (file)
@@ -36,7 +36,8 @@ export type SendMessageFn = (
   messageId: string,
   messagePayload: JsonType,
   commandName: string,
-  params?: RequestParams
+  params?: RequestParams,
+  cancellationGenerationAtRequestStart?: number
 ) => Promise<JsonType>
 
 /**
@@ -46,7 +47,7 @@ export interface SendMessageMock {
   fn: SendMessageFn
   mock: {
     calls: {
-      arguments: [ChargingStation, string, JsonType, string, RequestParams?]
+      arguments: [ChargingStation, string, JsonType, string, RequestParams?, number?]
     }[]
   }
 }
index 54ad08cfb96d16fa7dd85cd560aef07e73de6973..6cb18076d87ae9b257e155c041e9f1ad7d4b52df 100644 (file)
@@ -16,6 +16,8 @@
  */
 
 import type {
+  IncomingRequestCommand,
+  JsonType,
   OCPP20CertificateSignedRequest,
   OCPP20CertificateSignedResponse,
   OCPP20ChangeAvailabilityRequest,
@@ -71,7 +73,7 @@ import type { OCPP20IncomingRequestService } from '../OCPP20IncomingRequestServi
  * 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.
@@ -197,7 +199,7 @@ interface TestableOCPP20IncomingRequestService {
   handleRequestReset: (
     chargingStation: ChargingStation,
     commandPayload: OCPP20ResetRequest
-  ) => Promise<OCPP20ResetResponse>
+  ) => OCPP20ResetResponse
 
   handleRequestSendLocalList: (
     chargingStation: ChargingStation,
@@ -262,6 +264,12 @@ interface TestableOCPP20IncomingRequestService {
     presentedGroupIdToken?: OCPP20IdTokenType
   ) => boolean
 
+  onResponseSendError: (
+    chargingStation: ChargingStation,
+    commandName: IncomingRequestCommand,
+    commandPayload: JsonType
+  ) => Promise<void>
+
   sendFirmwareStatusNotification: (
     chargingStation: ChargingStation,
     status: OCPP20FirmwareStatusEnumType,
@@ -316,6 +324,7 @@ export function createTestableIncomingRequestService (
     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),
   }
 }
index 68c7f80a15eca299aad929f8caa1cda0f1b72e75..642523b87a388121cd0554172191f903780028bd 100644 (file)
@@ -1,6 +1,7 @@
 import { type ChargingStation } from '../../charging-station/index.js'
 import { OCPPError } from '../../exception/index.js'
 import {
+  AvailabilityType,
   ChargingStationEvents,
   type ConnectorStatus,
   ConnectorStatusEnum,
@@ -18,33 +19,73 @@ import { OCPP20Constants } from './2.0/OCPP20Constants.js'
  * 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,
   })
 }
 
@@ -53,21 +94,33 @@ export const sendAndSetConnectorStatus = async (
  * 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 }
+  )
 }
 
 /**
@@ -75,11 +128,13 @@ export const sendPostTransactionStatus = async (
  * @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 &&
@@ -87,11 +142,13 @@ export const restoreConnectorStatus = async (
   ) {
     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,
     })
   }
@@ -100,10 +157,14 @@ export const restoreConnectorStatus = async (
 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:
@@ -133,7 +194,7 @@ const checkConnectorStatusTransition = (
         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
index b5cf91e3e2901796cf17d270fe7102033ac5b663..8d8568e074f68caa717ab1c5a3e7515399565b90 100644 (file)
@@ -79,6 +79,7 @@ export class OCPPConstants {
   })
 
   static readonly OCPP_MEASURANDS_SUPPORTED = Object.freeze([
+    MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_INTERVAL,
     MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
     MeterValueMeasurand.POWER_ACTIVE_IMPORT,
     MeterValueMeasurand.CURRENT_IMPORT,
index a7606ddbe183ca550174855b76e236e52f0ae595..3b91e411d259365f7f4112664254884181fbe294 100644 (file)
@@ -1,5 +1,6 @@
 import type { ValidateFunction } from 'ajv'
 
+import { AsyncLocalStorage } from 'node:async_hooks'
 import { EventEmitter } from 'node:events'
 
 import { type ChargingStation } from '../../charging-station/index.js'
@@ -23,9 +24,10 @@ import { type Ajv, createAjv, validatePayload } from './OCPPServiceUtils.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.
@@ -90,6 +92,14 @@ export abstract class OCPPIncomingRequestService<
    * `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) {
@@ -124,86 +134,40 @@ export abstract class OCPPIncomingRequestService<
     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)
-    }
   }
 
   /**
@@ -217,7 +181,8 @@ export abstract class OCPPIncomingRequestService<
    * `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
@@ -240,6 +205,28 @@ export abstract class OCPPIncomingRequestService<
     }
   }
 
+  /**
+   * 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}.
@@ -247,33 +234,11 @@ export abstract class OCPPIncomingRequestService<
    */
   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)
@@ -281,6 +246,40 @@ export abstract class OCPPIncomingRequestService<
     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.
@@ -292,6 +291,40 @@ export abstract class OCPPIncomingRequestService<
     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
@@ -308,6 +341,27 @@ export abstract class OCPPIncomingRequestService<
    */
   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>
@@ -336,4 +390,218 @@ export abstract class OCPPIncomingRequestService<
       '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()
+  }
 }
index a93057d4b4fb7ca27e94960fc17f6c6426adaaf0..5a098b47adb1599ebd1bb3d247586121b916ec0e 100644 (file)
@@ -15,6 +15,7 @@ import {
   MessageType,
   type OCPPVersion,
   type OutgoingRequest,
+  type PendingRequestCancellationCallback,
   RequestCommand,
   type RequestParams,
   type Response,
@@ -22,12 +23,15 @@ import {
   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'
@@ -38,6 +42,41 @@ import {
   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,
@@ -56,6 +95,17 @@ export abstract class OCPPRequestService {
   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 (
@@ -89,6 +139,216 @@ export abstract class OCPPRequestService {
     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.
@@ -104,6 +364,8 @@ export abstract class OCPPRequestService {
     commandParams?: ReqType,
     params?: RequestParams
   ): Promise<ResType> {
+    const cancellationGenerationAtRequestStart =
+      this.outgoingCallCancellationStates.get(chargingStation)?.generation ?? 0
     logger.debug(
       `${chargingStation.logPrefix()} ${this.moduleName}.requestHandler: Processing '${commandName}' request`
     )
@@ -126,7 +388,8 @@ export abstract class OCPPRequestService {
           messageId,
           requestPayload,
           commandName,
-          params
+          params,
+          cancellationGenerationAtRequestStart
         )) as ResType
         logger.debug(
           `${chargingStation.logPrefix()} ${this.moduleName}.requestHandler: '${commandName}' request completed successfully`
@@ -172,7 +435,8 @@ export abstract class OCPPRequestService {
     chargingStation: ChargingStation,
     messageId: string,
     messagePayload: JsonType,
-    commandName: IncomingRequestCommand
+    commandName: IncomingRequestCommand,
+    params?: RequestParams
   ): Promise<ResponseType> {
     try {
       return await this.internalSendMessage(
@@ -180,7 +444,8 @@ export abstract class OCPPRequestService {
         messageId,
         messagePayload,
         MessageType.CALL_RESULT_MESSAGE,
-        commandName
+        commandName,
+        params
       )
     } catch (error) {
       handleSendMessageError(
@@ -196,12 +461,41 @@ export abstract class OCPPRequestService {
     }
   }
 
+  /**
+   * 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,
@@ -234,7 +528,8 @@ export abstract class OCPPRequestService {
     messageId: string,
     messagePayload: JsonType,
     commandName: RequestCommand,
-    params?: RequestParams
+    params?: RequestParams,
+    cancellationGenerationAtRequestStart?: number
   ): Promise<ResponseType> {
     params = {
       ...defaultRequestParams,
@@ -247,7 +542,8 @@ export abstract class OCPPRequestService {
         messagePayload,
         MessageType.CALL_MESSAGE,
         commandName,
-        params
+        params,
+        cancellationGenerationAtRequestStart
       )
     } catch (error) {
       handleSendMessageError(
@@ -281,29 +577,6 @@ export abstract class OCPPRequestService {
     )
   }
 
-  /**
-   * 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,
@@ -359,184 +632,718 @@ export abstract class OCPPRequestService {
     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 (
@@ -545,13 +1352,28 @@ export abstract class OCPPRequestService {
     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)
+  }
 }
index 4312f1df88a8ef0eaa01ccc6a8a2f7b40c58262b..31a88b305cdf423c969a8dd43cf97067fe0c825f 100644 (file)
@@ -4,13 +4,12 @@ import { type ChargingStation } from '../../charging-station/index.js'
 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'
@@ -92,6 +91,7 @@ export const stopTransactionOnConnector = async (
 
 /**
  * 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
  */
@@ -101,14 +101,35 @@ export const stopRunningTransactions = async (
 ): 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:
@@ -186,18 +207,23 @@ export const flushQueuedTransactionMessages = async (
       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
index b0b9447c79feaa25a5f1b7e5cc72eacf03ad88e2..6508713bbf99c58886bdba46c91b6d81af815507 100644 (file)
@@ -18,6 +18,22 @@ import {
   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,
@@ -38,9 +54,13 @@ import {
   MeterValueMeasurand,
   MeterValuePhase,
   MeterValueUnit,
+  OCPP16MeterValueFormat,
   OCPP20ComponentName,
+  type OCPP20MeterValue,
   OCPP20OptionalVariableName,
+  OCPP20PhaseEnumType,
   OCPP20ReadingContextEnumType,
+  type OCPP20SampledValue,
   OCPPVersion,
   RequestCommand,
   type SampledValue,
@@ -59,8 +79,10 @@ import {
   getRandomFloatFluctuatedRounded,
   getRandomFloatRounded,
   handleFileException,
+  isEmpty,
   isNotEmptyArray,
   isNotEmptyString,
+  isOCPP20x,
   isValidRandomIntBounds,
   JSONStringify,
   logger,
@@ -69,12 +91,6 @@ import {
   min,
   roundTo,
 } from '../../utils/index.js'
-import {
-  buildCoherentMeterValue,
-  type BuildVersionedSampledValue,
-  isCoherentModeActive,
-  resolveRootSeed,
-} from '../meter-values/index.js'
 import {
   buildOCPP16BootNotificationRequest,
   buildOCPP16SampledValue,
@@ -120,6 +136,7 @@ interface MultiPhaseMeasurandData {
 }
 
 interface SingleValueMeasurandData {
+  phaseValues?: { template: SampledValueTemplate; value: number }[]
   template: SampledValueTemplate
   value: number
 }
@@ -171,6 +188,7 @@ export const ajvErrorsToErrorType = (errors: ErrorObject[] | null | undefined):
  * @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
@@ -180,9 +198,10 @@ export const validatePayload = <T extends JsonType>(
   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) {
@@ -248,14 +267,17 @@ const buildSocMeasurandValue = (
   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
@@ -316,14 +338,17 @@ const buildVoltageMeasurandValue = (
   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
@@ -386,15 +411,18 @@ const addPhaseVoltageToMeterValue = <TSampledValue extends SampledValue>(
   ) => 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) {
@@ -421,29 +449,50 @@ const buildEnergyMeasurandValue = (
   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(
@@ -452,9 +501,9 @@ const buildEnergyMeasurandValue = (
         connectorMaximumEnergyRounded,
         connectorMinimumEnergyRounded,
         {
-          fallbackValue: connectorMinimumEnergyRounded,
+          fallbackValue: minimumEnergy,
           limitationEnabled: chargingStation.stationInfo?.customValueLimitationMeterValues,
-          unitMultiplier: unitDivider,
+          unitMultiplier: unitDivider * timeScale,
         }
       ),
       energyTemplate.fluctuationPercent ?? Constants.DEFAULT_FLUCTUATION_PERCENT
@@ -468,8 +517,12 @@ const buildEnergyMeasurandValue = (
 }
 
 const updateConnectorEnergyValues = (
+  chargingStation: ChargingStation,
   connectorStatus: ConnectorStatus | undefined,
-  energyValue: number
+  energyValue: number,
+  evseId?: number,
+  transactionEnergyValue = energyValue,
+  timestamp?: Date
 ): void => {
   if (connectorStatus != null) {
     if (
@@ -479,11 +532,32 @@ const updateConnectorEnergyValues = (
       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
+      }
+    }
   }
 }
 
@@ -491,14 +565,18 @@ const buildPowerMeasurandValue = (
   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
@@ -513,7 +591,8 @@ const buildPowerMeasurandValue = (
         measurandsKey,
         MeterValueMeasurand.POWER_ACTIVE_IMPORT,
         evseId,
-        MeterValuePhase.L1_N
+        MeterValuePhase.L1_N,
+        connectorLocalFallback
       ),
       L2: getSampledValueTemplate(
         chargingStation,
@@ -521,7 +600,8 @@ const buildPowerMeasurandValue = (
         measurandsKey,
         MeterValueMeasurand.POWER_ACTIVE_IMPORT,
         evseId,
-        MeterValuePhase.L2_N
+        MeterValuePhase.L2_N,
+        connectorLocalFallback
       ),
       L3: getSampledValueTemplate(
         chargingStation,
@@ -529,17 +609,27 @@ const buildPowerMeasurandValue = (
         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()
@@ -702,14 +792,18 @@ const buildCurrentMeasurandValue = (
   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
@@ -724,7 +818,8 @@ const buildCurrentMeasurandValue = (
         measurandsKey,
         MeterValueMeasurand.CURRENT_IMPORT,
         evseId,
-        MeterValuePhase.L1
+        MeterValuePhase.L1,
+        connectorLocalFallback
       ),
       L2: getSampledValueTemplate(
         chargingStation,
@@ -732,7 +827,8 @@ const buildCurrentMeasurandValue = (
         measurandsKey,
         MeterValueMeasurand.CURRENT_IMPORT,
         evseId,
-        MeterValuePhase.L2
+        MeterValuePhase.L2,
+        connectorLocalFallback
       ),
       L3: getSampledValueTemplate(
         chargingStation,
@@ -740,15 +836,25 @@ const buildCurrentMeasurandValue = (
         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
 
@@ -908,12 +1014,41 @@ export const buildEmptyMeterValue = (): MeterValue => ({
   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
@@ -928,22 +1063,34 @@ interface VersionedSampledValueDispatch {
 
 /**
  * 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) {
@@ -956,10 +1103,11 @@ const createVersionedSampledValueDispatcher = (
         )
       }
       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,
@@ -970,13 +1118,18 @@ const createVersionedSampledValueDispatcher = (
       {
         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,
@@ -985,11 +1138,11 @@ const createVersionedSampledValueDispatcher = (
           } 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
             )
           }
@@ -1021,17 +1174,19 @@ const createVersionedSampledValueDispatcher = (
                 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}`
               )
             }
           }
@@ -1052,9 +1207,16 @@ const createVersionedSampledValueDispatcher = (
           )
           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:
@@ -1065,7 +1227,14 @@ const createVersionedSampledValueDispatcher = (
         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).
@@ -1130,10 +1299,7 @@ const resolveEnabledMeasurands = (
   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)) {
@@ -1151,6 +1317,628 @@ const resolveEnabledMeasurands = (
   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
@@ -1159,6 +1947,7 @@ const resolveEnabledMeasurands = (
  * @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 = (
@@ -1167,76 +1956,291 @@ 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(
@@ -1261,7 +2265,9 @@ export const buildMeterValue = (
         buildVersionedSampledValue,
         measurandsKey,
         context,
-        voltageMeasurand.value
+        voltageMeasurand.value,
+        evseId,
+        snapshot
       )
       if (chargingStation.stationInfo?.phaseLineToLineVoltageMeterValues === true) {
         const nextPhase =
@@ -1284,25 +2290,33 @@ export const buildMeterValue = (
           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)
@@ -1357,11 +2371,15 @@ export const buildMeterValue = (
     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(
@@ -1370,7 +2388,7 @@ export const buildMeterValue = (
           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(
@@ -1420,49 +2438,263 @@ export const buildMeterValue = (
     }
   }
   // 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
 }
 
@@ -1539,6 +2771,7 @@ const isMeasurandSupported = (measurand: MeterValueMeasurand): boolean => {
  * @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 = (
@@ -1547,7 +2780,8 @@ 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)) {
@@ -1571,6 +2805,8 @@ export const getSampledValueTemplate = (
     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()) {
@@ -1666,6 +2902,13 @@ export const resolveSampledValueFields = (
   }
 }
 
+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 => {
index dc630018334ba358126842c47ea06cfcd8a1cbc5..318537dcc1d6d82a11b18577d91d821f971e1b07 100644 (file)
@@ -2,11 +2,86 @@ import { createPublicKey } from 'node:crypto'
 
 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
index 00446d7ec737b4e438cc18a953f2c9c86c22787f..1754f0df166e79a8f3a06869908b5705335067b0 100644 (file)
@@ -21,7 +21,11 @@ export {
   type SignedMeterDataParams,
 } from './OCPPSignedMeterDataGenerator.js'
 export {
+  claimPublicKeyDelivery,
+  getOCPP16SignedMeterValuePublicKey,
   parsePublicKeyWithSignedMeterValue,
+  releasePublicKeyDelivery,
+  retainPublicKeyDelivery,
   type SampledValueSigningConfig,
   shouldIncludePublicKey,
   type SignedSampledValueResult,
index ff564a547c7604b6d85a15b74c522f0050699584..995e3193ef754d999555fab8bda6e43a58468cc3 100644 (file)
@@ -19,6 +19,7 @@ export interface ConnectorStatus {
   availability: AvailabilityType
   bootStatus?: ConnectorStatusEnum
   chargingProfiles?: ChargingProfile[]
+  energyActiveImportIntervalBaselines?: Record<string, number> // In Wh
   energyActiveImportRegisterValue?: number // In Wh
   errorCode?: OCPP16ChargePointErrorCode
   idTagAuthorized?: boolean
@@ -27,6 +28,7 @@ export interface ConnectorStatus {
   locked?: boolean
   maximumPower?: number // In W
   MeterValues: SampledValueTemplate[]
+  postTransactionDelayTransactionId?: number | string
   publicKeySentInTransaction?: boolean
   remoteStartId?: number
   reservation?: Reservation
@@ -36,6 +38,10 @@ export interface ConnectorStatus {
   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
@@ -45,15 +51,29 @@ export interface ConnectorStatus {
   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
 }
index 680785bde3c29915178e7e631a78fcf0b365ac1b..565bd84c178fa2f85093f3ad5620a8b3008ccfaf 100644 (file)
@@ -21,6 +21,9 @@ export interface EvseEntryData {
 export interface EvseStatus {
   availability: AvailabilityType
   connectors: Map<number, ConnectorStatus>
+  energyActiveImportIntervalBaseline?: number // In Wh
+  energyActiveImportRegisterLastUpdatedAt?: Date
+  energyActiveImportRegisterValue?: number // In Wh
   MeterValues?: SampledValueTemplate[]
 }
 
index d371d6cef630f8bc9f57d9674bbc3444cda01d64..62576476f8345e91b176e7c16b0b89633c2c3342 100644 (file)
@@ -44,7 +44,7 @@ export {
   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'
@@ -391,6 +391,8 @@ export {
   MessageTrigger,
   type MeterValuesRequest,
   type OutgoingRequest,
+  type PendingRequestCancellationCallback,
+  type PendingRequestCancellationOptions,
   RequestCommand,
   type RequestParams,
   type ResponseCallback,
index daed85f6429791183a8cffb7325d3f668b7dfdf9..9af27c82b7379c534b2a8471872205ff1d24b8dd 100644 (file)
@@ -286,6 +286,7 @@ export interface OCPP20TransactionEventOptions extends JsonObject {
   remoteStartId?: number
   reservationId?: number
   stoppedReason?: OCPP20ReasonEnumType
+  timestamp?: Date
   transactionId?: string
   triggerReason?: OCPP20TriggerReasonEnumType
 }
index 9b7f5fde66f1861d5ea0461fcfada2fa20fbf9ec..8d781c540c4da92c8a23e946afbb9e3437c1ea1c 100644 (file)
@@ -48,6 +48,7 @@ export enum OCPP20OptionalVariableName {
   RetryBackOffRandomRange = 'RetryBackOffRandomRange',
   RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes',
   RetryBackOffWaitMinimum = 'RetryBackOffWaitMinimum',
+  SendDuringIdle = 'SendDuringIdle',
   SignReadings = 'SignReadings',
   ValueSize = 'ValueSize',
   WebSocketPingInterval = 'WebSocketPingInterval',
index 5780ee7ded56ba889a6717f53769ce948a41a8a5..c2df63d0133da72117859116cb9d9ffa24f4bf2e 100644 (file)
@@ -44,7 +44,11 @@ export type CachedRequest = [
   ResponseCallback,
   ErrorCallback,
   IncomingRequestCommand | RequestCommand,
-  JsonType
+  JsonType,
+  PendingRequestCancellationCallback?,
+  (() => void)?,
+  (() => void)?,
+  number?
 ]
 
 export type DataTransferRequest = OCPP16DataTransferRequest | OCPP20DataTransferRequest
@@ -63,6 +67,23 @@ export type IncomingRequest = [MessageType.CALL_MESSAGE, string, IncomingRequest
 
 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,
@@ -83,10 +104,32 @@ export const RequestCommand = {
 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 = {
index cb73c5453efba764d51a1751d026f3f001f255f9..dfb95c79cde604d46b523d7dbf32f065fe5a66ec 100644 (file)
@@ -1,12 +1,57 @@
 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) {
@@ -40,8 +85,10 @@ export const buildConnectorEntries = (chargingStation: ChargingStation): Connect
       ({
         connectorId,
         connectorStatus: {
+          postTransactionDelayTransactionId,
           transactionEndedMeterValues,
           transactionEndedMeterValuesSetInterval,
+          transactionEnding,
           transactionEventQueue,
           transactionUpdatedMeterValuesSetInterval,
           ...connectorStatus
@@ -67,13 +114,53 @@ export const buildConnectorsStatus = (
       ({
         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()
 }
@@ -89,13 +176,31 @@ export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntryDat
           ([
             connectorId,
             {
+              postTransactionDelayTransactionId,
               transactionEndedMeterValues,
               transactionEndedMeterValuesSetInterval,
+              transactionEnding,
+              transactionEnergyActiveImportIntervalBaselines,
+              transactionEnergyActiveImportIntervalCarry,
               transactionEventQueue,
+              transactionRestored,
+              transactionStarting,
               transactionUpdatedMeterValuesSetInterval,
               ...connectorStatus
             },
-          ]) => ({ connectorId, connectorStatus, evseId })
+          ]) => ({
+            connectorId,
+            connectorStatus: {
+              ...connectorStatus,
+              ...buildPersistentTransactionEnergyIntervalState(
+                transactionEnergyActiveImportIntervalBaselines
+              ),
+              ...buildPersistentTransactionEnergyIntervalCarry(
+                transactionEnergyActiveImportIntervalCarry
+              ),
+            },
+            evseId,
+          })
         ),
       },
     }))
@@ -114,15 +219,55 @@ export const buildEvsesStatus = (
         ([
           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,
         {
index 7e277aa854a9c042191bccfb2ce170af911ada80..0a492ff3c936539a2404ee68112e355b13135811 100644 (file)
@@ -14,6 +14,8 @@ const DAY_IN_SECONDS = 86_400
 
 // 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,
@@ -137,11 +139,19 @@ export class Constants {
 
   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
 
index eba731ef408737bfba2108f90c3cf3293fd6194c..300a68d9f3f0fccb28996349ca2e1a2f11b5dcc6 100644 (file)
@@ -6,6 +6,7 @@ export {
   buildConnectorsStatus,
   buildEvseEntries,
   buildEvsesStatus,
+  buildPersistentTransactionEnergyIntervalState,
 } from './ChargingStationConfigurationUtils.js'
 export { Configuration } from './Configuration.js'
 export {
index a7044c6d694f478c7088b7692111d48ef047d4a1..c52fb8a031c9c57ada070d4824d4df3ed9e91372 100644 (file)
@@ -8,14 +8,17 @@
  * 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'
 
@@ -25,6 +28,8 @@ interface TemplateOverrides {
   connectorMaximumPower?: number
   conversionEfficiency?: number
   currentOutType?: string
+  numberOfPhases?: number
+  ocppVersion?: OCPPVersion
 }
 
 // Fresh DC template with powerSharedByConnectors:false (deterministic
@@ -36,18 +41,35 @@ interface TemplateOverrides {
 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',
@@ -108,6 +130,114 @@ await describe('ChargingStation AC/DC conversion efficiency', async () => {
     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 })
index 4a299f9742f2fdcc352a457241addf98db97d01d..904a3987366d6ee8fd9de37fa1a14516a4ee5197 100644 (file)
@@ -3,12 +3,83 @@
  * @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 () => {
@@ -39,6 +110,28 @@ await describe('ChargingStation Lifecycle', 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 })
@@ -104,16 +197,2766 @@ await describe('ChargingStation Lifecycle', async () => {
       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 })
@@ -161,6 +3004,172 @@ await describe('ChargingStation Lifecycle', async () => {
       // 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 () => {
@@ -176,6 +3185,244 @@ await describe('ChargingStation Lifecycle', 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 })
@@ -191,6 +3438,36 @@ await describe('ChargingStation Lifecycle', async () => {
       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 })
@@ -206,6 +3483,369 @@ await describe('ChargingStation Lifecycle', async () => {
       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 })
index 21e855a64304037d149835ed025eca0b99a73d68..285fb93c3e271aad9b3c099e2edaefaa39557a1b 100644 (file)
@@ -10,26 +10,57 @@ import { WebSocket } from 'ws'
 
 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 = () => {
@@ -37,7 +68,8 @@ const makeStation = (): { reconnectCount: () => number; station: ChargingStation
     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 () => {
@@ -47,25 +79,207 @@ 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)
+    })
+  })
 })
index fae0ae75ac70d9693cee71df824d075f74938fdd..d861e177126e7db451916635062849ee68ba7009 100644 (file)
@@ -361,6 +361,31 @@ await describe('ChargingStation Transaction Management', async () => {
       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 })
@@ -668,6 +693,27 @@ await describe('ChargingStation Transaction Management', async () => {
       })
     })
 
+    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
index 540c052ce9c8af8150f0e0b8e1f08a623ed40ffa..5f44a71aa05c74f85746978fb36606989abfb2bd 100644 (file)
@@ -915,6 +915,7 @@ await describe('Helpers', async () => {
         transactionSeqNo: 5,
         transactionStart: new Date(),
         transactionStarted: true,
+        transactionStartedExhaustedTransactionId: 'tx-123',
       }
 
       resetConnectorStatus(connectorStatus)
@@ -929,6 +930,7 @@ await describe('Helpers', async () => {
       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)
@@ -974,16 +976,22 @@ await describe('Helpers', async () => {
     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)
     })
   })
 
diff --git a/tests/charging-station/TransactionEventQueueUtils.test.ts b/tests/charging-station/TransactionEventQueueUtils.test.ts
new file mode 100644 (file)
index 0000000..b8009ca
--- /dev/null
@@ -0,0 +1,1493 @@
+/**
+ * @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)
+  })
+})
diff --git a/tests/charging-station/TransactionIntervalUtils.test.ts b/tests/charging-station/TransactionIntervalUtils.test.ts
new file mode 100644 (file)
index 0000000..32c3acf
--- /dev/null
@@ -0,0 +1,130 @@
+/**
+ * @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)
+  })
+})
index aa90e11b9f4ac8fd10f83a1fd4bf535dba88ffab..d5a3678ac95f1f1354405aaeeeda33fb801ec65d 100644 (file)
@@ -10,26 +10,60 @@ import { randomUUID } from 'node:crypto'
 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'
 
 // ============================================================================
@@ -64,6 +98,7 @@ interface TestableWorkerBroadcastChannel {
     commandResponse: unknown
   ) => ResponseStatus
   requestHandler: (messageEvent: { data: unknown }) => void
+  requestParams: RequestParams
 }
 
 /**
@@ -80,6 +115,7 @@ function createTestableWorkerBroadcastChannel (
     commandHandlers: testable.commandHandlers,
     commandResponseToResponseStatus: testable.commandResponseToResponseStatus.bind(instance),
     requestHandler: testable.requestHandler.bind(instance),
+    requestParams: testable.requestParams,
   }
 }
 
@@ -549,6 +585,86 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
     }
   })
 
+  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 () => {
@@ -728,6 +844,1154 @@ await describe('ChargingStationWorkerBroadcastChannel', 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
   // ==========================================================================
@@ -847,6 +2111,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
           },
         ]
         connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING
+        connectorStatus.transactionStarted = true
       }
 
       instance = new ChargingStationWorkerBroadcastChannel(station)
@@ -864,6 +2129,7 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
 
       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'
@@ -873,6 +2139,167 @@ await describe('ChargingStationWorkerBroadcastChannel', async () => {
         '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 () => {
index 3411f1c9b999986f8f8f8f708fc395040b85bdd8..8caa6620dbf62391c8a122f576ef2d978df2a014 100644 (file)
@@ -27,6 +27,8 @@ import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js'
  * ```
  */
 export function cleanupChargingStation (station: ChargingStation): void {
+  if (typeof station.stopAlignedMeterValues === 'function') station.stopAlignedMeterValues()
+
   // Stop heartbeat timer
   if (station.heartbeatSetInterval != null) {
     clearInterval(station.heartbeatSetInterval)
index a7aee78d72a1d000310dbf63101d97fd294a5f8b..d760c9163afd57f8dba249f76f371740157d90dc 100644 (file)
@@ -2,8 +2,9 @@
  * @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,
@@ -20,9 +21,13 @@ import type {
 } 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,
@@ -37,6 +42,22 @@ import { MockIdTagsCache, MockSharedLRUCache } from '../mocks/MockCaches.js'
 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
  *
@@ -147,7 +168,7 @@ export function createMockChargingStation (
   }
 
   // Create requests map
-  const requests = new Map<string, unknown>()
+  const requests = new Map<string, CachedRequest>()
 
   // Create the station object that mimics ChargingStation
   const station = {
@@ -156,6 +177,7 @@ export function createMockChargingStation (
     __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(
@@ -170,8 +192,8 @@ export function createMockChargingStation (
         connectorStatus.reservation = reservation as unknown as Reservation
       }
     },
-    automaticTransactionGenerator: undefined,
 
+    automaticTransactionGenerator: undefined,
     bootNotificationRequest: undefined,
 
     bootNotificationResponse: {
@@ -185,8 +207,54 @@ export function createMockChargingStation (
         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) {
@@ -196,8 +264,8 @@ export function createMockChargingStation (
     },
     // Coherent MeterValues session store (real class uses a private Map).
     coherentSessions: new Map<number | string, CoherentSession>(),
-
     connectors,
+
     createCoherentSession (
       _transactionId: number | string,
       _connectorId: number
@@ -209,28 +277,56 @@ export function createMockChargingStation (
       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)
     },
@@ -248,30 +344,50 @@ export function createMockChargingStation (
         ({ 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
@@ -304,7 +420,7 @@ export function createMockChargingStation (
       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(
@@ -324,11 +440,14 @@ export function createMockChargingStation (
       )?.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)
@@ -384,6 +503,10 @@ export function createMockChargingStation (
       return reservation == null
     },
 
+    isStopping (): boolean {
+      return this.stopping
+    },
+
     isWebSocketConnectionOpened (): boolean {
       return this.wsConnection?.readyState === WebSocketReadyState.OPEN
     },
@@ -412,6 +535,8 @@ export function createMockChargingStation (
       }
     },
 
+    lifecycleAbortSignal: new AbortController().signal,
+
     listenerCount: () => 0,
 
     lockConnector (connectorId: number): void {
@@ -453,8 +578,25 @@ export function createMockChargingStation (
       },
       ...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(
@@ -476,6 +618,7 @@ export function createMockChargingStation (
           )
         )
       },
+      validateRequestPayload: () => true,
       ...ocppRequestService,
     },
 
@@ -487,10 +630,95 @@ export function createMockChargingStation (
 
     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 {
@@ -499,8 +727,27 @@ export function createMockChargingStation (
         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()
@@ -510,9 +757,43 @@ export function createMockChargingStation (
       /* 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
@@ -558,6 +839,10 @@ export function createMockChargingStation (
       }
     },
 
+    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)
index 7384a06ef6b897fcbf162de5b2f568871a7b1ff1..57bf95a09164e16888863293d35a6b70eae0e05b 100644 (file)
@@ -146,7 +146,10 @@ export interface MockOCPPIncomingRequestService {
  * 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
 }
index e0f082c3555d085a61af14def9696465175be6b1..9dba091d17fa6760d8727f5cb606894d8e18c116 100644 (file)
@@ -20,11 +20,15 @@ import type {
 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,
@@ -34,9 +38,11 @@ import {
   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,
@@ -63,8 +69,9 @@ const baseProfile: EvProfile = {
 /**
  * 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
@@ -84,6 +91,7 @@ const baseProfile: EvProfile = {
  */
 const buildContext = (
   overrides: {
+    conversionEfficiency?: number
     currentType?: CurrentType
     evseMaxPowerW?: number
     evseMeterValues?: SampledValueTemplate[]
@@ -95,6 +103,7 @@ const buildContext = (
 ): {
   connectorStatus: ConnectorStatus
   context: ICoherentContext
+  mainConnectorStatus: ConnectorStatus
   sessions: Map<number | string, CoherentSession>
   stationInfo: ChargingStationInfo
 } => {
@@ -107,6 +116,9 @@ const buildContext = (
     chargePointModel: 'model',
     chargePointVendor: 'vendor',
     coherentMeterValues: true,
+    ...(overrides.conversionEfficiency != null && {
+      conversionEfficiency: overrides.conversionEfficiency,
+    }),
     currentOutType: overrides.currentType ?? CurrentType.AC,
     hashId: 'hash-1',
     numberOfPhases,
@@ -125,10 +137,20 @@ const buildContext = (
     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.
@@ -138,6 +160,12 @@ const buildContext = (
       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)
@@ -163,7 +191,7 @@ const buildContext = (
     logPrefix: () => '[test]',
     stationInfo,
   }
-  return { connectorStatus, context, sessions, stationInfo }
+  return { connectorStatus, context, mainConnectorStatus, sessions, stationInfo }
 }
 
 const templatesFor = (
@@ -212,6 +240,61 @@ const createSessionOrFail = (
   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()
@@ -237,7 +320,7 @@ await describe('CoherentMeterValues', async () => {
   })
 
   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,
@@ -269,7 +352,7 @@ await describe('CoherentMeterValues', async () => {
   })
 
   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,
@@ -300,7 +383,7 @@ await describe('CoherentMeterValues', async () => {
   })
 
   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,
@@ -440,6 +523,396 @@ await describe('CoherentMeterValues', async () => {
       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 () => {
@@ -1382,6 +1855,131 @@ await describe('CoherentMeterValues', 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 () => {
index 9d42e4a871e56b9ebd3feb45c7f9746cbe4b7d17..64b89e51409732f43b342c09747185343000aee9 100644 (file)
 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,
@@ -267,6 +268,15 @@ await describe('StrategyDispatch', async () => {
         '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,
@@ -279,13 +289,18 @@ await describe('StrategyDispatch', async () => {
       )
       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],
+        ]
       )
     })
 
index 42c3ca8764fc07b1e9e1e9cd14335e9da979e028..b13e9c6801958027c19ff79b158fd4367904a936 100644 (file)
@@ -12,10 +12,15 @@ import type { ChargingStation } from '../../../../src/charging-station/index.js'
 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'
@@ -30,7 +35,9 @@ import {
   createOCPP16IncomingRequestTestContext,
   createOCPP16ListenerStation,
   createOCPP16NonContiguousConnectorsContext,
+  createOCPP16RequestTestContext,
   type OCPP16IncomingRequestTestContext,
+  setMockRequestHandler,
 } from './OCPP16TestUtils.js'
 
 await describe('OCPP16IncomingRequestService — RemoteStartTransaction', async () => {
@@ -311,6 +318,81 @@ 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 () => {
@@ -364,6 +446,48 @@ await describe('OCPP16IncomingRequestService — RemoteStartTransaction', 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 = {
index 62e192c661609afe99797917f4cb807ecf65113d..0ce5e9bca83cb5b9f4e8e92535a7a049928abfcf 100644 (file)
@@ -161,6 +161,19 @@ await describe('OCPP16IncomingRequestService — RemoteStopTransaction and Unloc
       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, {
index 0d4254f6b33e1dbd518085b1fefc3901d1624e5b..1063f7596d1652268dacb39eb6c4e3eb847ce03e 100644 (file)
@@ -5,24 +5,26 @@
  */
 
 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,
@@ -65,7 +67,13 @@ await describe('OCPP16IncomingRequestService — Reservation', async () => {
   let context: OCPP16IncomingRequestTestContext
 
   beforeEach(() => {
-    context = createOCPP16IncomingRequestTestContext()
+    context = createOCPP16IncomingRequestTestContext({
+      stationInfo: {
+        commandsSupport: createCommandsSupport({
+          incomingCommands: { [OCPP16IncomingRequestCommand.CANCEL_RESERVATION]: true },
+        }),
+      },
+    })
   })
 
   afterEach(() => {
@@ -228,6 +236,105 @@ await describe('OCPP16IncomingRequestService — Reservation', async () => {
       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
index dfc2e367b7033f1a88324a39dc1b9ad2c960ca98..1eeab0b4946ad52d64eea6f25315c3efe3d73683 100644 (file)
@@ -4,11 +4,15 @@
  */
 
 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,
@@ -110,6 +114,73 @@ await describe('OCPP16IncomingRequestService — Reset', async () => {
     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
index 0e8972c8952d4c5292b2d55fbd8346021c649374..0b7678476370a82ea6675b20c3776692da1805b7 100644 (file)
@@ -17,19 +17,33 @@ import type {
   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 {
@@ -37,9 +51,13 @@ 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,
@@ -78,9 +96,17 @@ const emitAcceptedTrigger = (
   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)
 }
 
@@ -152,6 +178,7 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
     await it('should return Accepted for MeterValues trigger', () => {
       // Arrange
       const { station, testableService } = context
+      enableConnectorMeterValues(station, 1)
 
       // Act
       const response = testableService.handleRequestTriggerMessage(station, {
@@ -164,7 +191,841 @@ await describe('OCPP16IncomingRequestService — TriggerMessage', async () => {
     })
   })
 
+  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,
index cbba4b0d330676bbc86deb226a0dfdad257e290d..fb1a676232691e3fe8b6c2099ac4eafba21423cb 100644 (file)
@@ -12,7 +12,7 @@
 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,
@@ -24,6 +24,7 @@ import type {
 } 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 {
@@ -195,6 +196,7 @@ await describe('OCPP16CoherentMeterValues', async () => {
   beforeEach(() => {
     const ctx = createOCPP16ResponseTestContext()
     station = ctx.station
+    station.isStopping = () => false
     responseService = ctx.responseService
     setMockRequestHandler(station, async () => Promise.resolve({}))
     mock.method(OCPP16ServiceUtils, 'startUpdatedMeterValues', () => {
@@ -294,6 +296,7 @@ await describe('OCPP16CoherentMeterValues', async () => {
     // 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)) {
index d7cac311d508248ad3889d7fb6b8537da796ec92..025a6af427a6f801aaf792e935e9ad3aee0c94ae 100644 (file)
@@ -64,6 +64,7 @@ function createIntegrationContext (): {
     },
     websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
   })
+  station.isStopping = () => false
 
   // IncomingRequest service (handles RemoteStart/Stop from CSMS)
   const incomingRequestService = new OCPP16IncomingRequestService()
index 04b83190a0d91047cf2144c7f37fa8d9f2d72548..90671bc98bd3701ac81d0c611b4cb29e77881aad 100644 (file)
@@ -297,5 +297,40 @@ await describe('OCPP16RequestService — buildRequestPayload', async () => {
       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)
+    })
   })
 })
index d9e0019329f45d92f5d8a8a33f5c464a5361ddfa..a988fd11cde0512e43ed232f664b38dc683351cf 100644 (file)
@@ -18,6 +18,7 @@ import type {
 import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
 import {
   AvailabilityType,
+  ChargingStationEvents,
   OCPP16AuthorizationStatus,
   OCPP16ChargePointStatus,
   OCPP16RequestCommand,
@@ -42,6 +43,12 @@ await describe('OCPP16ResponseService — PostTransactionDelay', async () => {
     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[]) => {
@@ -208,8 +215,13 @@ await describe('OCPP16ResponseService — PostTransactionDelay', async () => {
     )
   })
 
-  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,
@@ -222,21 +234,54 @@ await describe('OCPP16ResponseService — PostTransactionDelay', async () => {
 
     // 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
@@ -254,7 +299,98 @@ await describe('OCPP16ResponseService — PostTransactionDelay', async () => {
     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'
+    )
   })
 })
index 4e388cd286921a4b34a83ed183bcfdf131d7c81a..16f1c746a795e4b9555c66151989ba4dc0217a30 100644 (file)
@@ -37,6 +37,7 @@ await describe('OCPP16ResponseService — StartTransaction and StopTransaction',
   beforeEach(() => {
     const ctx = createOCPP16ResponseTestContext()
     station = ctx.station
+    station.isStopping = () => false
     responseService = ctx.responseService
 
     // Mock requestHandler so OCPP requests (StatusNotification, MeterValues) resolve
index 23371f692ecbbf957546c4314f878dfa796dab39..672ecff2b78a6edc76a42b8bfb633c6f0922ddd6 100644 (file)
@@ -10,8 +10,10 @@ import { afterEach, describe, it } from 'node:test'
 
 import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
 import {
+  CurrentType,
   type OCPP16MeterValue,
   OCPP16MeterValueContext,
+  OCPP16MeterValueLocation,
   OCPP16MeterValueMeasurand,
   OCPP16MeterValueUnit,
   OCPPVersion,
@@ -107,6 +109,84 @@ await describe('OCPP16ServiceUtils — MeterValues', async () => {
       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({
@@ -252,5 +332,83 @@ await describe('OCPP16ServiceUtils — MeterValues', async () => {
       // 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')
+    })
   })
 })
index b6975cddd6e3ebf43c952536f8ffe3abc4866d36..cac239b72b9e0d28baed1ab00fae4c37dc3cf7c1 100644 (file)
@@ -7,20 +7,23 @@
 
 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,
@@ -195,6 +198,54 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => {
       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')
 
@@ -293,6 +344,36 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => {
       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) {
@@ -492,6 +573,55 @@ await describe('OCPP 1.6 — Signed MeterValues', async () => {
         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 ───────────────────────────────────────────────────
index 0acdbe075d3887baca2a0b59e06112c5d0eaf021..fc92df8b11ad92855d02a250b1a63af56c889b07 100644 (file)
@@ -8,8 +8,9 @@
  */
 
 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,
@@ -19,8 +20,11 @@ import {
   isIncomingRequestCommandSupported,
   isRequestCommandSupported,
 } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import { OCPPError } from '../../../../src/exception/index.js'
 import {
   ChargePointErrorCode,
+  CurrentType,
+  ErrorType,
   OCPP16AuthorizationStatus,
   OCPP16ChargePointStatus,
   type OCPP16ChargingProfile,
@@ -31,17 +35,40 @@ import {
   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(() => {
@@ -634,6 +661,2072 @@ await describe('OCPP16ServiceUtils — pure functions', async () => {
     })
   })
 
+  // ─── 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 () => {
index 2e7f4299db68d8842f4c60378064f9642ef53834..2c4ded89842ee7a0c187056da7320733c9be3019 100644 (file)
@@ -18,6 +18,7 @@ import {
   type OCPP20CertificateSignedRequest,
   type OCPP20CertificateSignedResponse,
   OCPP20ComponentName,
+  OCPP20IncomingRequestCommand,
   OCPP20OptionalVariableName,
   OCPP20RequestCommand,
   OCPPVersion,
@@ -90,6 +91,96 @@ await describe('I04 - CertificateSigned', async () => {
       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,
index b1bcf9046600803e22fc1c931c9da00a2cd93bc7..2691fc6379f7e10a8e689fa9dc545678768d1ddf 100644 (file)
@@ -14,6 +14,7 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import {
   ChangeAvailabilityStatusEnumType,
+  ConnectorStatusEnum,
   OCPP20OperationalStatusEnumType,
   OCPP20RequestCommand,
   ReasonCodeEnumType,
@@ -24,6 +25,7 @@ import {
   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 () => {
@@ -62,6 +64,100 @@ 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, {
index 0831fe92c7845d0768f58ffd5f1be7c193f12bbf..37939e58e1549a662d827803f0abb9b55552fa87 100644 (file)
@@ -122,6 +122,25 @@ await describe('OCPP20IncomingRequestService — changeConfiguration seam', asyn
     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')
 
index 7c6ed1bbe380e89389c4bf683ab57a2f7250ab95..860017515abc188a5345a43c5d6571cfaf73b3f1 100644 (file)
@@ -7,6 +7,7 @@ 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 { RequestParams } from '../../../../src/types/index.js'
 
 import {
   addConfigurationKey,
@@ -43,6 +44,7 @@ import {
   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
@@ -146,6 +148,62 @@ await describe('B07 - Get Base Report', async () => {
     }
   })
 
+  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 = {
@@ -361,6 +419,210 @@ await describe('B07 - Get Base Report', async () => {
     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>
index a87938d5dfa9477ac2cc559fda51e8373e527db7..9f131f4a85db30297e8d5c83aa869ec656444106 100644 (file)
@@ -4,20 +4,36 @@
  */
 
 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
@@ -92,4 +108,205 @@ await describe('D14 - GetTransactionStatus', async () => {
     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)
+    })
+  })
 })
index f5d051e2542adb62c806c8605075322505e9bb3e..306264afa8b95eba93d9a1d8e0d87131b3aa0306 100644 (file)
@@ -17,7 +17,12 @@ import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 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,
@@ -41,6 +46,7 @@ interface OCPP20StationStateShape {
 }
 
 interface PlumbingAccess {
+  activate: (chargingStation: ChargingStation, lifecycleSignal: AbortSignal) => void
   createStationState: () => OCPP20StationStateShape
   getOrCreateStationState: (chargingStation: ChargingStation) => OCPP20StationStateShape
   sendNotifyReportRequest: (
@@ -179,6 +185,129 @@ await describe('OCPP20IncomingRequestService — post-stop resurrection guard',
       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)
index 6fc458f3b3beefb7e2520783c16544e0e24482a1..67dae5b3c99838f73eae32ce32786b367bb7e906 100644 (file)
@@ -1,24 +1,22 @@
-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,
@@ -27,6 +25,7 @@ import {
 } from '../../../../src/charging-station/ocpp/auth/index.js'
 import {
   AttributeEnumType,
+  ConnectorStatusEnum,
   OCPP20ChargingProfileKindEnumType,
   OCPP20ChargingProfilePurposeEnumType,
   OCPP20ChargingRateUnitEnumType,
@@ -45,13 +44,14 @@ import {
 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,
@@ -495,6 +495,90 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
     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) {
@@ -608,6 +692,309 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
     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
@@ -655,6 +1042,119 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
     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>
@@ -701,14 +1201,6 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       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,
@@ -717,7 +1209,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       listenerService.emit(
         OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
         listenerStation,
-        request,
+        startRequest,
         response
       )
 
@@ -733,6 +1225,172 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       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,
@@ -773,14 +1431,6 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       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,
@@ -789,7 +1439,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       listenerService.emit(
         OCPP20IncomingRequestCommand.REQUEST_START_TRANSACTION,
         listenerStation,
-        request,
+        startRequest,
         response
       )
 
@@ -804,10 +1454,7 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       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 () => {
@@ -836,27 +1483,24 @@ await describe('F01 & F02 - Remote Start Transaction', 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,
@@ -868,6 +1512,323 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       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)
index 61105e01fe91dbb92eb21c496de66a0f75b223b9..cfa13a64a17dbf78b01380476c8c036965e942f3 100644 (file)
@@ -3,10 +3,8 @@
  * @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 {
@@ -98,6 +96,36 @@ await describe('F03 - Remote Stop Transaction', async () => {
     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 () => {
@@ -111,6 +139,29 @@ await describe('F03 - Remote Stop 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)
@@ -284,7 +335,7 @@ await describe('F03 - Remote Stop Transaction', async () => {
 
         await flushMicrotasks()
 
-        assert.strictEqual(requestHandlerMock.mock.callCount(), 2)
+        assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
         const args = requestHandlerMock.mock.calls[0].arguments as [
           unknown,
           string,
@@ -294,6 +345,136 @@ await describe('F03 - Remote Stop Transaction', async () => {
       }
     )
 
+    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,
@@ -377,20 +558,25 @@ await describe('F03 - Remote Stop Transaction', async () => {
         } 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
@@ -415,6 +601,8 @@ await describe('F03 - Remote Stop Transaction', async () => {
         } satisfies OCPP20RequestStopTransactionResponse
       )
 
+      await flushMicrotasks()
+
       assert.strictEqual(requestHandlerMock.mock.callCount(), 1)
       const args = requestHandlerMock.mock.calls[0].arguments as [
         unknown,
index 4cefedd0947da5a502e2fa83169a74f187d6c917..f0b64b56522fd255a944406d39fec358b3f611d7 100644 (file)
@@ -19,11 +19,16 @@ import { VARIABLE_REGISTRY } from '../../../../src/charging-station/ocpp/2.0/OCP
 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'
 
@@ -49,13 +54,13 @@ await describe('B11 & B12 - Reset', async () => {
     })
 
     // 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
       )
@@ -71,13 +76,222 @@ await describe('B11 & B12 - Reset', async () => {
       )
     })
 
-    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
       )
@@ -95,12 +309,12 @@ await describe('B11 & B12 - Reset', async () => {
     })
 
     // 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
       )
@@ -115,12 +329,12 @@ await describe('B11 & B12 - Reset', async () => {
       }
     })
 
-    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
       )
@@ -129,7 +343,7 @@ await describe('B11 & B12 - Reset', async () => {
       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,
@@ -142,7 +356,7 @@ await describe('B11 & B12 - Reset', async () => {
         type: ResetEnumType.Immediate,
       }
 
-      const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+      const response: OCPP20ResetResponse = testableService.handleRequestReset(
         mockStation,
         resetRequest
       )
@@ -168,13 +382,13 @@ await describe('B11 & B12 - Reset', async () => {
       })
     })
 
-    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
       )
@@ -194,7 +408,7 @@ await describe('B11 & B12 - Reset', async () => {
     })
 
     // 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
 
@@ -202,7 +416,7 @@ await describe('B11 & B12 - Reset', async () => {
         type: ResetEnumType.Immediate,
       }
 
-      const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+      const response: OCPP20ResetResponse = testableService.handleRequestReset(
         mockStation,
         resetRequest
       )
@@ -212,7 +426,7 @@ await describe('B11 & B12 - Reset', async () => {
       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
 
@@ -220,7 +434,7 @@ await describe('B11 & B12 - Reset', async () => {
         type: ResetEnumType.OnIdle,
       }
 
-      const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+      const response: OCPP20ResetResponse = testableService.handleRequestReset(
         mockStation,
         resetRequest
       )
@@ -231,7 +445,7 @@ await describe('B11 & B12 - Reset', async () => {
     })
 
     // 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
 
@@ -240,7 +454,7 @@ await describe('B11 & B12 - Reset', async () => {
         type: ResetEnumType.Immediate,
       }
 
-      const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+      const response: OCPP20ResetResponse = testableService.handleRequestReset(
         mockStation,
         resetRequest
       )
@@ -252,7 +466,7 @@ await describe('B11 & B12 - Reset', async () => {
       )
     })
 
-    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,
@@ -266,7 +480,7 @@ await describe('B11 & B12 - Reset', async () => {
         type: ResetEnumType.Immediate,
       }
 
-      const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+      const response: OCPP20ResetResponse = testableService.handleRequestReset(
         mockStation,
         resetRequest
       )
@@ -302,7 +516,7 @@ await describe('B11 & B12 - Reset', async () => {
       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) {
@@ -316,7 +530,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -326,7 +540,7 @@ await describe('B11 & B12 - Reset', async () => {
           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) {
@@ -340,7 +554,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -350,7 +564,7 @@ await describe('B11 & B12 - Reset', async () => {
           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) {
@@ -364,7 +578,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -374,7 +588,7 @@ await describe('B11 & B12 - Reset', async () => {
           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) {
@@ -388,7 +602,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -397,7 +611,7 @@ await describe('B11 & B12 - Reset', async () => {
           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) {
@@ -411,7 +625,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -424,7 +638,7 @@ await describe('B11 & B12 - Reset', async () => {
       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)
@@ -446,7 +660,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -455,7 +669,7 @@ await describe('B11 & B12 - Reset', async () => {
           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)
@@ -477,7 +691,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -487,7 +701,7 @@ await describe('B11 & B12 - Reset', async () => {
           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)
 
@@ -495,7 +709,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -508,7 +722,7 @@ await describe('B11 & B12 - Reset', async () => {
       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
@@ -525,7 +739,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -534,7 +748,7 @@ await describe('B11 & B12 - Reset', async () => {
           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
@@ -556,7 +770,7 @@ await describe('B11 & B12 - Reset', async () => {
             type: ResetEnumType.OnIdle,
           }
 
-          const response: OCPP20ResetResponse = await testableService.handleRequestReset(
+          const response: OCPP20ResetResponse = testableService.handleRequestReset(
             station,
             resetRequest
           )
@@ -580,28 +794,28 @@ await describe('B11 & B12 - Reset', async () => {
       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)
     })
   })
index f4ebc3a596d9819de1fdb3f797dd051aa850e489..dce77bbcd2b8bd8967d74d15051b275ba733b714 100644 (file)
@@ -7,46 +7,128 @@ import assert from 'node:assert/strict'
 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.
@@ -71,6 +153,20 @@ function createTriggerMessageStation (): {
     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 }
 }
 
@@ -199,6 +295,75 @@ await describe('F06 - TriggerMessage', async () => {
       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,
@@ -323,6 +488,20 @@ await describe('F06 - TriggerMessage', async () => {
       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,
@@ -397,6 +576,84 @@ await describe('F06 - TriggerMessage', async () => {
       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(
@@ -406,6 +663,211 @@ await describe('F06 - TriggerMessage', async () => {
       )
     })
 
+    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,
@@ -484,13 +946,14 @@ await describe('F06 - TriggerMessage', async () => {
       })
     }
 
-    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,
@@ -498,6 +961,7 @@ await describe('F06 - TriggerMessage', async () => {
         request,
         response
       )
+      await flushMicrotasks()
 
       // F06.FR.06: TriggerMessage(MessageTrigger.MeterValues) without a
       // specific EVSE MUST emit one MeterValuesRequest per EVSE. Fixture has
@@ -532,9 +996,9 @@ await describe('F06 - TriggerMessage', async () => {
         )
         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)
       }
@@ -544,13 +1008,16 @@ await describe('F06 - TriggerMessage', async () => {
       )
     })
 
-    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,
@@ -558,40 +1025,38 @@ await describe('F06 - TriggerMessage', async () => {
         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,
@@ -599,78 +1064,775 @@ await describe('F06 - TriggerMessage', async () => {
         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 = {
@@ -836,29 +1998,30 @@ await describe('F06 - TriggerMessage', async () => {
      * @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
             ]
         )
@@ -885,6 +2048,253 @@ await describe('F06 - TriggerMessage', async () => {
       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')
 
@@ -946,17 +2356,18 @@ await describe('F06 - TriggerMessage', async () => {
       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')
@@ -970,12 +2381,68 @@ await describe('F06 - TriggerMessage', async () => {
         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.
@@ -983,28 +2450,31 @@ await describe('F06 - TriggerMessage', async () => {
         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]
@@ -1013,14 +2483,16 @@ await describe('F06 - TriggerMessage', async () => {
       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)
@@ -1028,8 +2500,8 @@ await describe('F06 - TriggerMessage', async () => {
       }
     })
 
-    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)
     })
@@ -1057,11 +2529,17 @@ await describe('F06 - TriggerMessage', async () => {
         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()
index e2d412a86d0ba479732832fed9cf706977429f0d..5759e725e6ec0d12084aabee98da772394006262 100644 (file)
@@ -156,10 +156,25 @@ await describe('OCPP 2.0 Request Call Chain — requestHandler → buildRequestP
       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,
       })
 
index eb5b7b8b66ab7c4e6f67498e9f3ce816fd39009d..0f04aee90b7ba10296075875feb2ce6954639fba 100644 (file)
@@ -1,19 +1,41 @@
 /**
- * @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,
@@ -27,7 +49,7 @@ import {
   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
 
@@ -176,4 +198,2992 @@ await describe('G02 - Heartbeat', async () => {
     // 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)
+  })
 })
index dff540b667b3053522052b464ac6ca646134fd6d..0aab4eb2f22775e9afaef2d9952e91aaca04a99b 100644 (file)
@@ -21,6 +21,7 @@ import {
   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,
@@ -65,6 +66,8 @@ await describe('OCPP20ResponseServiceCoherentSession', async () => {
     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()
@@ -86,6 +89,52 @@ await describe('OCPP20ResponseServiceCoherentSession', async () => {
     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 () => {
@@ -98,6 +147,29 @@ await describe('OCPP20ResponseServiceCoherentSession', 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 = {
index 7d67f8be3bb9716d274ddd6f358dfcc22b882d4c..9b6eb8d685ccc715e9fe5f066bf16f62189b2383 100644 (file)
@@ -66,7 +66,7 @@ await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issu
       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
@@ -80,12 +80,7 @@ await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issu
   })
 
   // 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(
@@ -121,11 +116,57 @@ await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issu
     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',
@@ -286,7 +327,7 @@ await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issu
       },
       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
@@ -381,7 +422,7 @@ await describe('OCPP20ResponseService — forceTransactionOnInvalidIdToken (issu
         },
         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
index 555745bd0bce21283b9a7a9529ff36de0bf67acf..537144241063fd380c33a228c2607b0957d17722 100644 (file)
@@ -8,21 +8,37 @@ 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 { 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'
@@ -85,6 +101,424 @@ await describe('D01 - TransactionEvent Response', async () => {
     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(
@@ -281,4 +715,80 @@ await describe('D01 - TransactionEvent Response', async () => {
     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)
+  })
 })
diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AlignedMeterValues.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-AlignedMeterValues.test.ts
new file mode 100644 (file)
index 0000000..94dc59e
--- /dev/null
@@ -0,0 +1,9872 @@
+/**
+ * @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)
+    })
+  })
+})
index f01b7748b072b57c4af4ea1bff3b37661a5bec63..785b2eadc285c0ff11488ddc498c666112c8e27e 100644 (file)
@@ -8,11 +8,17 @@
 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,
@@ -47,6 +53,7 @@ await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
     connectorStatus.transactionStarted = true
     connectorStatus.transactionId = 'tx-1'
     connectorStatus.locked = true
+    connectorStatus.status = ConnectorStatusEnum.Finishing
   })
 
   afterEach(() => {
@@ -56,7 +63,29 @@ await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
   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()
       }
@@ -78,6 +107,7 @@ await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
     // 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)
@@ -87,9 +117,113 @@ await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
     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)
@@ -104,10 +238,10 @@ await describe('OCPP20ServiceUtilsPostTransactionDelay', async () => {
       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,
index 9eed14c42eb4498b6c9a1b3d5892ccd4b67aad9a..2c2c61bdd095e7d9d8c9d3a4002c204a13116a51 100644 (file)
 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,
@@ -89,6 +123,28 @@ const TRANSACTION_FLOWS = [
   },
 ] 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
@@ -121,6 +177,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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)
@@ -128,6 +185,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         const transactionEvent = buildTransactionEvent(mockStation, {
           connectorId,
           eventType: OCPP20TransactionEventEnumType.Started,
+          timestamp,
           transactionId,
           triggerReason,
         })
@@ -136,7 +194,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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')
         }
@@ -151,6 +209,108 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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()
@@ -325,39 +485,293 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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)
       })
     })
 
@@ -385,6 +799,60 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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
 
@@ -497,41 +965,6 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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 () => {
@@ -1430,6 +1863,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', 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)
 
@@ -1452,6 +1886,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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 () => {
@@ -1548,9 +1983,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', 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 () => {
@@ -1601,12 +2035,44 @@ await describe('OCPP20 TransactionEvent ServiceUtils', 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)
@@ -1636,53 +2102,321 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         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,
@@ -1764,7 +2498,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           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
@@ -1792,10 +2526,10 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         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,
@@ -1805,17 +2539,50 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           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()
@@ -1913,15 +2680,17 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
     })
 
     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)
         })
@@ -1939,8 +2708,15 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           },
           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)
 
@@ -1968,729 +2744,8264 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           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)
     })
   })
 
@@ -2757,6 +11068,152 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       )
     })
 
+    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,
@@ -2865,6 +11322,96 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       )
     })
 
+    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()
index 3b411d4c6879930695d19a6e0c99df09f30993e4..5b48edd1f26f27850c62b72258d36ec84da5cd82 100644 (file)
@@ -188,6 +188,7 @@ export function createOCPP20RequestTestContext (
     },
     websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
   })
+  station.ocppRequestService = requestService
 
   return { requestService, station, testableRequestService }
 }
index 3ae07f5aff859adec5e92565b8cb36f829fb3032..89311232bc8c127afde74c86d2a618ed0a2a582d 100644 (file)
@@ -1576,8 +1576,12 @@ await describe('B05 - OCPP20VariableManager', async () => {
       // 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)}`
+        )
       }
     })
 
index 5177612fa3787117d661f1b5228a53b77c1d9ea3..f1f633483cb71a68d468354592f40273e5bb7886 100644 (file)
@@ -22,6 +22,7 @@ import {
   standardCleanup,
 } from '../../helpers/TestLifecycleHelpers.js'
 import { TEST_ID_TAG } from '../ChargingStationTestConstants.js'
+import { createConnectorStatus } from '../helpers/StationHelpers.js'
 
 await describe('OCPPConnectorStatusOperations', async () => {
   afterEach(() => {
@@ -169,5 +170,32 @@ await describe('OCPPConnectorStatusOperations', async () => {
       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,
+      })
+    })
   })
 })
index 48f8fdd9e3b2c4b0cad13a5a58223033b3ebf3cc..486e3ed68daf7c9305bd6e022d0f75f995f6d554 100644 (file)
@@ -32,10 +32,20 @@ interface OCPP16StationStateShape {
 }
 
 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 = (
@@ -101,6 +111,53 @@ await describe('OCPPIncomingRequestService — per-station state plumbing', asyn
     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')
index c62fc6f636a0318dad501ce26eec800db98771f9..1d2894aa760522bf5d4d12ff08e727d0704ad0f7 100644 (file)
@@ -8,6 +8,8 @@
 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,
@@ -21,13 +23,20 @@ import {
   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,
@@ -116,7 +125,7 @@ await describe('OCPPServiceOperations', async () => {
   })
 
   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,
@@ -134,6 +143,91 @@ await describe('OCPPServiceOperations', async () => {
       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({
@@ -142,10 +236,10 @@ await describe('OCPPServiceOperations', async () => {
         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' } })
       })
@@ -169,10 +263,10 @@ await describe('OCPPServiceOperations', async () => {
         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' } })
       })
@@ -268,6 +362,8 @@ await describe('OCPPServiceOperations', async () => {
         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)
@@ -290,6 +386,58 @@ await describe('OCPPServiceOperations', async () => {
       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)
+      }
     })
   })
 
index 0ad928ccd02fae2303ef9f1e6f840faf7f392395..c9d32dbdefbff8fad79b901ca3ba95f943560429 100644 (file)
@@ -9,6 +9,7 @@
  * - 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'
@@ -16,11 +17,20 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 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,
@@ -94,6 +104,28 @@ await describe('buildMeterValue', async () => {
       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),
@@ -174,6 +206,141 @@ await describe('buildMeterValue', async () => {
         '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
index 2109c6b1fac487b33fe4386924c42c6ed6c6e307..412c8b73421b248655c5f4033e5375da578c8078 100644 (file)
@@ -1,16 +1,20 @@
 /**
  * @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'
@@ -83,6 +87,43 @@ await describe('SignedMeterValueUtils', async () => {
     })
   })
 
+  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(
index 236a325ca0b61fcd74f1f375d169e5698294c013..bd5ba37d5bde2bd8cdfa7d2115d357f32c025583 100644 (file)
@@ -9,9 +9,33 @@ import assert from 'node:assert/strict'
 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,
@@ -36,6 +60,35 @@ interface MockStationInternals {
   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
 
@@ -71,6 +124,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
           MeterValues: [],
           transactionEndedMeterValues: [{ sampledValue: [], timestamp: new Date() }],
           transactionEndedMeterValuesSetInterval: interval2,
+          transactionEnding: true,
           transactionEventQueue: [],
           transactionUpdatedMeterValuesSetInterval: interval1,
         } as unknown as ConnectorStatus)
@@ -80,6 +134,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
         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))
@@ -109,6 +164,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
       internals.connectors.set(1, {
         availability: AvailabilityType.Operative,
         bootStatus: 'Available',
+        energyActiveImportIntervalBaselines: { 'station:AlignedDataCtrlr.Measurands': 12 },
         MeterValues: [],
         transactionEventQueue: undefined,
         transactionId: 42,
@@ -121,10 +177,63 @@ await describe('ChargingStationConfigurationUtils', async () => {
       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
@@ -161,7 +270,19 @@ await describe('ChargingStationConfigurationUtils', async () => {
       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,
       })
@@ -173,6 +294,9 @@ await describe('ChargingStationConfigurationUtils', async () => {
       internals.evses.set(1, {
         availability: AvailabilityType.Operative,
         connectors: evseConnectors,
+        energyActiveImportIntervalBaseline: 20,
+        energyActiveImportRegisterLastUpdatedAt: new Date(),
+        energyActiveImportRegisterValue: 25,
       })
 
       const result = buildEvsesStatus(station)
@@ -182,12 +306,40 @@ await describe('ChargingStationConfigurationUtils', async () => {
       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 },
@@ -196,13 +348,33 @@ await describe('ChargingStationConfigurationUtils', async () => {
       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,
       })
 
@@ -224,9 +396,1201 @@ await describe('ChargingStationConfigurationUtils', async () => {
       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', () => {