]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor: build version-agnostic OCPP transaction primitives in service layer (#1741)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 20 Mar 2026 17:07:17 +0000 (18:07 +0100)
committerGitHub <noreply@github.com>
Fri, 20 Mar 2026 17:07:17 +0000 (18:07 +0100)
* refactor: add StopTransactionResult type and OCPP 2.0 reason mapping

* refactor: extract OCPP 1.6 stopTransactionOnConnector to OCPP16ServiceUtils

* refactor: add unified stopTransactionOnConnector and stopRunningTransactions to OCPPServiceUtils

Add two version-dispatching functions to OCPPServiceUtils:

- stopTransactionOnConnector: dispatches to OCPP16ServiceUtils or
  OCPP20ServiceUtils via dynamic import, returns StopTransactionResult
- stopRunningTransactions: sequential for OCPP 1.6, parallel (Promise.all)
  for OCPP 2.0, includes transactionPending check for OCPP 2.0

Both use dynamic imports to avoid circular dependencies. stopRunningTransactions
is exposed as OCPPServiceUtils.stopRunningTransactions class member.
stopTransactionOnConnector is exported as standalone function only (class member
omitted due to OCPP16ServiceUtils override type conflict).

Includes 6 tests covering both OCPP versions and error cases.

* refactor: simplify ChargingStation and ATG to use OCPPServiceUtils unified methods

- ChargingStation.stopRunningTransactions: delegate to standalone function from OCPPServiceUtils
- Remove ChargingStation.stopRunningTransactionsOCPP20 private method (logic now in standalone function)
- Remove OCPP20ReasonEnumType import from ChargingStation (no longer needed)
- AutomaticTransactionGenerator.stopTransaction: call stopTransactionOnConnector standalone function
- Update ATG return type to Promise<StopTransactionResult | undefined>
- Use result.accepted instead of stopResponse.idTagInfo?.status === AuthorizationStatus.ACCEPTED
- Remove TODO comment from ATG.stopTransaction

* refactor: remove stopTransactionOnConnector from ChargingStation public API

* refactor: remove stopTransactionOnConnector from ChargingStation public API

- Remove ChargingStation.stopTransactionOnConnector public method
- Remove unused StopTransactionRequest, StopTransactionResponse, buildTransactionEndMeterValue, OCPP16ServiceUtils imports from ChargingStation
- Update OCPP16IncomingRequestService.handleRequestUnlockConnector to call OCPP16ServiceUtils.stopTransactionOnConnector directly
- Update test mocks in RemoteStopUnlock tests to target OCPP16ServiceUtils
- Fix OCPPServiceUtils-StopTransaction test: use args-based pattern for command detection, add proper JSDoc, fix type issues

* refactor(ocpp2.0): deduplicate stop pattern via stopAllTransactions

- Add OCPP20ServiceUtils.stopAllTransactions() — single factored function for parallel
  EVSE iteration + requestStopTransaction, supports optional evseId for single-EVSE scope
- terminateAllTransactions delegates to stopAllTransactions(station, ResetCommand, reason)
- terminateEvseTransactions delegates to stopAllTransactions(station, ResetCommand, reason, evseId)
- stopRunningTransactions OCPP 2.0 path delegates to stopAllTransactions(station, trigger, stopped)
- Eliminates 3x duplicated iteration+parallel-stop pattern

* refactor: add startTransactionOnConnector and flushQueuedTransactionMessages abstractions

- Add startTransactionOnConnector to OCPPServiceUtils (version dispatch via dynamic imports)
  OCPP 1.6: sends START_TRANSACTION via new OCPP16ServiceUtils.startTransactionOnConnector
  OCPP 2.0: sends TransactionEvent(Started) via OCPP20ServiceUtils.sendTransactionEvent
  Returns StartTransactionResult { accepted: boolean }
- Add flushQueuedTransactionMessages to OCPPServiceUtils (OCPP 1.6: no-op, OCPP 2.0: flushes queue)
- Migrate ATG startTransaction to use startTransactionOnConnector (fixes OCPP 2.0 ATG start)
- Migrate ATG handleStartTransactionResponse to handleStartTransactionResult (uses unified type)
- Remove ATG dependency on AuthorizationStatus, RequestCommand, StartTransactionRequest/Response
- Remove ChargingStation.flushQueuedTransactionEvents private method
- Remove version check in ChargingStation boot handler (line 2304)
- Add StartTransactionResult type to types barrel

* refactor: extract periodic meter values to OCPP service layer

- Add OCPP16ServiceUtils.startPeriodicMeterValues/stopPeriodicMeterValues
- Add OCPP20ServiceUtils.startPeriodicMeterValues/stopPeriodicMeterValues
- Add OCPPServiceUtils.startPeriodicMeterValues/stopPeriodicMeterValues (version dispatch)
- Remove startMeterValues, stopMeterValues, startTxUpdatedInterval, stopTxUpdatedInterval,
  restartMeterValues from ChargingStation.ts
- Migrate all callers to use versioned ServiceUtils methods
- Fix test referencing removed startTxUpdatedInterval method
- ChargingStation.ts: -130 lines of version-specific meter values logic removed

* fix: update tests for renamed APIs and removed ChargingStation methods

- AutomaticTransactionGenerator.test.ts: handleStartTransactionResponse -> handleStartTransactionResult, use StartTransactionResult { accepted } instead of StartTransactionResponse
- ChargingStation-Transactions.test.ts: test already uses OCPP16ServiceUtils.stopPeriodicMeterValues
- OCPP20ServiceUtils-TransactionEvent.test.ts: startTxUpdatedInterval -> startPeriodicMeterValues
- StationHelpers.ts: remove stopMeterValues, startMeterValues, startTxUpdatedInterval, stopTxUpdatedInterval, restartMeterValues from mock (no longer on ChargingStation)
- OCPP16ServiceUtils.stopPeriodicMeterValues: add missing delete after clearInterval

* fix: correct startPeriodicMeterValues test to verify no-transaction guard instead of version guard

* fix: guard undefined evseId and avoid throw in shutdown paths

- stopTransactionOnConnector: guard getEvseIdByConnectorId returning undefined, return { accepted: false } with warn log
- stopRunningTransactions: replace throw with warn log in default branch (shutdown path must not crash)

* fix: deduplicate test coverage and address review findings

- Remove ChargingStation-StopRunningTransactions.test.ts (duplicated coverage with OCPPServiceUtils-StopTransaction.test.ts)
- Move error handling test to OCPPServiceUtils-StopTransaction.test.ts
- Guard undefined evseId in stopTransactionOnConnector (review finding)
- Replace throw with warn in stopRunningTransactions default branch (review finding)

* fix: generate transactionId for OCPP 2.0 start and align offline stop acceptance

- startTransactionOnConnector OCPP 2.0: generate UUID transactionId and reset seqNo when
  connector has no transactionId (ATG path)
- stopTransactionOnConnector OCPP 2.0: treat missing idTokenInfo as accepted (offline queued
  events return undefined idTokenInfo, consistent with startTransactionOnConnector)

* fix: map Other reason correctly and use static generateUUID import

- mapStopReasonToOCPP20: add explicit Other -> Other/AbnormalCondition mapping
  instead of falling through to Local/StopAuthorized
- startTransactionOnConnector: use statically imported generateUUID instead of
  unnecessary dynamic import (utils/index.js already imported at top of file)

* test: add coverage for startTransactionOnConnector, flushQueuedTransactionMessages, and mapStopReasonToOCPP20

- startTransactionOnConnector: OCPP 1.6 accepted, OCPP 2.0 accepted, OCPP 2.0 UUID generation
- flushQueuedTransactionMessages: OCPP 1.6 no-op, OCPP 2.0 flush queued events
- mapStopReasonToOCPP20: Other, undefined, Remote mappings

* refactor: remove stopRunningTransactions indirection in ChargingStation

Call stopRunningTransactions(this, reason) directly instead of through
a private method that only delegates.

21 files changed:
src/charging-station/AutomaticTransactionGenerator.ts
src/charging-station/ChargingStation.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.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/OCPP20ResponseService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/index.ts
src/types/index.ts
src/types/ocpp/Transaction.ts
tests/charging-station/AutomaticTransactionGenerator.test.ts
tests/charging-station/ChargingStation-StopRunningTransactions.test.ts [deleted file]
tests/charging-station/ChargingStation-Transactions.test.ts
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/ocpp/1.6/OCPP16IncomingRequestService-RemoteStopUnlock.test.ts
tests/charging-station/ocpp/1.6/OCPP16Integration-Transactions.test.ts
tests/charging-station/ocpp/1.6/OCPP16ResponseService-Transactions.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts [new file with mode: 0644]

index d77bbf76ecfaae69278e2ea58d2fac8763c3b0ce..32158dc8d0a223dbc15beb3f8a296c8bb5c41ce8 100644 (file)
@@ -8,14 +8,11 @@ import type { ChargingStation } from './ChargingStation.js'
 import { BaseError } from '../exception/index.js'
 import { PerformanceStatistics } from '../performance/index.js'
 import {
-  AuthorizationStatus,
   ChargingStationEvents,
-  RequestCommand,
-  type StartTransactionRequest,
-  type StartTransactionResponse,
+  type StartTransactionResult,
   type Status,
   StopTransactionReason,
-  type StopTransactionResponse,
+  type StopTransactionResult,
 } from '../types/index.js'
 import {
   clone,
@@ -31,6 +28,7 @@ import {
 import { checkChargingStationState } from './Helpers.js'
 import { IdTagsCache } from './IdTagsCache.js'
 import { isIdTagAuthorized } from './ocpp/index.js'
+import { startTransactionOnConnector, stopTransactionOnConnector } from './ocpp/OCPPServiceUtils.js'
 
 export class AutomaticTransactionGenerator {
   private static readonly instances: Map<string, AutomaticTransactionGenerator> = new Map<
@@ -238,13 +236,10 @@ export class AutomaticTransactionGenerator {
     )
   }
 
-  private handleStartTransactionResponse (
-    connectorId: number,
-    startResponse: StartTransactionResponse
-  ): void {
+  private handleStartTransactionResult (connectorId: number, result: StartTransactionResult): void {
     // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
     ++this.connectorsStatus.get(connectorId)!.startTransactionRequests
-    if (startResponse.idTagInfo.status === AuthorizationStatus.ACCEPTED) {
+    if (result.accepted) {
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       ++this.connectorsStatus.get(connectorId)!.acceptedStartTransactionRequests
     } else {
@@ -317,7 +312,7 @@ export class AutomaticTransactionGenerator {
         this.connectorsStatus.get(connectorId)!.skippedConsecutiveTransactions = 0
         // Start transaction
         const startResponse = await this.startTransaction(connectorId)
-        if (startResponse?.idTagInfo.status === AuthorizationStatus.ACCEPTED) {
+        if (startResponse?.accepted === true) {
           // Wait until end of transaction
           const waitTrxEnd = secondsToMilliseconds(
             randomInt(
@@ -436,12 +431,10 @@ export class AutomaticTransactionGenerator {
     }
   }
 
-  private async startTransaction (
-    connectorId: number
-  ): Promise<StartTransactionResponse | undefined> {
+  private async startTransaction (connectorId: number): Promise<StartTransactionResult | undefined> {
     const measureId = 'StartTransaction with ATG'
     const beginId = PerformanceStatistics.beginMeasure(measureId)
-    let startResponse: StartTransactionResponse | undefined
+    let result: StartTransactionResult | undefined
     if (this.chargingStation.hasIdTags()) {
       const idTag = IdTagsCache.getInstance().getIdTag(
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -459,46 +452,27 @@ export class AutomaticTransactionGenerator {
           // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
           ++this.connectorsStatus.get(connectorId)!.acceptedAuthorizeRequests
           logger.info(startTransactionLogMsg)
-          // Start transaction
-          startResponse = await this.chargingStation.ocppRequestService.requestHandler<
-            Partial<StartTransactionRequest>,
-            StartTransactionResponse
-          >(this.chargingStation, RequestCommand.START_TRANSACTION, {
-            connectorId,
-            idTag,
-          })
-          this.handleStartTransactionResponse(connectorId, startResponse)
+          result = await startTransactionOnConnector(this.chargingStation, connectorId, idTag)
+          this.handleStartTransactionResult(connectorId, result)
           PerformanceStatistics.endMeasure(measureId, beginId)
-          return startResponse
+          return result
         }
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         ++this.connectorsStatus.get(connectorId)!.rejectedAuthorizeRequests
         PerformanceStatistics.endMeasure(measureId, beginId)
-        return startResponse
+        return result
       }
       logger.info(startTransactionLogMsg)
-      // Start transaction
-      startResponse = await this.chargingStation.ocppRequestService.requestHandler<
-        Partial<StartTransactionRequest>,
-        StartTransactionResponse
-      >(this.chargingStation, RequestCommand.START_TRANSACTION, {
-        connectorId,
-        idTag,
-      })
-      this.handleStartTransactionResponse(connectorId, startResponse)
+      result = await startTransactionOnConnector(this.chargingStation, connectorId, idTag)
+      this.handleStartTransactionResult(connectorId, result)
       PerformanceStatistics.endMeasure(measureId, beginId)
-      return startResponse
+      return result
     }
     logger.info(`${this.logPrefix(connectorId)} start transaction without an idTag`)
-    startResponse = await this.chargingStation.ocppRequestService.requestHandler<
-      Partial<StartTransactionRequest>,
-      StartTransactionResponse
-    >(this.chargingStation, RequestCommand.START_TRANSACTION, {
-      connectorId,
-    })
-    this.handleStartTransactionResponse(connectorId, startResponse)
+    result = await startTransactionOnConnector(this.chargingStation, connectorId)
+    this.handleStartTransactionResult(connectorId, result)
     PerformanceStatistics.endMeasure(measureId, beginId)
-    return startResponse
+    return result
   }
 
   private stopConnectors (): void {
@@ -522,10 +496,10 @@ export class AutomaticTransactionGenerator {
   private async stopTransaction (
     connectorId: number,
     reason = StopTransactionReason.LOCAL
-  ): Promise<StopTransactionResponse | undefined> {
+  ): Promise<StopTransactionResult | undefined> {
     const measureId = 'StopTransaction with ATG'
     const beginId = PerformanceStatistics.beginMeasure(measureId)
-    let stopResponse: StopTransactionResponse | undefined
+    let result: StopTransactionResult | undefined
     if (this.chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true) {
       logger.info(
         // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
@@ -533,12 +507,10 @@ export class AutomaticTransactionGenerator {
           .getConnectorStatus(connectorId)
           ?.transactionId?.toString()}`
       )
-      // TODO: OCPP 2.0 stations should use OCPP20ServiceUtils.requestStopTransaction() instead
-      // See: src/charging-station/ChargingStation.ts#stopRunningTransactionsOCPP20
-      stopResponse = await this.chargingStation.stopTransactionOnConnector(connectorId, reason)
+      result = await stopTransactionOnConnector(this.chargingStation, connectorId, reason)
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       ++this.connectorsStatus.get(connectorId)!.stopTransactionRequests
-      if (stopResponse.idTagInfo?.status === AuthorizationStatus.ACCEPTED) {
+      if (result.accepted) {
         // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
         ++this.connectorsStatus.get(connectorId)!.acceptedStopTransactionRequests
       } else {
@@ -554,7 +526,7 @@ export class AutomaticTransactionGenerator {
       )
     }
     PerformanceStatistics.endMeasure(measureId, beginId)
-    return stopResponse
+    return result
   }
 
   private async waitChargingStationAvailable (connectorId: number): Promise<void> {
index ff06512f8029f8d0b444138bf9318c13837c3f89..0764f6b9520be6bc2a2b9af6ce2eb0c1867fc389 100644 (file)
@@ -41,12 +41,6 @@ import {
   type IncomingRequestCommand,
   MessageType,
   MeterValueMeasurand,
-  type MeterValuesRequest,
-  type MeterValuesResponse,
-  type OCPP20MeterValue,
-  OCPP20ReasonEnumType,
-  OCPP20TransactionEventEnumType,
-  OCPP20TriggerReasonEnumType,
   OCPPVersion,
   type OutgoingRequest,
   PowerUnits,
@@ -59,8 +53,6 @@ import {
   StandardParametersKey,
   type Status,
   type StopTransactionReason,
-  type StopTransactionRequest,
-  type StopTransactionResponse,
   SupervisionUrlDistribution,
   SupportedFeatureProfiles,
   type Voltage,
@@ -150,8 +142,6 @@ import {
 } from './Helpers.js'
 import { IdTagsCache } from './IdTagsCache.js'
 import {
-  buildMeterValue,
-  buildTransactionEndMeterValue,
   getMessageTypeString,
   OCPP16IncomingRequestService,
   OCPP16RequestService,
@@ -159,12 +149,12 @@ import {
   OCPP20IncomingRequestService,
   OCPP20RequestService,
   OCPP20ResponseService,
-  OCPP20ServiceUtils,
   OCPPAuthServiceFactory,
   type OCPPIncomingRequestService,
   type OCPPRequestService,
   sendAndSetConnectorStatus,
 } from './ocpp/index.js'
+import { flushQueuedTransactionMessages, stopRunningTransactions } from './ocpp/OCPPServiceUtils.js'
 import { SharedLRUCache } from './SharedLRUCache.js'
 
 export class ChargingStation extends EventEmitter {
@@ -893,11 +883,6 @@ export class ChargingStation extends EventEmitter {
     this.startHeartbeat()
   }
 
-  public restartMeterValues (connectorId: number, interval: number): void {
-    this.stopMeterValues(connectorId)
-    this.startMeterValues(connectorId, interval)
-  }
-
   public restartWebSocketPing (): void {
     // Stop WebSocket ping
     this.stopWebSocketPing()
@@ -1040,108 +1025,6 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  public startMeterValues (connectorId: number, interval: number): void {
-    if (connectorId === 0) {
-      logger.error(
-        `${this.logPrefix()} Trying to start MeterValues on connector id ${connectorId.toString()}`
-      )
-      return
-    }
-    const connectorStatus = this.getConnectorStatus(connectorId)
-    if (connectorStatus == null) {
-      logger.error(
-        `${this.logPrefix()} Trying to start MeterValues on non existing connector id
-          ${connectorId.toString()}`
-      )
-      return
-    }
-    if (connectorStatus.transactionStarted === false) {
-      logger.error(
-        `${this.logPrefix()} Trying to start MeterValues on connector id ${connectorId.toString()} with no transaction started`
-      )
-      return
-    } else if (
-      connectorStatus.transactionStarted === true &&
-      connectorStatus.transactionId == null
-    ) {
-      logger.error(
-        `${this.logPrefix()} Trying to start MeterValues on connector id ${connectorId.toString()} with no transaction id`
-      )
-      return
-    }
-    if (interval > 0) {
-      connectorStatus.transactionSetInterval = setInterval(() => {
-        const transactionId = convertToInt(connectorStatus.transactionId)
-        const meterValue = buildMeterValue(this, connectorId, transactionId, interval)
-        this.ocppRequestService
-          .requestHandler<MeterValuesRequest, MeterValuesResponse>(
-            this,
-            RequestCommand.METER_VALUES,
-            {
-              connectorId,
-              meterValue: [meterValue],
-              transactionId,
-            } as MeterValuesRequest
-          )
-          .catch((error: unknown) => {
-            logger.error(
-              `${this.logPrefix()} Error while sending '${RequestCommand.METER_VALUES}':`,
-              error
-            )
-          })
-      }, clampToSafeTimerValue(interval))
-    } else {
-      logger.error(
-        `${this.logPrefix()} Charging station ${
-          StandardParametersKey.MeterValueSampleInterval
-        } configuration set to ${interval.toString()}, not sending MeterValues`
-      )
-    }
-  }
-
-  public startTxUpdatedInterval (connectorId: number, interval: number): void {
-    if (this.stationInfo?.ocppVersion !== OCPPVersion.VERSION_20) {
-      return
-    }
-    const connector = this.getConnectorStatus(connectorId)
-    if (connector == null) {
-      logger.error(`${this.logPrefix()} Connector ${connectorId.toString()} not found`)
-      return
-    }
-    if (interval <= 0) {
-      logger.debug(
-        `${this.logPrefix()} TxUpdatedInterval is ${interval.toString()}, not starting periodic TransactionEvent`
-      )
-      return
-    }
-    if (connector.transactionTxUpdatedSetInterval != null) {
-      logger.warn(`${this.logPrefix()} TxUpdatedInterval already started, stopping first`)
-      this.stopTxUpdatedInterval(connectorId)
-    }
-    connector.transactionTxUpdatedSetInterval = setInterval(() => {
-      const connectorStatus = this.getConnectorStatus(connectorId)
-      if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
-        const meterValue = buildMeterValue(this, connectorId, 0, interval) as OCPP20MeterValue
-        OCPP20ServiceUtils.sendTransactionEvent(
-          this,
-          OCPP20TransactionEventEnumType.Updated,
-          OCPP20TriggerReasonEnumType.MeterValuePeriodic,
-          connectorId,
-          connectorStatus.transactionId as string,
-          { meterValue: [meterValue] }
-        ).catch((error: unknown) => {
-          logger.error(
-            `${this.logPrefix()} Error sending periodic TransactionEvent at TxUpdatedInterval:`,
-            error
-          )
-        })
-      }
-    }, clampToSafeTimerValue(interval))
-    logger.info(
-      `${this.logPrefix()} TxUpdatedInterval started every ${formatDurationMilliSeconds(interval)}`
-    )
-  }
-
   public async stop (
     reason?: StopTransactionReason,
     stopTransactions = this.stationInfo?.stopTransactionsOnStopped
@@ -1185,58 +1068,6 @@ export class ChargingStation extends EventEmitter {
     this.emitChargingStationEvent(ChargingStationEvents.updated)
   }
 
-  public stopMeterValues (connectorId: number): void {
-    const connectorStatus = this.getConnectorStatus(connectorId)
-    if (connectorStatus?.transactionSetInterval != null) {
-      clearInterval(connectorStatus.transactionSetInterval)
-    }
-  }
-
-  public async stopTransactionOnConnector (
-    connectorId: number,
-    reason?: StopTransactionReason
-  ): Promise<StopTransactionResponse> {
-    const rawTransactionId = this.getConnectorStatus(connectorId)?.transactionId
-    const transactionId = rawTransactionId != null ? convertToInt(rawTransactionId) : undefined
-    if (
-      this.stationInfo?.beginEndMeterValues === true &&
-      this.stationInfo.ocppStrictCompliance === true &&
-      this.stationInfo.outOfOrderEndMeterValues === false
-    ) {
-      const transactionEndMeterValue = buildTransactionEndMeterValue(
-        this,
-        connectorId,
-        this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
-      )
-      await this.ocppRequestService.requestHandler<MeterValuesRequest, MeterValuesResponse>(
-        this,
-        RequestCommand.METER_VALUES,
-        {
-          connectorId,
-          meterValue: [transactionEndMeterValue],
-          transactionId,
-        } as MeterValuesRequest
-      )
-    }
-    return await this.ocppRequestService.requestHandler<
-      Partial<StopTransactionRequest>,
-      StopTransactionResponse
-    >(this, RequestCommand.STOP_TRANSACTION, {
-      meterStop: this.getEnergyActiveImportRegisterByTransactionId(rawTransactionId, true),
-      transactionId,
-      ...(reason != null && { reason: reason as StopTransactionRequest['reason'] }),
-    })
-  }
-
-  public stopTxUpdatedInterval (connectorId: number): void {
-    const connector = this.getConnectorStatus(connectorId)
-    if (connector?.transactionTxUpdatedSetInterval != null) {
-      clearInterval(connector.transactionTxUpdatedSetInterval)
-      delete connector.transactionTxUpdatedSetInterval
-      logger.info(`${this.logPrefix()} TxUpdatedInterval stopped`)
-    }
-  }
-
   private add (): void {
     this.emitChargingStationEvent(ChargingStationEvents.added)
   }
@@ -1257,40 +1088,6 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  private async flushQueuedTransactionEvents (): Promise<void> {
-    if (this.hasEvses) {
-      for (const evseStatus of this.evses.values()) {
-        for (const [connectorId, connectorStatus] of evseStatus.connectors) {
-          if ((connectorStatus.transactionEventQueue?.length ?? 0) === 0) {
-            continue
-          }
-          await OCPP20ServiceUtils.sendQueuedTransactionEvents(this, connectorId).catch(
-            (error: unknown) => {
-              logger.error(
-                `${this.logPrefix()} Error while flushing queued TransactionEvents:`,
-                error
-              )
-            }
-          )
-        }
-      }
-    } else {
-      for (const [connectorId, connectorStatus] of this.connectors) {
-        if ((connectorStatus.transactionEventQueue?.length ?? 0) === 0) {
-          continue
-        }
-        await OCPP20ServiceUtils.sendQueuedTransactionEvents(this, connectorId).catch(
-          (error: unknown) => {
-            logger.error(
-              `${this.logPrefix()} Error while flushing queued TransactionEvents:`,
-              error
-            )
-          }
-        )
-      }
-    }
-  }
-
   private getAmperageLimitation (): number | undefined {
     if (
       isNotEmptyString(this.stationInfo?.amperageLimitationOcppKey) &&
@@ -2340,8 +2137,8 @@ export class ChargingStation extends EventEmitter {
           // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
           `${this.logPrefix()} Registration failure: maximum retries reached (${registrationRetryCount.toString()}) or retry disabled (${this.stationInfo?.registrationMaxRetries?.toString()})`
         )
-      } else if (this.stationInfo?.ocppVersion === OCPPVersion.VERSION_20) {
-        await this.flushQueuedTransactionEvents()
+      } else {
+        await flushQueuedTransactionMessages(this)
       }
       this.emitChargingStationEvent(ChargingStationEvents.updated)
     } else {
@@ -2702,7 +2499,7 @@ export class ChargingStation extends EventEmitter {
   ): Promise<void> {
     this.internalStopMessageSequence()
     // Stop ongoing transactions
-    stopTransactions && (await this.stopRunningTransactions(reason))
+    stopTransactions && (await stopRunningTransactions(this, reason))
     if (this.hasEvses) {
       for (const [evseId, evseStatus] of this.evses) {
         if (evseId > 0) {
@@ -2727,77 +2524,6 @@ export class ChargingStation extends EventEmitter {
     }
   }
 
-  private async stopRunningTransactions (reason?: StopTransactionReason): Promise<void> {
-    if (
-      this.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 ||
-      this.stationInfo?.ocppVersion === OCPPVersion.VERSION_201
-    ) {
-      await this.stopRunningTransactionsOCPP20(reason)
-      return
-    }
-    if (this.hasEvses) {
-      for (const [evseId, evseStatus] of this.evses) {
-        if (evseId === 0) {
-          continue
-        }
-        for (const [connectorId, connectorStatus] of evseStatus.connectors) {
-          if (connectorStatus.transactionStarted === true) {
-            await this.stopTransactionOnConnector(connectorId, reason)
-          }
-        }
-      }
-    } else {
-      for (const connectorId of this.connectors.keys()) {
-        if (connectorId > 0 && this.getConnectorStatus(connectorId)?.transactionStarted === true) {
-          await this.stopTransactionOnConnector(connectorId, reason)
-        }
-      }
-    }
-  }
-
-  private async stopRunningTransactionsOCPP20 (reason?: StopTransactionReason): Promise<void> {
-    const stoppedReason =
-      reason != null ? (reason as unknown as OCPP20ReasonEnumType) : OCPP20ReasonEnumType.Local
-    const terminationPromises: Promise<unknown>[] = []
-
-    for (const [evseId, evseStatus] of this.evses) {
-      if (evseId === 0) {
-        continue
-      }
-      for (const [connectorId, connectorStatus] of evseStatus.connectors) {
-        if (
-          connectorStatus.transactionStarted === true ||
-          connectorStatus.transactionPending === true
-        ) {
-          logger.info(
-            `${this.logPrefix()} stopRunningTransactionsOCPP20: Stopping transaction ${connectorStatus.transactionId?.toString() ?? 'unknown'} on connector ${connectorId.toString()}`
-          )
-          terminationPromises.push(
-            OCPP20ServiceUtils.requestStopTransaction(
-              this,
-              connectorId,
-              evseId,
-              OCPP20TriggerReasonEnumType.StopAuthorized,
-              stoppedReason
-            ).catch((error: unknown) => {
-              logger.error(
-                `${this.logPrefix()} stopRunningTransactionsOCPP20: Error stopping transaction on connector ${connectorId.toString()}:`,
-                error
-              )
-            })
-          )
-        }
-      }
-    }
-
-    if (terminationPromises.length > 0) {
-      await Promise.all(terminationPromises)
-      logger.info(
-        `${this.logPrefix()} stopRunningTransactionsOCPP20: All transactions stopped on charging station`
-      )
-    }
-  }
-
   private stopWebSocketPing (): void {
     if (this.wsPingSetInterval != null) {
       clearInterval(this.wsPingSetInterval)
index 9471353a11d0225528cd5cae39179d9042f88092..11bdfbfa9b8e9a7f098103ee8d490116d7cc47f7 100644 (file)
@@ -828,7 +828,9 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
           connectorId++
         ) {
           if (chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true) {
-            chargingStation.restartMeterValues(
+            OCPP16ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
+            OCPP16ServiceUtils.startPeriodicMeterValues(
+              chargingStation,
               connectorId,
               secondsToMilliseconds(convertToInt(value))
             )
@@ -1557,7 +1559,8 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
       return OCPP16Constants.OCPP_RESPONSE_UNLOCK_NOT_SUPPORTED
     }
     if (chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true) {
-      const stopResponse = await chargingStation.stopTransactionOnConnector(
+      const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
+        chargingStation,
         connectorId,
         OCPP16StopTransactionReason.UNLOCK_COMMAND
       )
index 2ba96a03cc469cce82f6059ea776d5a6e7a4d0a8..12dfd7c10276071ca49ad3d26fead1500b33af6c 100644 (file)
@@ -442,7 +442,8 @@ export class OCPP16ResponseService extends OCPPResponseService {
         chargingStation,
         OCPP16StandardParametersKey.MeterValueSampleInterval
       )
-      chargingStation.startMeterValues(
+      OCPP16ServiceUtils.startPeriodicMeterValues(
+        chargingStation,
         connectorId,
         configuredMeterValueSampleInterval != null
           ? secondsToMilliseconds(convertToInt(configuredMeterValueSampleInterval.value))
@@ -518,7 +519,7 @@ export class OCPP16ResponseService extends OCPPResponseService {
       chargingStation.powerDivider!--
     }
     resetConnectorStatus(chargingStation.getConnectorStatus(transactionConnectorId))
-    chargingStation.stopMeterValues(transactionConnectorId)
+    OCPP16ServiceUtils.stopPeriodicMeterValues(chargingStation, transactionConnectorId)
     const logMsg = `${chargingStation.logPrefix()} ${moduleName}.handleResponseStopTransaction: Transaction with id ${requestPayload.transactionId.toString()} STOPPED on ${
       // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
       chargingStation.stationInfo?.chargingStationId
@@ -538,7 +539,7 @@ export class OCPP16ResponseService extends OCPPResponseService {
     chargingStation: ChargingStation,
     connectorId: number
   ): Promise<void> {
-    chargingStation.stopMeterValues(connectorId)
+    OCPP16ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
     resetConnectorStatus(connectorStatus)
     await OCPP16ServiceUtils.restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
index 52b24a5b436185af0884da2a74e67f8594b7619e..9b4c20292d04f25741452b1d72947a9e18f4a724 100644 (file)
@@ -16,6 +16,8 @@ import {
 import {
   type ConfigurationKey,
   type GenericResponse,
+  type MeterValuesRequest,
+  type MeterValuesResponse,
   OCPP16AuthorizationStatus,
   type OCPP16AvailabilityType,
   type OCPP16ChangeAvailabilityResponse,
@@ -33,9 +35,26 @@ import {
   OCPP16StopTransactionReason,
   type OCPP16SupportedFeatureProfiles,
   OCPPVersion,
+  RequestCommand,
+  type StartTransactionRequest,
+  type StartTransactionResponse,
+  type StopTransactionReason,
+  type StopTransactionRequest,
+  type StopTransactionResponse,
 } from '../../../types/index.js'
-import { convertToDate, isNotEmptyArray, logger, roundTo } from '../../../utils/index.js'
-import { OCPPServiceUtils } from '../OCPPServiceUtils.js'
+import {
+  clampToSafeTimerValue,
+  convertToDate,
+  convertToInt,
+  isNotEmptyArray,
+  logger,
+  roundTo,
+} from '../../../utils/index.js'
+import {
+  buildMeterValue,
+  buildTransactionEndMeterValue,
+  OCPPServiceUtils,
+} from '../OCPPServiceUtils.js'
 import { OCPP16Constants } from './OCPP16Constants.js'
 
 const moduleName = 'OCPP16ServiceUtils'
@@ -521,7 +540,8 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils {
       connectorId,
       OCPP16ChargePointStatus.Finishing
     )
-    const stopResponse = await chargingStation.stopTransactionOnConnector(
+    const stopResponse = await OCPP16ServiceUtils.stopTransactionOnConnector(
+      chargingStation,
       connectorId,
       OCPP16StopTransactionReason.REMOTE
     )
@@ -573,6 +593,116 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils {
     !cpReplaced && chargingStation.getConnectorStatus(connectorId)?.chargingProfiles?.push(cp)
   }
 
+  public static startPeriodicMeterValues (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    interval: number
+  ): void {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (connectorStatus == null) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Connector ${connectorId.toString()} not found`
+      )
+      return
+    }
+    if (connectorStatus.transactionStarted !== true || connectorStatus.transactionId == null) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: No active transaction on connector ${connectorId.toString()}`
+      )
+      return
+    }
+    if (interval <= 0) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: MeterValueSampleInterval set to ${interval.toString()}, not sending MeterValues`
+      )
+      return
+    }
+    connectorStatus.transactionSetInterval = setInterval(() => {
+      const transactionId = convertToInt(connectorStatus.transactionId)
+      const meterValue = buildMeterValue(chargingStation, connectorId, transactionId, interval)
+      chargingStation.ocppRequestService
+        .requestHandler<MeterValuesRequest, MeterValuesResponse>(
+          chargingStation,
+          RequestCommand.METER_VALUES,
+          {
+            connectorId,
+            meterValue: [meterValue],
+            transactionId,
+          } as MeterValuesRequest
+        )
+        .catch((error: unknown) => {
+          logger.error(
+            `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Error while sending '${RequestCommand.METER_VALUES}':`,
+            error
+          )
+        })
+    }, clampToSafeTimerValue(interval))
+  }
+
+  public static async startTransactionOnConnector (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    idTag?: string
+  ): Promise<StartTransactionResponse> {
+    return chargingStation.ocppRequestService.requestHandler<
+      Partial<StartTransactionRequest>,
+      StartTransactionResponse
+    >(chargingStation, RequestCommand.START_TRANSACTION, {
+      connectorId,
+      ...(idTag != null && { idTag }),
+    })
+  }
+
+  public static stopPeriodicMeterValues (
+    chargingStation: ChargingStation,
+    connectorId: number
+  ): void {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (connectorStatus?.transactionSetInterval != null) {
+      clearInterval(connectorStatus.transactionSetInterval)
+      delete connectorStatus.transactionSetInterval
+    }
+  }
+
+  public static async stopTransactionOnConnector (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    reason?: StopTransactionReason
+  ): Promise<StopTransactionResponse> {
+    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 = buildTransactionEndMeterValue(
+        chargingStation,
+        connectorId,
+        chargingStation.getEnergyActiveImportRegisterByTransactionId(rawTransactionId)
+      )
+      await chargingStation.ocppRequestService.requestHandler<
+        MeterValuesRequest,
+        MeterValuesResponse
+      >(chargingStation, RequestCommand.METER_VALUES, {
+        connectorId,
+        meterValue: [transactionEndMeterValue],
+        transactionId,
+      } as MeterValuesRequest)
+    }
+    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'] }),
+    })
+  }
+
   private static readonly composeChargingSchedule = (
     chargingSchedule: OCPP16ChargingSchedule,
     compositeInterval: Interval
index ea0fabce78ce3bf02b94f8a8b1961877a76bb929..5f9898aa885384dd8d3277686e93b93263206fec 100644 (file)
@@ -3033,7 +3033,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     connectorId: number,
     evseId?: number
   ): Promise<void> {
-    chargingStation.stopMeterValues(connectorId)
+    OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
     resetConnectorStatus(connectorStatus)
     await restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
@@ -3587,34 +3587,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     chargingStation: ChargingStation,
     reason: OCPP20ReasonEnumType
   ): Promise<void> {
-    const terminationPromises: Promise<unknown>[] = []
-
-    for (const [evseId, evse] of chargingStation.evses) {
-      for (const [connectorId, connector] of evse.connectors) {
-        if (connector.transactionId != null) {
-          logger.info(
-            `${chargingStation.logPrefix()} ${moduleName}.terminateAllTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
-          )
-          terminationPromises.push(
-            OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
-              (error: unknown) => {
-                logger.error(
-                  `${chargingStation.logPrefix()} ${moduleName}.terminateAllTransactions: Error terminating transaction on connector ${connectorId.toString()}:`,
-                  error
-                )
-              }
-            )
-          )
-        }
-      }
-    }
-
-    if (terminationPromises.length > 0) {
-      await Promise.all(terminationPromises)
-      logger.info(
-        `${chargingStation.logPrefix()} ${moduleName}.terminateAllTransactions: All transactions terminated on charging station`
-      )
-    }
+    await OCPP20ServiceUtils.stopAllTransactions(
+      chargingStation,
+      OCPP20TriggerReasonEnumType.ResetCommand,
+      reason
+    )
   }
 
   /**
@@ -3628,39 +3605,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
     evseId: number,
     reason: OCPP20ReasonEnumType
   ): Promise<void> {
-    const evse = chargingStation.getEvseStatus(evseId)
-    if (!evse) {
-      logger.warn(
-        `${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: EVSE ${evseId.toString()} not found`
-      )
-      return
-    }
-
-    const terminationPromises: Promise<unknown>[] = []
-    for (const [connectorId, connector] of evse.connectors) {
-      if (connector.transactionId != null) {
-        logger.info(
-          `${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: Terminating transaction ${connector.transactionId.toString()} on connector ${connectorId.toString()}`
-        )
-        terminationPromises.push(
-          OCPP20ServiceUtils.requestStopTransaction(chargingStation, connectorId, evseId).catch(
-            (error: unknown) => {
-              logger.error(
-                `${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: Error terminating transaction on connector ${connectorId.toString()}:`,
-                error
-              )
-            }
-          )
-        )
-      }
-    }
-
-    if (terminationPromises.length > 0) {
-      await Promise.all(terminationPromises)
-      logger.info(
-        `${chargingStation.logPrefix()} ${moduleName}.terminateEvseTransactions: All transactions terminated on EVSE ${evseId.toString()}`
-      )
-    }
+    await OCPP20ServiceUtils.stopAllTransactions(
+      chargingStation,
+      OCPP20TriggerReasonEnumType.ResetCommand,
+      reason,
+      evseId
+    )
   }
 
   private toHandler (
index ef07ca6c7278f778c638200baf2a88b0ba7071ca..c88823e3c724e7f793b2b715a6be953f708bb890 100644 (file)
@@ -333,7 +333,11 @@ export class OCPP20ResponseService extends OCPPResponseService {
               )
             })
             const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
-            chargingStation.startTxUpdatedInterval(connectorId, txUpdatedInterval)
+            OCPP20ServiceUtils.startPeriodicMeterValues(
+              chargingStation,
+              connectorId,
+              txUpdatedInterval
+            )
           }
           logger.info(
             `${chargingStation.logPrefix()} ${moduleName}.handleResponseTransactionEvent: Transaction ${requestPayload.transactionInfo.transactionId} STARTED on connector ${String(connectorId)}`
index 45b234c335ac536b67fd59778e1ced2b730fa3f8..c51ae341c2a94003cd8f12401fdc746a25a19b3f 100644 (file)
@@ -27,14 +27,20 @@ import {
   type UUIDv4,
 } from '../../../types/index.js'
 import {
+  clampToSafeTimerValue,
   Constants,
   convertToIntOrNaN,
+  formatDurationMilliSeconds,
   generateUUID,
   logger,
   validateIdentifierString,
 } from '../../../utils/index.js'
 import { getConfigurationKey } from '../../ConfigurationKeyUtils.js'
-import { OCPPServiceUtils, sendAndSetConnectorStatus } from '../OCPPServiceUtils.js'
+import {
+  buildMeterValue,
+  OCPPServiceUtils,
+  sendAndSetConnectorStatus,
+} from '../OCPPServiceUtils.js'
 import { OCPP20VariableManager } from './OCPP20VariableManager.js'
 
 const moduleName = 'OCPP20ServiceUtils'
@@ -332,7 +338,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
         }
       )
 
-      chargingStation.stopTxUpdatedInterval(connectorId)
+      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
       resetConnectorStatus(connectorStatus)
       await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
 
@@ -383,7 +389,7 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
         }
       )
 
-      chargingStation.stopTxUpdatedInterval(connectorId)
+      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
       resetConnectorStatus(connectorStatus)
       await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
 
@@ -524,6 +530,132 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     }
   }
 
+  public static startPeriodicMeterValues (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    interval: number
+  ): void {
+    const connector = chargingStation.getConnectorStatus(connectorId)
+    if (connector == null) {
+      logger.error(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Connector ${connectorId.toString()} not found`
+      )
+      return
+    }
+    if (interval <= 0) {
+      logger.debug(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: TxUpdatedInterval is ${interval.toString()}, not starting periodic TransactionEvent`
+      )
+      return
+    }
+    if (connector.transactionTxUpdatedSetInterval != null) {
+      logger.warn(
+        `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: TxUpdatedInterval already started, stopping first`
+      )
+      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
+    }
+    connector.transactionTxUpdatedSetInterval = setInterval(() => {
+      const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+      if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
+        const meterValue = buildMeterValue(
+          chargingStation,
+          connectorId,
+          0,
+          interval
+        ) as OCPP20MeterValue
+        OCPP20ServiceUtils.sendTransactionEvent(
+          chargingStation,
+          OCPP20TransactionEventEnumType.Updated,
+          OCPP20TriggerReasonEnumType.MeterValuePeriodic,
+          connectorId,
+          connectorStatus.transactionId as string,
+          { meterValue: [meterValue] }
+        ).catch((error: unknown) => {
+          logger.error(
+            `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Error sending periodic TransactionEvent:`,
+            error
+          )
+        })
+      }
+    }, clampToSafeTimerValue(interval))
+    logger.info(
+      `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: TxUpdatedInterval started every ${formatDurationMilliSeconds(interval)}`
+    )
+  }
+
+  public static async stopAllTransactions (
+    chargingStation: ChargingStation,
+    triggerReason: OCPP20TriggerReasonEnumType = OCPP20TriggerReasonEnumType.RemoteStop,
+    stoppedReason: OCPP20ReasonEnumType = OCPP20ReasonEnumType.Remote,
+    evseId?: number
+  ): Promise<void> {
+    const terminationPromises: Promise<unknown>[] = []
+    if (evseId != null) {
+      const evseStatus = chargingStation.getEvseStatus(evseId)
+      if (evseStatus != null) {
+        for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+          if (connectorStatus.transactionId != null) {
+            terminationPromises.push(
+              OCPP20ServiceUtils.requestStopTransaction(
+                chargingStation,
+                connectorId,
+                evseId,
+                triggerReason,
+                stoppedReason
+              ).catch((error: unknown) => {
+                logger.error(
+                  `${chargingStation.logPrefix()} ${moduleName}.stopAllTransactions: Error stopping transaction on connector ${connectorId.toString()}:`,
+                  error
+                )
+              })
+            )
+          }
+        }
+      }
+    } else {
+      for (const [iteratedEvseId, evseStatus] of chargingStation.evses) {
+        if (iteratedEvseId === 0) {
+          continue
+        }
+        for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+          if (connectorStatus.transactionId != null) {
+            terminationPromises.push(
+              OCPP20ServiceUtils.requestStopTransaction(
+                chargingStation,
+                connectorId,
+                iteratedEvseId,
+                triggerReason,
+                stoppedReason
+              ).catch((error: unknown) => {
+                logger.error(
+                  `${chargingStation.logPrefix()} ${moduleName}.stopAllTransactions: Error stopping transaction on connector ${connectorId.toString()}:`,
+                  error
+                )
+              })
+            )
+          }
+        }
+      }
+    }
+    if (terminationPromises.length > 0) {
+      await Promise.all(terminationPromises)
+    }
+  }
+
+  public static stopPeriodicMeterValues (
+    chargingStation: ChargingStation,
+    connectorId: number
+  ): void {
+    const connector = chargingStation.getConnectorStatus(connectorId)
+    if (connector?.transactionTxUpdatedSetInterval != null) {
+      clearInterval(connector.transactionTxUpdatedSetInterval)
+      delete connector.transactionTxUpdatedSetInterval
+      logger.info(
+        `${chargingStation.logPrefix()} ${moduleName}.stopPeriodicMeterValues: TxUpdatedInterval stopped`
+      )
+    }
+  }
+
   private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] {
     const finalMeterValues: OCPP20MeterValue[] = []
     const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
index 224880b62097133b4305207842df795e68cd895a..8098abc15298e8f712e854d6b52ae6a48e7b6429 100644 (file)
@@ -5,6 +5,8 @@ import { readFileSync } from 'node:fs'
 import { dirname, join } from 'node:path'
 import { fileURLToPath } from 'node:url'
 
+import type { StopTransactionReason } from '../../types/index.js'
+
 import {
   type ChargingStation,
   getConfigurationKey,
@@ -38,16 +40,24 @@ import {
   type OCPP16MeterValue,
   type OCPP16SampledValue,
   type OCPP16StatusNotificationRequest,
+  OCPP16StopTransactionReason,
+  OCPP20AuthorizationStatusEnumType,
   type OCPP20ConnectorStatusEnumType,
+  OCPP20IdTokenEnumType,
   type OCPP20MeterValue,
+  OCPP20ReasonEnumType,
   type OCPP20SampledValue,
+  OCPP20TransactionEventEnumType,
+  OCPP20TriggerReasonEnumType,
   OCPPVersion,
   RequestCommand,
   type SampledValue,
   type SampledValueTemplate,
   StandardParametersKey,
+  type StartTransactionResult,
   type StatusNotificationRequest,
   type StatusNotificationResponse,
+  type StopTransactionResult,
 } from '../../types/index.js'
 import {
   ACElectricUtils,
@@ -55,6 +65,7 @@ import {
   convertToFloat,
   convertToInt,
   DCElectricUtils,
+  generateUUID,
   getRandomFloatFluctuatedRounded,
   getRandomFloatRounded,
   handleFileException,
@@ -336,6 +347,317 @@ export const restoreConnectorStatus = async (
   }
 }
 
+export const mapStopReasonToOCPP20 = (
+  reason?: StopTransactionReason
+): {
+  stoppedReason: OCPP20ReasonEnumType
+  triggerReason: OCPP20TriggerReasonEnumType
+} => {
+  switch (reason) {
+    case OCPP16StopTransactionReason.DE_AUTHORIZED:
+    case OCPP20ReasonEnumType.DeAuthorized:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.DeAuthorized,
+        triggerReason: OCPP20TriggerReasonEnumType.Deauthorized,
+      }
+    case OCPP16StopTransactionReason.EMERGENCY_STOP:
+    case OCPP20ReasonEnumType.EmergencyStop:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.EmergencyStop,
+        triggerReason: OCPP20TriggerReasonEnumType.AbnormalCondition,
+      }
+    case OCPP16StopTransactionReason.EV_DISCONNECTED:
+    case OCPP20ReasonEnumType.EVDisconnected:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.EVDisconnected,
+        triggerReason: OCPP20TriggerReasonEnumType.EVDeparted,
+      }
+    case OCPP16StopTransactionReason.HARD_RESET:
+    case OCPP16StopTransactionReason.REBOOT:
+    case OCPP16StopTransactionReason.SOFT_RESET:
+    case OCPP20ReasonEnumType.ImmediateReset:
+    case OCPP20ReasonEnumType.Reboot:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.ImmediateReset,
+        triggerReason: OCPP20TriggerReasonEnumType.ResetCommand,
+      }
+    case OCPP16StopTransactionReason.OTHER:
+    case OCPP20ReasonEnumType.Other:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.Other,
+        triggerReason: OCPP20TriggerReasonEnumType.AbnormalCondition,
+      }
+    case OCPP16StopTransactionReason.POWER_LOSS:
+    case OCPP20ReasonEnumType.PowerLoss:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.PowerLoss,
+        triggerReason: OCPP20TriggerReasonEnumType.AbnormalCondition,
+      }
+    case OCPP16StopTransactionReason.REMOTE:
+    case OCPP20ReasonEnumType.Remote:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.Remote,
+        triggerReason: OCPP20TriggerReasonEnumType.RemoteStop,
+      }
+    case OCPP16StopTransactionReason.LOCAL:
+    case OCPP20ReasonEnumType.Local:
+    case undefined:
+    default:
+      return {
+        stoppedReason: OCPP20ReasonEnumType.Local,
+        triggerReason: OCPP20TriggerReasonEnumType.StopAuthorized,
+      }
+  }
+}
+
+export const startTransactionOnConnector = async (
+  chargingStation: ChargingStation,
+  connectorId: number,
+  idTag?: string
+): Promise<StartTransactionResult> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16: {
+      const { OCPP16ServiceUtils } = await import('./1.6/OCPP16ServiceUtils.js')
+      const response = await OCPP16ServiceUtils.startTransactionOnConnector(
+        chargingStation,
+        connectorId,
+        idTag
+      )
+      return { accepted: response.idTagInfo.status === AuthorizationStatus.ACCEPTED }
+    }
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+      let transactionId = connectorStatus?.transactionId as string | undefined
+      if (transactionId == null) {
+        transactionId = generateUUID()
+        if (connectorStatus != null) {
+          connectorStatus.transactionId = transactionId
+        }
+        OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId)
+      }
+      const response = await OCPP20ServiceUtils.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Started,
+        OCPP20TriggerReasonEnumType.Authorized,
+        connectorId,
+        transactionId,
+        {
+          idToken:
+            idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.Central } : undefined,
+        }
+      )
+      return {
+        accepted:
+          response.idTokenInfo == null ||
+          response.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted,
+      }
+    }
+    default:
+      throw new OCPPError(
+        ErrorType.INTERNAL_ERROR,
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        `startTransactionOnConnector: unsupported OCPP version ${chargingStation.stationInfo?.ocppVersion}`
+      )
+  }
+}
+
+export const stopTransactionOnConnector = async (
+  chargingStation: ChargingStation,
+  connectorId: number,
+  reason?: StopTransactionReason
+): Promise<StopTransactionResult> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16: {
+      const { OCPP16ServiceUtils } = await import('./1.6/OCPP16ServiceUtils.js')
+      const response = await OCPP16ServiceUtils.stopTransactionOnConnector(
+        chargingStation,
+        connectorId,
+        reason
+      )
+      return { accepted: response.idTagInfo?.status === AuthorizationStatus.ACCEPTED }
+    }
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      const evseId = chargingStation.getEvseIdByConnectorId(connectorId)
+      if (evseId == null) {
+        logger.warn(
+          `${chargingStation.logPrefix()} stopTransactionOnConnector: cannot resolve EVSE ID for connector ${connectorId.toString()}, skipping`
+        )
+        return { accepted: false }
+      }
+      const { stoppedReason, triggerReason } = mapStopReasonToOCPP20(reason)
+      const response = await OCPP20ServiceUtils.requestStopTransaction(
+        chargingStation,
+        connectorId,
+        evseId,
+        triggerReason,
+        stoppedReason
+      )
+      return {
+        accepted:
+          response.idTokenInfo == null ||
+          response.idTokenInfo.status === OCPP20AuthorizationStatusEnumType.Accepted,
+      }
+    }
+    default:
+      throw new OCPPError(
+        ErrorType.INTERNAL_ERROR,
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        `stopTransactionOnConnector: unsupported OCPP version ${chargingStation.stationInfo?.ocppVersion}`
+      )
+  }
+}
+
+export const stopRunningTransactions = async (
+  chargingStation: ChargingStation,
+  reason?: StopTransactionReason
+): Promise<void> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16: {
+      const { OCPP16ServiceUtils } = await import('./1.6/OCPP16ServiceUtils.js')
+      // Sequential — OCPP 1.6 behavior
+      if (chargingStation.hasEvses) {
+        for (const [evseId, evseStatus] of chargingStation.evses) {
+          if (evseId === 0) {
+            continue
+          }
+          for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+            if (connectorStatus.transactionStarted === true) {
+              await OCPP16ServiceUtils.stopTransactionOnConnector(
+                chargingStation,
+                connectorId,
+                reason
+              )
+            }
+          }
+        }
+      } else {
+        for (const connectorId of chargingStation.connectors.keys()) {
+          if (
+            connectorId > 0 &&
+            chargingStation.getConnectorStatus(connectorId)?.transactionStarted === true
+          ) {
+            await OCPP16ServiceUtils.stopTransactionOnConnector(
+              chargingStation,
+              connectorId,
+              reason
+            )
+          }
+        }
+      }
+      break
+    }
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      const { stoppedReason, triggerReason } = mapStopReasonToOCPP20(reason)
+      await OCPP20ServiceUtils.stopAllTransactions(chargingStation, triggerReason, stoppedReason)
+      break
+    }
+    default:
+      logger.warn(
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        `${chargingStation.logPrefix()} stopRunningTransactions: unsupported OCPP version ${chargingStation.stationInfo?.ocppVersion}, no transactions stopped`
+      )
+  }
+}
+
+export const startPeriodicMeterValues = async (
+  chargingStation: ChargingStation,
+  connectorId: number,
+  interval: number
+): Promise<void> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16: {
+      const { OCPP16ServiceUtils } = await import('./1.6/OCPP16ServiceUtils.js')
+      OCPP16ServiceUtils.startPeriodicMeterValues(chargingStation, connectorId, interval)
+      break
+    }
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      OCPP20ServiceUtils.startPeriodicMeterValues(chargingStation, connectorId, interval)
+      break
+    }
+    default:
+      logger.error(
+        // eslint-disable-next-line @typescript-eslint/restrict-template-expressions
+        `${chargingStation.logPrefix()} OCPPServiceUtils.startPeriodicMeterValues: unsupported OCPP version ${chargingStation.stationInfo?.ocppVersion}`
+      )
+  }
+}
+
+export const stopPeriodicMeterValues = async (
+  chargingStation: ChargingStation,
+  connectorId: number
+): Promise<void> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16: {
+      const { OCPP16ServiceUtils } = await import('./1.6/OCPP16ServiceUtils.js')
+      OCPP16ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
+      break
+    }
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
+      break
+    }
+    default:
+      break
+  }
+}
+
+export const flushQueuedTransactionMessages = async (
+  chargingStation: ChargingStation
+): Promise<void> => {
+  switch (chargingStation.stationInfo?.ocppVersion) {
+    case OCPPVersion.VERSION_16:
+      break
+    case OCPPVersion.VERSION_20:
+    case OCPPVersion.VERSION_201: {
+      const { OCPP20ServiceUtils } = await import('./2.0/OCPP20ServiceUtils.js')
+      if (chargingStation.hasEvses) {
+        for (const evseStatus of chargingStation.evses.values()) {
+          for (const [connectorId, connectorStatus] of evseStatus.connectors) {
+            if ((connectorStatus.transactionEventQueue?.length ?? 0) > 0) {
+              await OCPP20ServiceUtils.sendQueuedTransactionEvents(
+                chargingStation,
+                connectorId
+              ).catch((error: unknown) => {
+                logger.error(
+                  `${chargingStation.logPrefix()} OCPPServiceUtils.flushQueuedTransactionMessages: Error flushing queued TransactionEvents:`,
+                  error
+                )
+              })
+            }
+          }
+        }
+      } else {
+        for (const [connectorId, connectorStatus] of chargingStation.connectors) {
+          if ((connectorStatus.transactionEventQueue?.length ?? 0) > 0) {
+            await OCPP20ServiceUtils.sendQueuedTransactionEvents(
+              chargingStation,
+              connectorId
+            ).catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} OCPPServiceUtils.flushQueuedTransactionMessages: Error flushing queued TransactionEvents:`,
+                error
+              )
+            })
+          }
+        }
+      }
+      break
+    }
+    default:
+      break
+  }
+}
+
 const checkConnectorStatusTransition = async (
   chargingStation: ChargingStation,
   connectorId: number,
@@ -1990,8 +2312,10 @@ export class OCPPServiceUtils {
   public static readonly buildTransactionEndMeterValue = buildTransactionEndMeterValue
   public static readonly isIdTagAuthorized = isIdTagAuthorized
   public static readonly isIdTagAuthorizedUnified = isIdTagAuthorizedUnified
+  public static readonly mapStopReasonToOCPP20 = mapStopReasonToOCPP20
   public static readonly restoreConnectorStatus = restoreConnectorStatus
   public static readonly sendAndSetConnectorStatus = sendAndSetConnectorStatus
+  public static readonly stopRunningTransactions = stopRunningTransactions
 
   protected static buildSampledValue = buildSampledValue
   protected static getSampledValueTemplate = getSampledValueTemplate
index ef64322b3511770e5399db776ee1a2ca81493a5c..e8d578b01f0f7194a9f4f73db17c31797776d5e9 100644 (file)
@@ -1,6 +1,7 @@
 export { OCPP16IncomingRequestService } from './1.6/OCPP16IncomingRequestService.js'
 export { OCPP16RequestService } from './1.6/OCPP16RequestService.js'
 export { OCPP16ResponseService } from './1.6/OCPP16ResponseService.js'
+export { OCPP16ServiceUtils } from './1.6/OCPP16ServiceUtils.js'
 export { OCPP20IncomingRequestService } from './2.0/OCPP20IncomingRequestService.js'
 export { OCPP20RequestService } from './2.0/OCPP20RequestService.js'
 export { OCPP20ResponseService } from './2.0/OCPP20ResponseService.js'
index e8582e201c58372d04193aa1bb0d785472f2e79d..e1ee3157d2abfa80085520199c64113d6e56222a 100644 (file)
@@ -408,9 +408,11 @@ export {
   type AuthorizeResponse,
   type StartTransactionRequest,
   type StartTransactionResponse,
+  type StartTransactionResult,
   StopTransactionReason,
   type StopTransactionRequest,
   type StopTransactionResponse,
+  type StopTransactionResult,
 } from './ocpp/Transaction.js'
 export { PerformanceRecord } from './orm/entities/PerformanceRecord.js'
 export type { SimulatorState } from './SimulatorState.js'
index 4bf4f778825021b1e5550763b455c11666c2acb9..a96c5d0529f0c758cc65a08875499fe237c2417b 100644 (file)
@@ -29,9 +29,17 @@ export const StopTransactionReason = {
   ...OCPP16StopTransactionReason,
   ...OCPP20ReasonEnumType,
 } as const
+export interface StartTransactionResult {
+  readonly accepted: boolean
+}
+
 // eslint-disable-next-line @typescript-eslint/no-redeclare
 export type StopTransactionReason = OCPP16StopTransactionReason | OCPP20ReasonEnumType
 
 export type StopTransactionRequest = OCPP16StopTransactionRequest
 
 export type StopTransactionResponse = OCPP16StopTransactionResponse
+
+export interface StopTransactionResult {
+  readonly accepted: boolean
+}
index fa8b420b8710fc65b860e986fe8d297bc0af120d..eee728ae29b0b0658f7caac348f0802711483238 100644 (file)
@@ -6,7 +6,7 @@
  * - Singleton pattern (getInstance / deleteInstance)
  * - Lifecycle state machine (start / stop / starting / stopping guards)
  * - Connector status management (startConnector / stopConnector)
- * - handleStartTransactionResponse — transaction counter updates
+ * - handleStartTransactionResult — transaction counter updates
  * - initializeConnectorsStatus — connector status initialization
  *
  * Note: The async transaction loop (internalStartConnector, startTransaction, stopTransaction)
@@ -21,7 +21,7 @@ import type { ChargingStation } from '../../src/charging-station/index.js'
 
 import { AutomaticTransactionGenerator } from '../../src/charging-station/AutomaticTransactionGenerator.js'
 import { BaseError } from '../../src/exception/index.js'
-import { AuthorizationStatus, type StartTransactionResponse } from '../../src/types/index.js'
+import { type StartTransactionResult } from '../../src/types/index.js'
 import { flushMicrotasks } from '../helpers/TestLifecycleHelpers.js'
 import { createMockChargingStation, standardCleanup } from './ChargingStationTestUtils.js'
 
@@ -95,21 +95,18 @@ function getDefinedATG (station: ChargingStation): AutomaticTransactionGenerator
 }
 
 /**
- * Extracts the private handleStartTransactionResponse method from an ATG instance.
+ * Extracts the private handleStartTransactionResult method from an ATG instance.
  * @param atg - The ATG instance to extract the method from
- * @returns The bound handleStartTransactionResponse method
+ * @returns The bound handleStartTransactionResult method
  */
-function getHandleStartTransactionResponse (
+function getHandleStartTransactionResult (
   atg: AutomaticTransactionGenerator
-): (connectorId: number, response: StartTransactionResponse) => void {
+): (connectorId: number, result: StartTransactionResult) => void {
   return (
     atg as unknown as {
-      handleStartTransactionResponse: (
-        connectorId: number,
-        response: StartTransactionResponse
-      ) => void
+      handleStartTransactionResult: (connectorId: number, result: StartTransactionResult) => void
     }
-  ).handleStartTransactionResponse.bind(atg)
+  ).handleStartTransactionResult.bind(atg)
 }
 
 /**
@@ -247,17 +244,14 @@ await describe('AutomaticTransactionGenerator', async () => {
     })
   })
 
-  await describe('handleStartTransactionResponse', async () => {
+  await describe('handleStartTransactionResult', async () => {
     await it('should increment accepted counters on accepted start response', () => {
       const station = createStationForATG()
       const atg = getDefinedATG(station)
       const connectorStatus = getConnectorStatus(atg, 1)
-      const handleResponse = getHandleStartTransactionResponse(atg)
+      const handleResult = getHandleStartTransactionResult(atg)
 
-      handleResponse(1, {
-        idTagInfo: { status: AuthorizationStatus.ACCEPTED },
-        transactionId: 1,
-      } as StartTransactionResponse)
+      handleResult(1, { accepted: true })
 
       assert.strictEqual(connectorStatus.startTransactionRequests, 1)
       assert.strictEqual(connectorStatus.acceptedStartTransactionRequests, 1)
@@ -268,12 +262,9 @@ await describe('AutomaticTransactionGenerator', async () => {
       const station = createStationForATG()
       const atg = getDefinedATG(station)
       const connectorStatus = getConnectorStatus(atg, 1)
-      const handleResponse = getHandleStartTransactionResponse(atg)
+      const handleResult = getHandleStartTransactionResult(atg)
 
-      handleResponse(1, {
-        idTagInfo: { status: AuthorizationStatus.INVALID },
-        transactionId: 1,
-      } as StartTransactionResponse)
+      handleResult(1, { accepted: false })
 
       assert.strictEqual(connectorStatus.startTransactionRequests, 1)
       assert.strictEqual(connectorStatus.acceptedStartTransactionRequests, 0)
diff --git a/tests/charging-station/ChargingStation-StopRunningTransactions.test.ts b/tests/charging-station/ChargingStation-StopRunningTransactions.test.ts
deleted file mode 100644 (file)
index bd32284..0000000
+++ /dev/null
@@ -1,223 +0,0 @@
-/**
- * @file Tests for ChargingStation stopRunningTransactions
- * @description Verifies version-aware transaction stopping: OCPP 2.0 uses TransactionEvent(Ended),
- *              OCPP 1.6 uses StopTransaction
- */
-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 { EmptyObject, JsonType, StopTransactionReason } from '../../src/types/index.js'
-
-import { ChargingStation as ChargingStationClass } from '../../src/charging-station/ChargingStation.js'
-import { OCPPVersion } 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 } from './ChargingStationTestConstants.js'
-import { cleanupChargingStation, createMockChargingStation } from './ChargingStationTestUtils.js'
-
-interface TestableChargingStationPrivate {
-  stopRunningTransactions: (reason?: StopTransactionReason) => Promise<void>
-  stopRunningTransactionsOCPP20: (reason?: StopTransactionReason) => Promise<void>
-  stopTransactionOnConnector: (
-    connectorId: number,
-    reason?: StopTransactionReason
-  ) => Promise<unknown>
-}
-
-/**
- * Binds private ChargingStation methods to a mock station instance for testing
- * @param station - The mock station to bind methods to
- */
-function bindPrivateMethods (station: ChargingStation): void {
-  const proto = ChargingStationClass.prototype as unknown as TestableChargingStationPrivate
-  const stationRecord = station as unknown as Record<string, unknown>
-  stationRecord.stopRunningTransactions = proto.stopRunningTransactions
-  stationRecord.stopRunningTransactionsOCPP20 = proto.stopRunningTransactionsOCPP20
-  stationRecord.stopTransactionOnConnector = proto.stopTransactionOnConnector
-}
-
-await describe('ChargingStation stopRunningTransactions', async () => {
-  let station: ChargingStation | undefined
-
-  beforeEach(() => {
-    station = undefined
-  })
-
-  afterEach(() => {
-    standardCleanup()
-    if (station != null) {
-      cleanupChargingStation(station)
-    }
-  })
-
-  await it('should send TransactionEvent(Ended) for OCPP 2.0 stations when stopping running transactions', async () => {
-    // Arrange
-    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
-    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
-      sentRequests.push({
-        command: args[1] as string,
-        payload: args[2] as Record<string, unknown>,
-      })
-      return Promise.resolve({} as EmptyObject)
-    })
-
-    const result = createMockChargingStation({
-      baseName: TEST_CHARGING_STATION_BASE_NAME,
-      connectorsCount: 2,
-      evseConfiguration: { evsesCount: 2 },
-      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
-      ocppRequestService: {
-        requestHandler: requestHandlerMock,
-      },
-      stationInfo: {
-        ocppVersion: OCPPVersion.VERSION_201,
-      },
-      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
-    })
-    station = result.station
-    station.isWebSocketConnectionOpened = () => true
-    bindPrivateMethods(station)
-
-    setupConnectorWithTransaction(station, 1, { transactionId: 1001 })
-    const connector1 = station.getConnectorStatus(1)
-    if (connector1 != null) {
-      connector1.transactionId = 'tx-ocpp20-1001'
-    }
-
-    // Act
-    const testable = station as unknown as TestableChargingStationPrivate
-    await testable.stopRunningTransactions()
-
-    // Assert
-    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
-    assert.ok(transactionEventCalls.length > 0, 'Expected at least one TransactionEvent request')
-    const stopTransactionCalls = sentRequests.filter(r => r.command === 'StopTransaction')
-    assert.strictEqual(
-      stopTransactionCalls.length,
-      0,
-      'Should not send StopTransaction for OCPP 2.0'
-    )
-  })
-
-  await it('should send StopTransaction for OCPP 1.6 stations when stopping running transactions', async () => {
-    // Arrange
-    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
-    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
-      sentRequests.push({
-        command: args[1] as string,
-        payload: args[2] as Record<string, unknown>,
-      })
-      return Promise.resolve({ idTagInfo: { status: 'Accepted' } } as unknown as JsonType)
-    })
-
-    const result = createMockChargingStation({
-      baseName: TEST_CHARGING_STATION_BASE_NAME,
-      connectorsCount: 2,
-      ocppRequestService: {
-        requestHandler: requestHandlerMock,
-      },
-      stationInfo: {
-        ocppVersion: OCPPVersion.VERSION_16,
-      },
-    })
-    station = result.station
-    station.isWebSocketConnectionOpened = () => true
-    bindPrivateMethods(station)
-
-    setupConnectorWithTransaction(station, 1, { transactionId: 5001 })
-
-    // Act
-    const testable = station as unknown as TestableChargingStationPrivate
-    await testable.stopRunningTransactions()
-
-    // Assert
-    const stopTransactionCalls = sentRequests.filter(r => r.command === 'StopTransaction')
-    assert.ok(stopTransactionCalls.length > 0, 'Expected at least one StopTransaction request')
-    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
-    assert.strictEqual(
-      transactionEventCalls.length,
-      0,
-      'Should not send TransactionEvent for OCPP 1.6'
-    )
-  })
-
-  await it('should handle errors gracefully when OCPP 2.0 transaction stop fails', async () => {
-    // Arrange
-    const requestHandlerMock = mock.fn(async () => {
-      return Promise.reject(new Error('Simulated network error'))
-    })
-
-    const result = createMockChargingStation({
-      baseName: TEST_CHARGING_STATION_BASE_NAME,
-      connectorsCount: 2,
-      evseConfiguration: { evsesCount: 2 },
-      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
-      ocppRequestService: {
-        requestHandler: requestHandlerMock,
-      },
-      stationInfo: {
-        ocppVersion: OCPPVersion.VERSION_201,
-      },
-      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
-    })
-    station = result.station
-    station.isWebSocketConnectionOpened = () => true
-    bindPrivateMethods(station)
-
-    setupConnectorWithTransaction(station, 1, { transactionId: 2001 })
-    const connector1 = station.getConnectorStatus(1)
-    if (connector1 != null) {
-      connector1.transactionId = 'tx-ocpp20-2001'
-    }
-
-    // Act & Assert — should not throw
-    const testable = station as unknown as TestableChargingStationPrivate
-    await testable.stopRunningTransactions()
-  })
-
-  await it('should also stop pending transactions for OCPP 2.0 stations', async () => {
-    // Arrange
-    const sentRequests: { command: string; payload: Record<string, unknown> }[] = []
-    const requestHandlerMock = mock.fn(async (...args: unknown[]) => {
-      sentRequests.push({
-        command: args[1] as string,
-        payload: args[2] as Record<string, unknown>,
-      })
-      return Promise.resolve({} as EmptyObject)
-    })
-
-    const result = createMockChargingStation({
-      baseName: TEST_CHARGING_STATION_BASE_NAME,
-      connectorsCount: 2,
-      evseConfiguration: { evsesCount: 2 },
-      heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
-      ocppRequestService: {
-        requestHandler: requestHandlerMock,
-      },
-      stationInfo: {
-        ocppVersion: OCPPVersion.VERSION_201,
-      },
-      websocketPingInterval: Constants.DEFAULT_WEBSOCKET_PING_INTERVAL,
-    })
-    station = result.station
-    station.isWebSocketConnectionOpened = () => true
-    bindPrivateMethods(station)
-
-    // Set up a pending transaction (not started, but pending)
-    const connector1 = station.getConnectorStatus(1)
-    if (connector1 != null) {
-      connector1.transactionPending = true
-      connector1.transactionStarted = false
-      connector1.transactionId = 'tx-pending-3001'
-    }
-
-    // Act
-    const testable = station as unknown as TestableChargingStationPrivate
-    await testable.stopRunningTransactions()
-
-    // Assert
-    const transactionEventCalls = sentRequests.filter(r => r.command === 'TransactionEvent')
-    assert.ok(transactionEventCalls.length > 0, 'Expected TransactionEvent for pending transaction')
-  })
-})
index 2ee122d790808490d037f5f8a35fd7ed42227e22..600b0d8c0c86d7fadbe4cbf0f0b71b57d31e5a91 100644 (file)
@@ -7,6 +7,8 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } 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 { OCPPVersion } from '../../src/types/index.js'
 import { standardCleanup, withMockTimers } from '../helpers/TestLifecycleHelpers.js'
 import { TEST_HEARTBEAT_INTERVAL_MS, TEST_ID_TAG } from './ChargingStationTestConstants.js'
@@ -536,7 +538,7 @@ await describe('ChargingStation Transaction Management', async () => {
         }
 
         // Act
-        station.startMeterValues(1, 10000)
+        OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 10000)
 
         // Assert - meter values interval should be created
         if (connector1 != null) {
@@ -556,11 +558,12 @@ await describe('ChargingStation Transaction Management', async () => {
           connector1.transactionStarted = true
           connector1.transactionId = 100
         }
-        station.startMeterValues(1, 10000)
+        OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 10000)
         const firstInterval = connector1?.transactionSetInterval
 
         // Act
-        station.restartMeterValues(1, 15000)
+        OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1)
+        OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 15000)
         const secondInterval = connector1?.transactionSetInterval
 
         // Assert - interval should be different
@@ -580,10 +583,10 @@ await describe('ChargingStation Transaction Management', async () => {
           connector1.transactionStarted = true
           connector1.transactionId = 100
         }
-        station.startMeterValues(1, 10000)
+        OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 10000)
 
         // Act
-        station.stopMeterValues(1)
+        OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1)
 
         // Assert - interval should be cleared
         assert.strictEqual(connector1?.transactionSetInterval, undefined)
@@ -605,7 +608,7 @@ await describe('ChargingStation Transaction Management', async () => {
         }
 
         // Act
-        station.startTxUpdatedInterval(1, 5000)
+        OCPP20ServiceUtils.startPeriodicMeterValues(station, 1, 5000)
 
         // Assert - transaction updated interval should be created
         if (connector1 != null) {
@@ -628,10 +631,10 @@ await describe('ChargingStation Transaction Management', async () => {
           connector1.transactionStarted = true
           connector1.transactionId = 100
         }
-        station.startTxUpdatedInterval(1, 5000)
+        OCPP20ServiceUtils.startPeriodicMeterValues(station, 1, 5000)
 
         // Act
-        station.stopTxUpdatedInterval(1)
+        OCPP20ServiceUtils.stopPeriodicMeterValues(station, 1)
 
         // Assert - interval should be cleared
         assert.strictEqual(connector1?.transactionTxUpdatedSetInterval, undefined)
index 5010a709fd2e652c38817537f6a31d3f3c088977..663be5840088ff4ad41b6ba7b40754cb68e8ab23 100644 (file)
@@ -769,11 +769,6 @@ export function createMockChargingStation (
       this.startHeartbeat()
     },
 
-    restartMeterValues (connectorId: number, interval: number): void {
-      this.stopMeterValues(connectorId)
-      this.startMeterValues(connectorId, interval)
-    },
-
     restartWebSocketPing (): void {
       /* empty */
     },
@@ -794,29 +789,6 @@ export function createMockChargingStation (
     },
     starting,
 
-    startMeterValues (connectorId: number, interval: number): void {
-      const connector = this.getConnectorStatus(connectorId)
-      if (connector != null) {
-        connector.transactionSetInterval = setInterval(() => {
-          /* empty */
-        }, interval)
-      }
-    },
-
-    startTxUpdatedInterval (connectorId: number, interval: number): void {
-      if (
-        this.stationInfo.ocppVersion === OCPPVersion.VERSION_20 ||
-        this.stationInfo.ocppVersion === OCPPVersion.VERSION_201
-      ) {
-        const connector = this.getConnectorStatus(connectorId)
-        if (connector != null) {
-          connector.transactionTxUpdatedSetInterval = setInterval(() => {
-            /* empty */
-          }, interval)
-        }
-      }
-    },
-
     startWebSocketPing (): void {
       /* empty */
     },
@@ -852,22 +824,8 @@ export function createMockChargingStation (
         delete this.heartbeatSetInterval
       }
     },
-    stopMeterValues (connectorId: number): void {
-      const connector = this.getConnectorStatus(connectorId)
-      if (connector?.transactionSetInterval != null) {
-        clearInterval(connector.transactionSetInterval)
-        delete connector.transactionSetInterval
-      }
-    },
     stopping: false,
 
-    stopTxUpdatedInterval (connectorId: number): void {
-      const connector = this.getConnectorStatus(connectorId)
-      if (connector?.transactionTxUpdatedSetInterval != null) {
-        clearInterval(connector.transactionTxUpdatedSetInterval)
-        delete connector.transactionTxUpdatedSetInterval
-      }
-    },
     templateFile,
     wsConnection: null as MockWebSocket | null,
     wsConnectionRetryCount: 0,
index f6b3c1f565c440c9a45c255d29ad3b1cd96bbaa0..09e304fb9e00750ede68c3664aae851677efb64a 100644 (file)
@@ -45,9 +45,10 @@ await describe('OCPP16IncomingRequestService — RemoteStopTransaction and Unloc
       Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
     )
 
-    // Mock stopTransactionOnConnector — called by UnlockConnector when transaction is active
-    station.stopTransactionOnConnector = async () =>
+    // Mock OCPP16ServiceUtils.stopTransactionOnConnector — called by UnlockConnector when transaction is active
+    mock.method(OCPP16ServiceUtils, 'stopTransactionOnConnector', async () =>
       Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.ACCEPTED } })
+    )
   })
 
   afterEach(() => {
@@ -147,8 +148,9 @@ await describe('OCPP16IncomingRequestService — RemoteStopTransaction and Unloc
     await it('should return UnlockFailed when active transaction stop is rejected', async () => {
       // Arrange
       setupConnectorWithTransaction(station, 1, { transactionId: 200 })
-      station.stopTransactionOnConnector = async () =>
+      mock.method(OCPP16ServiceUtils, 'stopTransactionOnConnector', async () =>
         Promise.resolve({ idTagInfo: { status: OCPP16AuthorizationStatus.INVALID } })
+      )
 
       // Act
       const response = await testableService.handleRequestUnlockConnector(station, {
index 63cff80ef765d6edfff6ba73559dddcfd98cf036..f03c1e2932ba10faca7248016d4401f5cedf3a2e 100644 (file)
@@ -7,6 +7,7 @@
  */
 
 import assert from 'node:assert/strict'
+import { mock } from 'node:test'
 import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/index.js'
@@ -24,6 +25,7 @@ import type {
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/__testable__/index.js'
 import { OCPP16IncomingRequestService } from '../../../../src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.js'
 import { OCPP16ResponseService as OCPP16ResponseServiceClass } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
 import {
   AvailabilityType,
   GenericStatus,
@@ -73,12 +75,20 @@ function createIntegrationContext (): {
   const responseService = new OCPP16ResponseServiceClass()
 
   // Mock meter value start/stop to avoid real timer setup
-  station.startMeterValues = (_connectorId: number, _interval: number) => {
-    /* noop */
-  }
-  station.stopMeterValues = (_connectorId: number) => {
-    /* noop */
-  }
+  mock.method(
+    OCPP16ServiceUtils,
+    'startPeriodicMeterValues',
+    (_station: unknown, _connectorId: number, _interval: number) => {
+      /* noop */
+    }
+  )
+  mock.method(
+    OCPP16ServiceUtils,
+    'stopPeriodicMeterValues',
+    (_station: unknown, _connectorId: number) => {
+      /* noop */
+    }
+  )
 
   // Add MeterValues template required by buildTransactionBeginMeterValue
   for (const [connectorId] of station.connectors) {
index 45da19619690cc961edf0e27e3fe34d8c5368e32..ae76e7633aea311c061cf66d57d1b4cd2e7e7f09 100644 (file)
@@ -6,7 +6,7 @@
  */
 
 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 { OCPP16ResponseService } from '../../../../src/charging-station/ocpp/1.6/OCPP16ResponseService.js'
@@ -17,6 +17,7 @@ import type {
   OCPP16StopTransactionResponse,
 } from '../../../../src/types/index.js'
 
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
 import {
   OCPP16AuthorizationStatus,
   OCPP16MeterValueUnit,
@@ -41,12 +42,12 @@ await describe('OCPP16ResponseService — StartTransaction and StopTransaction',
     setMockRequestHandler(station, async () => Promise.resolve({}))
 
     // Mock startMeterValues/stopMeterValues to avoid real timer setup
-    station.startMeterValues = (_connectorId: number, _interval: number) => {
+    mock.method(OCPP16ServiceUtils, 'startPeriodicMeterValues', () => {
       /* noop */
-    }
-    station.stopMeterValues = (_connectorId: number) => {
+    })
+    mock.method(OCPP16ServiceUtils, 'stopPeriodicMeterValues', () => {
       /* noop */
-    }
+    })
 
     // Add MeterValues template required by buildTransactionBeginMeterValue
     for (const [connectorId] of station.connectors) {
index 676ee713e03755edcb6f1700ee88247a630b817c..44ad5a9dfe6de095834bf856fc826de3449da00e 100644 (file)
@@ -21,6 +21,7 @@ import {
   OCPP20ServiceUtils,
 } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
 import { OCPP20VariableManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20VariableManager.js'
+import { startPeriodicMeterValues } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
 import { OCPPError } from '../../../../src/exception/index.js'
 import {
   AttributeEnumType,
@@ -39,7 +40,7 @@ import {
   OCPPVersion,
 } from '../../../../src/types/index.js'
 import { Constants, generateUUID } from '../../../../src/utils/index.js'
-import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { standardCleanup, withMockTimers } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import {
@@ -2036,22 +2037,22 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       standardCleanup()
     })
 
-    await describe('startTxUpdatedInterval', async () => {
-      await it('should not start timer for non-OCPP 2.0 stations', () => {
-        const { station: ocpp16Station } = createMockChargingStation({
-          baseName: TEST_CHARGING_STATION_BASE_NAME,
-          connectorsCount: 1,
-          stationInfo: {
-            ocppVersion: OCPPVersion.VERSION_16,
-          },
-        })
+    await describe('startPeriodicMeterValues', async () => {
+      await it('should not start OCPP 2.0 timer for OCPP 1.6 stations via unified dispatch', async t => {
+        await withMockTimers(t, ['setInterval'], async () => {
+          const { station: ocpp16Station } = createMockChargingStation({
+            baseName: TEST_CHARGING_STATION_BASE_NAME,
+            connectorsCount: 1,
+            stationInfo: {
+              ocppVersion: OCPPVersion.VERSION_16,
+            },
+          })
 
-        // Call startTxUpdatedInterval on OCPP 1.6 station
-        ocpp16Station.startTxUpdatedInterval(1, 60000)
+          await startPeriodicMeterValues(ocpp16Station, 1, 60000)
 
-        // Verify no timer was started (method should return early)
-        const connector = ocpp16Station.getConnectorStatus(1)
-        assert.strictEqual(connector?.transactionTxUpdatedSetInterval, undefined)
+          const connector = ocpp16Station.getConnectorStatus(1)
+          assert.strictEqual(connector?.transactionTxUpdatedSetInterval, undefined)
+        })
       })
 
       await it('should not start timer when interval is zero', () => {
diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts
new file mode 100644 (file)
index 0000000..fe32393
--- /dev/null
@@ -0,0 +1,336 @@
+/**
+ * @file Tests for OCPPServiceUtils stop transaction functions
+ * @description Verifies stopTransactionOnConnector and stopRunningTransactions
+ *              version-dispatching functions
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it, mock } from 'node:test'
+
+import type { ChargingStation } from '../../../src/charging-station/index.js'
+import type { MockChargingStationOptions } from '../helpers/StationHelpers.js'
+
+import {
+  flushQueuedTransactionMessages,
+  mapStopReasonToOCPP20,
+  startTransactionOnConnector,
+  stopRunningTransactions,
+  stopTransactionOnConnector,
+} from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import {
+  type OCPP20TransactionEventRequest,
+  OCPPVersion,
+  type StopTransactionReason,
+} from '../../../src/types/index.js'
+import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
+import { createMockChargingStation } from '../ChargingStationTestUtils.js'
+
+/**
+ * Creates a mock charging station with a tracked request handler for testing
+ * @param opts - optional charging station configuration options
+ * @returns object with the mock station and the mock request handler function
+ */
+function createStationWithRequestHandler (opts?: Partial<MockChargingStationOptions>): {
+  requestHandler: ReturnType<typeof mock.fn>
+  station: ChargingStation
+} {
+  const requestHandler = mock.fn(async (..._args: unknown[]) => Promise.resolve({}))
+  const { station } = createMockChargingStation({
+    ocppRequestService: { requestHandler },
+    ...opts,
+  })
+  return { requestHandler, station }
+}
+
+/**
+ * Configures a connector with a pending (not started) transaction for testing
+ * @param station - the charging station mock
+ * @param connectorId - the connector ID to configure
+ * @param txId - the transaction ID to assign
+ */
+function setupPendingTransaction (
+  station: ChargingStation,
+  connectorId: number,
+  txId: string
+): void {
+  const connector = station.getConnectorStatus(connectorId)
+  if (connector == null) {
+    throw new Error(`Connector ${String(connectorId)} not found`)
+  }
+  connector.transactionPending = true
+  connector.transactionStarted = false
+  connector.transactionId = txId
+  connector.transactionStart = new Date()
+}
+
+/**
+ * Configures a connector with a started transaction for testing
+ * @param station - the charging station mock
+ * @param connectorId - the connector ID to configure
+ * @param txId - the transaction ID to assign
+ */
+function setupTransaction (
+  station: ChargingStation,
+  connectorId: number,
+  txId: number | string
+): void {
+  const connector = station.getConnectorStatus(connectorId)
+  if (connector == null) {
+    throw new Error(`Connector ${String(connectorId)} not found`)
+  }
+  connector.transactionStarted = true
+  connector.transactionId = txId
+  connector.transactionIdTag = `TAG-${String(txId)}`
+  connector.transactionStart = new Date()
+  connector.idTagAuthorized = true
+}
+
+await describe('OCPPServiceUtils — stop transaction functions', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  await describe('stopTransactionOnConnector', async () => {
+    await it('should send StopTransaction for OCPP 1.6 stations and return accepted: true', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler()
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+        Promise.resolve({ idTagInfo: { status: 'Accepted' } })
+      )
+      setupTransaction(station, 1, 100)
+
+      const result = await stopTransactionOnConnector(station, 1)
+
+      assert.strictEqual(result.accepted, true)
+      assert.ok(requestHandler.mock.calls.length >= 1)
+      assert.strictEqual(requestHandler.mock.calls[0].arguments[1] as string, 'StopTransaction')
+    })
+
+    await it('should send TransactionEvent(Ended) for OCPP 2.0 stations and return accepted: true', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler({
+        evseConfiguration: { evsesCount: 1 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+        Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
+      )
+      setupTransaction(station, 1, 'tx-uuid-001')
+
+      const result = await stopTransactionOnConnector(station, 1)
+
+      assert.strictEqual(result.accepted, true)
+      assert.ok(requestHandler.mock.calls.length >= 1)
+      assert.strictEqual(requestHandler.mock.calls[0].arguments[1] as string, 'TransactionEvent')
+    })
+
+    await it('should throw OCPPError for unsupported OCPP version in stopTransactionOnConnector', async () => {
+      const { station } = createStationWithRequestHandler()
+      const stationInfo = station.stationInfo
+      if (stationInfo != null) {
+        ;(stationInfo as Record<string, unknown>).ocppVersion = '0.9'
+      }
+
+      await assert.rejects(
+        () => stopTransactionOnConnector(station, 1),
+        (error: Error) => {
+          assert.ok(error.message.includes('unsupported OCPP version'))
+          return true
+        }
+      )
+    })
+  })
+
+  await describe('stopRunningTransactions', async () => {
+    await it('should call stopTransactionOnConnector sequentially for each OCPP 1.6 connector with active transaction', async () => {
+      const sentCommands: string[] = []
+      const { requestHandler, station } = createStationWithRequestHandler({
+        connectorsCount: 2,
+      })
+      requestHandler.mock.mockImplementation(async (...args: unknown[]) => {
+        sentCommands.push(args[1] as string)
+        return Promise.resolve({ idTagInfo: { status: 'Accepted' } })
+      })
+      setupTransaction(station, 1, 101)
+      setupTransaction(station, 2, 102)
+
+      await stopRunningTransactions(station)
+
+      const stopCalls = sentCommands.filter(cmd => cmd === 'StopTransaction')
+      assert.strictEqual(stopCalls.length, 2)
+    })
+
+    await it('should call requestStopTransaction in parallel for OCPP 2.0 connectors', async () => {
+      const sentPayloads: { command: string; transactionId?: string }[] = []
+      const { requestHandler, station } = createStationWithRequestHandler({
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 2 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (...args: unknown[]) => {
+        const payload = args[2] as Record<string, unknown>
+        sentPayloads.push({
+          command: args[1] as string,
+          transactionId: payload.transactionId as string | undefined,
+        })
+        return Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
+      })
+      setupTransaction(station, 1, 'tx-001')
+      setupTransaction(station, 2, 'tx-002')
+
+      await stopRunningTransactions(station)
+
+      const txEventCalls = sentPayloads.filter(p => p.command === 'TransactionEvent')
+      assert.strictEqual(txEventCalls.length, 2)
+      const txIds = txEventCalls.map(p => p.transactionId)
+      assert.ok(txIds.includes('tx-001'))
+      assert.ok(txIds.includes('tx-002'))
+    })
+
+    await it('should include pending transactions in OCPP 2.0 stopRunningTransactions', async () => {
+      const sentPayloads: { command: string; transactionId?: string }[] = []
+      const { requestHandler, station } = createStationWithRequestHandler({
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 2 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (...args: unknown[]) => {
+        const payload = args[2] as Record<string, unknown>
+        sentPayloads.push({
+          command: args[1] as string,
+          transactionId: payload.transactionId as string | undefined,
+        })
+        return Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
+      })
+      setupTransaction(station, 1, 'tx-started')
+      setupPendingTransaction(station, 2, 'tx-pending')
+
+      await stopRunningTransactions(station)
+
+      const txEventCalls = sentPayloads.filter(p => p.command === 'TransactionEvent')
+      assert.strictEqual(txEventCalls.length, 2)
+    })
+
+    await it('should handle errors gracefully when OCPP 2.0 transaction stop fails', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler({
+        connectorsCount: 2,
+        evseConfiguration: { evsesCount: 2 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async () =>
+        Promise.reject(new Error('Simulated network error'))
+      )
+      setupTransaction(station, 1, 'tx-fail')
+
+      await assert.doesNotReject(() => stopRunningTransactions(station))
+    })
+  })
+
+  await describe('startTransactionOnConnector', async () => {
+    await it('should send StartTransaction for OCPP 1.6 stations and return accepted: true', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler()
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+        Promise.resolve({ idTagInfo: { status: 'Accepted' }, transactionId: 1 })
+      )
+
+      const result = await startTransactionOnConnector(station, 1, 'TAG001')
+
+      assert.strictEqual(result.accepted, true)
+      assert.ok(requestHandler.mock.calls.length >= 1)
+      assert.strictEqual(requestHandler.mock.calls[0].arguments[1] as string, 'StartTransaction')
+    })
+
+    await it('should send TransactionEvent(Started) for OCPP 2.0 stations and return accepted: true', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler({
+        evseConfiguration: { evsesCount: 1 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+        Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
+      )
+
+      const result = await startTransactionOnConnector(station, 1, 'TAG002')
+
+      assert.strictEqual(result.accepted, true)
+      assert.ok(requestHandler.mock.calls.length >= 1)
+      assert.strictEqual(requestHandler.mock.calls[0].arguments[1] as string, 'TransactionEvent')
+    })
+
+    await it('should generate transactionId for OCPP 2.0 when not pre-populated', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler({
+        evseConfiguration: { evsesCount: 1 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) =>
+        Promise.resolve({ idTokenInfo: { status: 'Accepted' } })
+      )
+      const connector = station.getConnectorStatus(1)
+      assert.notStrictEqual(connector, undefined)
+      assert(connector != null)
+      delete connector.transactionId
+
+      await startTransactionOnConnector(station, 1)
+
+      assert.notStrictEqual(connector.transactionId, undefined)
+      assert.strictEqual(typeof connector.transactionId, 'string')
+    })
+  })
+
+  await describe('flushQueuedTransactionMessages', async () => {
+    await it('should be a no-op for OCPP 1.6 stations', async () => {
+      const { station } = createStationWithRequestHandler()
+
+      await assert.doesNotReject(() => flushQueuedTransactionMessages(station))
+    })
+
+    await it('should flush queued events for OCPP 2.0 stations', async () => {
+      const { requestHandler, station } = createStationWithRequestHandler({
+        evseConfiguration: { evsesCount: 1 },
+        ocppVersion: OCPPVersion.VERSION_20,
+      })
+      requestHandler.mock.mockImplementation(async (..._args: unknown[]) => Promise.resolve({}))
+      const connector = station.getConnectorStatus(1)
+      assert.notStrictEqual(connector, undefined)
+      assert(connector != null)
+      connector.transactionEventQueue = [
+        {
+          request: {
+            eventType: 'Updated',
+            offline: true,
+            seqNo: 1,
+            timestamp: new Date().toISOString(),
+            transactionInfo: { transactionId: '550e8400-e29b-41d4-a716-446655440000' },
+            triggerReason: 'MeterValuePeriodic',
+          } as unknown as OCPP20TransactionEventRequest,
+          seqNo: 1,
+          timestamp: new Date(),
+        },
+      ]
+
+      await flushQueuedTransactionMessages(station)
+
+      assert.strictEqual(connector.transactionEventQueue.length, 0)
+    })
+  })
+
+  await describe('mapStopReasonToOCPP20', async () => {
+    await it('should map Other to Other/AbnormalCondition', () => {
+      const result = mapStopReasonToOCPP20('Other' as StopTransactionReason)
+
+      assert.strictEqual(result.stoppedReason, 'Other')
+      assert.strictEqual(result.triggerReason, 'AbnormalCondition')
+    })
+
+    await it('should map undefined to Local/StopAuthorized', () => {
+      const result = mapStopReasonToOCPP20(undefined)
+
+      assert.strictEqual(result.stoppedReason, 'Local')
+      assert.strictEqual(result.triggerReason, 'StopAuthorized')
+    })
+
+    await it('should map Remote to Remote/RemoteStop', () => {
+      const result = mapStopReasonToOCPP20('Remote' as StopTransactionReason)
+
+      assert.strictEqual(result.stoppedReason, 'Remote')
+      assert.strictEqual(result.triggerReason, 'RemoteStop')
+    })
+  })
+})