]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2): implement StopTxOnInvalidId and MaxEnergyOnInvalidId per E05 (#1745)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 22 Mar 2026 18:26:00 +0000 (19:26 +0100)
committerGitHub <noreply@github.com>
Sun, 22 Mar 2026 18:26:00 +0000 (19:26 +0100)
* fix(ocpp2): implement StopTxOnInvalidId and MaxEnergyOnInvalidId per E05

Implement the full deauthorization decision tree per OCPP 2.0.1 spec:
- StopTxOnInvalidId=false: transaction continues, no termination
- StopTxOnInvalidId=true + MaxEnergyOnInvalidId>0: defer termination
  until energy limit reached (checked in periodic meter values callback)
- StopTxOnInvalidId=true + MaxEnergyOnInvalidId=0: immediate suspension
  and termination

Also includes:
- Deduplicate transactionSetInterval/transactionTxUpdatedSetInterval
  into single transactionMeterValuesSetInterval field
- Add transactionDeauthorized/transactionDeauthorizedEnergyWh to
  ConnectorStatus for deauth state tracking (cleared in resetConnectorStatus)
- Extract readVariableValue/readVariableAsBoolean/readVariableAsInteger
  helpers using convertToInt and convertToBoolean utilities
- Fix buildEvsesStatus test that was missing evse-to-connector wiring

* refactor(ocpp2): address PR review comments

- Add logger.warn in readVariableAsInteger catch for diagnostic
- Replace unsafe 'as string' cast with .toString() in periodic callback
- Add tests for StopTxOnInvalidId=false and deauth state tracking

* chore: update webui.png

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
* test: add resetConnectorStatus unit tests to Helpers.test.ts

Verify all 18 transaction fields are properly cleaned, TX_PROFILE
charging profiles matching the transaction are removed, and
non-transaction fields are preserved.

---------

Signed-off-by: Jérôme Benoit <jerome.benoit@sap.com>
12 files changed:
src/charging-station/Helpers.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/types/ConnectorStatus.ts
src/utils/ChargingStationConfigurationUtils.ts
tests/charging-station/ChargingStation-Transactions.test.ts
tests/charging-station/Helpers.test.ts
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/helpers/TestLifecycleHelpers.ts
tests/utils/ChargingStationConfigurationUtils.test.ts
ui/web/src/assets/webui.png

index 85bf932223f678b848ad097ee1c9ff34248c08fd..2ae5aedb130e7991a90c0a61cba4f0a2314000e2 100644 (file)
@@ -568,6 +568,8 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
   delete connectorStatus.transactionSeqNo
   delete connectorStatus.transactionEvseSent
   delete connectorStatus.transactionIdTokenSent
+  delete connectorStatus.transactionDeauthorized
+  delete connectorStatus.transactionDeauthorizedEnergyWh
 }
 
 export const prepareConnectorStatus = (connectorStatus: ConnectorStatus): ConnectorStatus => {
index 9b4c20292d04f25741452b1d72947a9e18f4a724..ff5b5971944dff54720bb343284becd31800265a 100644 (file)
@@ -617,7 +617,7 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils {
       )
       return
     }
-    connectorStatus.transactionSetInterval = setInterval(() => {
+    connectorStatus.transactionMeterValuesSetInterval = setInterval(() => {
       const transactionId = convertToInt(connectorStatus.transactionId)
       const meterValue = buildMeterValue(chargingStation, connectorId, transactionId, interval)
       chargingStation.ocppRequestService
@@ -658,9 +658,9 @@ export class OCPP16ServiceUtils extends OCPPServiceUtils {
     connectorId: number
   ): void {
     const connectorStatus = chargingStation.getConnectorStatus(connectorId)
-    if (connectorStatus?.transactionSetInterval != null) {
-      clearInterval(connectorStatus.transactionSetInterval)
-      delete connectorStatus.transactionSetInterval
+    if (connectorStatus?.transactionMeterValuesSetInterval != null) {
+      clearInterval(connectorStatus.transactionMeterValuesSetInterval)
+      delete connectorStatus.transactionMeterValuesSetInterval
     }
   }
 
index 711de44638f4a173fe2d6583154c7257c191fd25..abea3b6036d12c89d05afd96ed2e13a12839c01a 100644 (file)
@@ -29,6 +29,8 @@ import {
 import {
   clampToSafeTimerValue,
   Constants,
+  convertToBoolean,
+  convertToInt,
   convertToIntOrNaN,
   formatDurationMilliSeconds,
   generateUUID,
@@ -296,7 +298,6 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     return { bytesLimit, itemsLimit }
   }
 
-  // E05.FR.09/FR.10 + E06.FR.04: Updated(Deauthorized) → Ended(DeAuthorized). Assumes StopTxOnInvalidId=true.
   public static async requestDeauthorizeTransaction (
     chargingStation: ChargingStation,
     connectorId: number,
@@ -307,6 +308,50 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       connectorId
     )
 
+    const stopTxOnInvalidId = OCPP20ServiceUtils.readVariableAsBoolean(
+      chargingStation,
+      OCPP20ComponentName.TxCtrlr,
+      OCPP20RequiredVariableName.StopTxOnInvalidId,
+      true
+    )
+
+    if (!stopTxOnInvalidId) {
+      await this.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Updated,
+        OCPP20TriggerReasonEnumType.Deauthorized,
+        connectorId,
+        transactionId,
+        { evseId }
+      )
+      return { idTokenInfo: undefined }
+    }
+
+    const maxEnergyOnInvalidId = OCPP20ServiceUtils.readVariableAsInteger(
+      chargingStation,
+      OCPP20ComponentName.TxCtrlr,
+      'MaxEnergyOnInvalidId',
+      0
+    )
+
+    if (maxEnergyOnInvalidId > 0) {
+      // E05.FR.03: continue charging up to MaxEnergyOnInvalidId Wh before terminating
+      connectorStatus.transactionDeauthorized = true
+      connectorStatus.transactionDeauthorizedEnergyWh =
+        connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+
+      await this.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Updated,
+        OCPP20TriggerReasonEnumType.Deauthorized,
+        connectorId,
+        transactionId,
+        { evseId }
+      )
+
+      return { idTokenInfo: undefined }
+    }
+
     await this.sendTransactionEvent(
       chargingStation,
       OCPP20TransactionEventEnumType.Updated,
@@ -500,15 +545,46 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       )
       return
     }
-    if (connector.transactionTxUpdatedSetInterval != null) {
+    if (connector.transactionMeterValuesSetInterval != null) {
       logger.warn(
         `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: TxUpdatedInterval already started, stopping first`
       )
       OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
     }
-    connector.transactionTxUpdatedSetInterval = setInterval(() => {
+    connector.transactionMeterValuesSetInterval = setInterval(() => {
       const connectorStatus = chargingStation.getConnectorStatus(connectorId)
       if (connectorStatus?.transactionStarted === true && connectorStatus.transactionId != null) {
+        if (
+          connectorStatus.transactionDeauthorized === true &&
+          connectorStatus.transactionDeauthorizedEnergyWh != null
+        ) {
+          const maxEnergy = OCPP20ServiceUtils.readVariableAsInteger(
+            chargingStation,
+            OCPP20ComponentName.TxCtrlr,
+            'MaxEnergyOnInvalidId',
+            0
+          )
+          const currentEnergy = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
+          const energySinceDeauth = currentEnergy - connectorStatus.transactionDeauthorizedEnergyWh
+          if (maxEnergy > 0 && energySinceDeauth >= maxEnergy) {
+            const evseId = chargingStation.getEvseIdByConnectorId(connectorId)
+            OCPP20ServiceUtils.terminateTransaction(
+              chargingStation,
+              connectorId,
+              connectorStatus,
+              connectorStatus.transactionId.toString(),
+              OCPP20TriggerReasonEnumType.Deauthorized,
+              OCPP20ReasonEnumType.DeAuthorized,
+              evseId
+            ).catch((error: unknown) => {
+              logger.error(
+                `${chargingStation.logPrefix()} ${moduleName}.startPeriodicMeterValues: Error terminating deauthorized transaction:`,
+                error
+              )
+            })
+            return
+          }
+        }
         const meterValue = buildMeterValue(
           chargingStation,
           connectorId,
@@ -599,9 +675,9 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     connectorId: number
   ): void {
     const connector = chargingStation.getConnectorStatus(connectorId)
-    if (connector?.transactionTxUpdatedSetInterval != null) {
-      clearInterval(connector.transactionTxUpdatedSetInterval)
-      delete connector.transactionTxUpdatedSetInterval
+    if (connector?.transactionMeterValuesSetInterval != null) {
+      clearInterval(connector.transactionMeterValuesSetInterval)
+      delete connector.transactionMeterValuesSetInterval
       logger.info(
         `${chargingStation.logPrefix()} ${moduleName}.stopPeriodicMeterValues: TxUpdatedInterval stopped`
       )
@@ -638,12 +714,58 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     )
   }
 
+  private static readVariableAsBoolean (
+    chargingStation: ChargingStation,
+    componentName: string,
+    variableName: string,
+    defaultValue: boolean
+  ): boolean {
+    const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName)
+    return value != null ? convertToBoolean(value) : defaultValue
+  }
+
+  private static readVariableAsInteger (
+    chargingStation: ChargingStation,
+    componentName: string,
+    variableName: string,
+    defaultValue: number
+  ): number {
+    const value = OCPP20ServiceUtils.readVariableValue(chargingStation, componentName, variableName)
+    if (value != null) {
+      try {
+        return convertToInt(value)
+      } catch {
+        logger.warn(
+          `${moduleName}.readVariableAsInteger: Cannot convert '${value}' to integer for ${componentName}.${variableName}, using default ${defaultValue.toString()}`
+        )
+        return defaultValue
+      }
+    }
+    return defaultValue
+  }
+
   private static readVariableAsIntervalMs (
     chargingStation: ChargingStation,
     componentName: string,
     variableName: string,
     defaultSeconds: number
   ): number {
+    const intervalSeconds = OCPP20ServiceUtils.readVariableAsInteger(
+      chargingStation,
+      componentName,
+      variableName,
+      defaultSeconds
+    )
+    return intervalSeconds > 0
+      ? secondsToMilliseconds(intervalSeconds)
+      : secondsToMilliseconds(defaultSeconds)
+  }
+
+  private static readVariableValue (
+    chargingStation: ChargingStation,
+    componentName: string,
+    variableName: string
+  ): string | undefined {
     const variableManager = OCPP20VariableManager.getInstance()
     const results = variableManager.getVariables(chargingStation, [
       {
@@ -652,12 +774,9 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
       },
     ])
     if (results.length > 0 && results[0].attributeValue != null) {
-      const intervalSeconds = parseInt(results[0].attributeValue, 10)
-      if (!isNaN(intervalSeconds) && intervalSeconds > 0) {
-        return secondsToMilliseconds(intervalSeconds)
-      }
+      return results[0].attributeValue
     }
-    return secondsToMilliseconds(defaultSeconds)
+    return undefined
   }
 
   private static resolveActiveTransaction (
index f6f40ec1b0ff4535f9d095bb9aa173c82dd9826b..5243c157c4858719b78cc8e76968d85e16efa360 100644 (file)
@@ -21,6 +21,8 @@ export interface ConnectorStatus {
   reservation?: Reservation
   status?: ConnectorStatusEnum
   transactionBeginMeterValue?: MeterValue
+  transactionDeauthorized?: boolean
+  transactionDeauthorizedEnergyWh?: number
   transactionEnergyActiveImportRegisterValue?: number // In Wh
   /**
    * OCPP 2.0.1 offline-first: Queue of TransactionEvents waiting to be sent
@@ -43,6 +45,7 @@ export interface ConnectorStatus {
    * that occurs after the transaction has been authorized.
    */
   transactionIdTokenSent?: boolean
+  transactionMeterValuesSetInterval?: NodeJS.Timeout
   /**
    * OCPP 2.0.1 E02 compliance: Transaction pending CSMS acknowledgment.
    * Blocks duplicate RequestStartTransaction until response handler sets transactionStarted.
@@ -50,10 +53,8 @@ export interface ConnectorStatus {
   transactionPending?: boolean
   transactionRemoteStarted?: boolean
   transactionSeqNo?: number
-  transactionSetInterval?: NodeJS.Timeout
   transactionStart?: Date
   transactionStarted?: boolean
-  transactionTxUpdatedSetInterval?: NodeJS.Timeout
   type?: ConnectorEnumType
 }
 
index c69af9d54c018540525523d062741b07dc83c33d..db0e6fb42e8450c78c4ad3156d5b7ad76c629e4a 100644 (file)
@@ -34,12 +34,7 @@ export const buildConnectorEntries = (chargingStation: ChargingStation): Connect
   return [...chargingStation.connectors.entries()].map(
     ([
       connectorId,
-      {
-        transactionEventQueue,
-        transactionSetInterval,
-        transactionTxUpdatedSetInterval,
-        ...connector
-      },
+      { transactionEventQueue, transactionMeterValuesSetInterval, ...connector },
     ]) => ({
       connector,
       connectorId,
@@ -53,12 +48,7 @@ export const buildConnectorsStatus = (
   return [...chargingStation.connectors.entries()].map(
     ([
       connectorId,
-      {
-        transactionEventQueue,
-        transactionSetInterval,
-        transactionTxUpdatedSetInterval,
-        ...connectorStatus
-      },
+      { transactionEventQueue, transactionMeterValuesSetInterval, ...connectorStatus },
     ]) => [connectorId, connectorStatus]
   )
 }
@@ -69,12 +59,7 @@ export const buildEvseEntries = (chargingStation: ChargingStation): EvseEntry[]
     connectors: [...evseStatus.connectors.entries()].map(
       ([
         connectorId,
-        {
-          transactionEventQueue,
-          transactionSetInterval,
-          transactionTxUpdatedSetInterval,
-          ...connector
-        },
+        { transactionEventQueue, transactionMeterValuesSetInterval, ...connector },
       ]) => ({
         connector,
         connectorId,
@@ -91,12 +76,7 @@ export const buildEvsesStatus = (
     const connectorsStatus: [number, ConnectorStatus][] = [...evseStatus.connectors.entries()].map(
       ([
         connectorId,
-        {
-          transactionEventQueue,
-          transactionSetInterval,
-          transactionTxUpdatedSetInterval,
-          ...connector
-        },
+        { transactionEventQueue, transactionMeterValuesSetInterval, ...connector },
       ]) => [connectorId, connector]
     )
     const { connectors: _, ...evseStatusRest } = evseStatus
index 600b0d8c0c86d7fadbe4cbf0f0b71b57d31e5a91..dd528e64dd7314df4b6e71bdb6b58e40f9b3a390 100644 (file)
@@ -542,8 +542,8 @@ await describe('ChargingStation Transaction Management', async () => {
 
         // Assert - meter values interval should be created
         if (connector1 != null) {
-          assert.notStrictEqual(connector1.transactionSetInterval, undefined)
-          assert.strictEqual(typeof connector1.transactionSetInterval, 'object')
+          assert.notStrictEqual(connector1.transactionMeterValuesSetInterval, undefined)
+          assert.strictEqual(typeof connector1.transactionMeterValuesSetInterval, 'object')
         }
       })
     })
@@ -559,12 +559,12 @@ await describe('ChargingStation Transaction Management', async () => {
           connector1.transactionId = 100
         }
         OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 10000)
-        const firstInterval = connector1?.transactionSetInterval
+        const firstInterval = connector1?.transactionMeterValuesSetInterval
 
         // Act
         OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1)
         OCPP16ServiceUtils.startPeriodicMeterValues(station, 1, 15000)
-        const secondInterval = connector1?.transactionSetInterval
+        const secondInterval = connector1?.transactionMeterValuesSetInterval
 
         // Assert - interval should be different
         assert.notStrictEqual(secondInterval, undefined)
@@ -589,7 +589,7 @@ await describe('ChargingStation Transaction Management', async () => {
         OCPP16ServiceUtils.stopPeriodicMeterValues(station, 1)
 
         // Assert - interval should be cleared
-        assert.strictEqual(connector1?.transactionSetInterval, undefined)
+        assert.strictEqual(connector1?.transactionMeterValuesSetInterval, undefined)
       })
     })
 
@@ -612,8 +612,8 @@ await describe('ChargingStation Transaction Management', async () => {
 
         // Assert - transaction updated interval should be created
         if (connector1 != null) {
-          assert.notStrictEqual(connector1.transactionTxUpdatedSetInterval, undefined)
-          assert.strictEqual(typeof connector1.transactionTxUpdatedSetInterval, 'object')
+          assert.notStrictEqual(connector1.transactionMeterValuesSetInterval, undefined)
+          assert.strictEqual(typeof connector1.transactionMeterValuesSetInterval, 'object')
         }
       })
     })
@@ -637,7 +637,7 @@ await describe('ChargingStation Transaction Management', async () => {
         OCPP20ServiceUtils.stopPeriodicMeterValues(station, 1)
 
         // Assert - interval should be cleared
-        assert.strictEqual(connector1?.transactionTxUpdatedSetInterval, undefined)
+        assert.strictEqual(connector1?.transactionMeterValuesSetInterval, undefined)
       })
     })
   })
index 3bba97cff875cf0956a7011a22c444c163e7757f..6f522fe44fa963fc21c2d7c0f1123d31658fda73 100644 (file)
@@ -19,17 +19,22 @@ import {
   hasPendingReservation,
   hasPendingReservations,
   hasReservationExpired,
+  resetConnectorStatus,
   validateStationInfo,
 } from '../../src/charging-station/Helpers.js'
 import {
   AvailabilityType,
+  type ChargingProfile,
+  ChargingProfilePurposeType,
   type ChargingStationConfiguration,
   type ChargingStationInfo,
   type ChargingStationTemplate,
   type ConnectorStatus,
   ConnectorStatusEnum,
+  type MeterValue,
   OCPPVersion,
   type Reservation,
+  type SampledValueTemplate,
 } from '../../src/types/index.js'
 import { logger } from '../../src/utils/index.js'
 import { standardCleanup } from '../helpers/TestLifecycleHelpers.js'
@@ -771,4 +776,103 @@ await describe('Helpers', async () => {
     // Act & Assert
     assert.strictEqual(hasPendingReservations(chargingStation), false)
   })
+
+  await describe('resetConnectorStatus', async () => {
+    afterEach(() => {
+      standardCleanup()
+    })
+
+    await it('should be a no-op for undefined input', () => {
+      resetConnectorStatus(undefined)
+    })
+
+    await it('should reset all transaction fields', () => {
+      const connectorStatus: ConnectorStatus = {
+        availability: AvailabilityType.Operative,
+        MeterValues: [],
+        transactionBeginMeterValue: { sampledValue: [], timestamp: new Date() } as MeterValue,
+        transactionDeauthorized: true,
+        transactionDeauthorizedEnergyWh: 500,
+        transactionEnergyActiveImportRegisterValue: 1234,
+        transactionEvseSent: true,
+        transactionGroupIdToken: 'group-token',
+        transactionId: 'tx-123',
+        transactionIdTag: 'tag-abc',
+        transactionIdTokenSent: true,
+        transactionPending: true,
+        transactionRemoteStarted: true,
+        transactionSeqNo: 5,
+        transactionStart: new Date(),
+        transactionStarted: true,
+      }
+
+      resetConnectorStatus(connectorStatus)
+
+      assert.strictEqual(connectorStatus.transactionStarted, false)
+      assert.strictEqual(connectorStatus.transactionPending, false)
+      assert.strictEqual(connectorStatus.transactionRemoteStarted, false)
+      assert.strictEqual(connectorStatus.transactionEnergyActiveImportRegisterValue, 0)
+      assert.strictEqual(connectorStatus.transactionId, undefined)
+      assert.strictEqual(connectorStatus.transactionIdTag, undefined)
+      assert.strictEqual(connectorStatus.transactionGroupIdToken, undefined)
+      assert.strictEqual(connectorStatus.transactionStart, undefined)
+      assert.strictEqual(connectorStatus.transactionBeginMeterValue, undefined)
+      assert.strictEqual(connectorStatus.transactionSeqNo, undefined)
+      assert.strictEqual(connectorStatus.transactionEvseSent, undefined)
+      assert.strictEqual(connectorStatus.transactionIdTokenSent, undefined)
+      assert.strictEqual(connectorStatus.transactionDeauthorized, undefined)
+      assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, undefined)
+      assert.strictEqual(connectorStatus.idTagAuthorized, false)
+      assert.strictEqual(connectorStatus.idTagLocalAuthorized, false)
+      assert.strictEqual(connectorStatus.authorizeIdTag, undefined)
+      assert.strictEqual(connectorStatus.localAuthorizeIdTag, undefined)
+    })
+
+    await it('should remove TX_PROFILE charging profiles matching transaction', () => {
+      const txProfile = {
+        chargingProfileId: 1,
+        chargingProfilePurpose: ChargingProfilePurposeType.TX_PROFILE,
+        stackLevel: 0,
+        transactionId: 'tx-123',
+      } as unknown as ChargingProfile
+      const otherProfile = {
+        chargingProfileId: 2,
+        chargingProfilePurpose: ChargingProfilePurposeType.TX_DEFAULT_PROFILE,
+        stackLevel: 0,
+      } as unknown as ChargingProfile
+      const connectorStatus: ConnectorStatus = {
+        availability: AvailabilityType.Operative,
+        chargingProfiles: [txProfile, otherProfile],
+        MeterValues: [],
+        transactionId: 'tx-123',
+        transactionStarted: true,
+      }
+
+      resetConnectorStatus(connectorStatus)
+
+      if (connectorStatus.chargingProfiles == null) {
+        assert.fail('chargingProfiles should not be undefined')
+      }
+      assert.strictEqual(connectorStatus.chargingProfiles.length, 1)
+      assert.strictEqual(
+        connectorStatus.chargingProfiles[0].chargingProfilePurpose,
+        ChargingProfilePurposeType.TX_DEFAULT_PROFILE
+      )
+    })
+
+    await it('should preserve non-transaction fields', () => {
+      const connectorStatus: ConnectorStatus = {
+        availability: AvailabilityType.Operative,
+        MeterValues: [{} as unknown as SampledValueTemplate],
+        status: ConnectorStatusEnum.Available,
+        transactionStarted: true,
+      }
+
+      resetConnectorStatus(connectorStatus)
+
+      assert.strictEqual(connectorStatus.availability, AvailabilityType.Operative)
+      assert.strictEqual(connectorStatus.status, ConnectorStatusEnum.Available)
+      assert.strictEqual(connectorStatus.MeterValues.length, 1)
+    })
+  })
 })
index 4b25e6fce55dc1972ce33cdaa424094c7f151cbb..b89b46de201d45b5c6640832c0f42a7c86ad5ac0 100644 (file)
@@ -218,26 +218,18 @@ export function cleanupChargingStation (station: ChargingStation): void {
 
   // Clear connector transaction state and timers
   for (const connectorStatus of station.connectors.values()) {
-    if (connectorStatus.transactionSetInterval != null) {
-      clearInterval(connectorStatus.transactionSetInterval)
-      connectorStatus.transactionSetInterval = undefined
-    }
-    if (connectorStatus.transactionTxUpdatedSetInterval != null) {
-      clearInterval(connectorStatus.transactionTxUpdatedSetInterval)
-      connectorStatus.transactionTxUpdatedSetInterval = undefined
+    if (connectorStatus.transactionMeterValuesSetInterval != null) {
+      clearInterval(connectorStatus.transactionMeterValuesSetInterval)
+      connectorStatus.transactionMeterValuesSetInterval = undefined
     }
   }
 
   // Clear EVSE connector transaction state and timers
   for (const evseStatus of station.evses.values()) {
     for (const connectorStatus of evseStatus.connectors.values()) {
-      if (connectorStatus.transactionSetInterval != null) {
-        clearInterval(connectorStatus.transactionSetInterval)
-        connectorStatus.transactionSetInterval = undefined
-      }
-      if (connectorStatus.transactionTxUpdatedSetInterval != null) {
-        clearInterval(connectorStatus.transactionTxUpdatedSetInterval)
-        connectorStatus.transactionTxUpdatedSetInterval = undefined
+      if (connectorStatus.transactionMeterValuesSetInterval != null) {
+        clearInterval(connectorStatus.transactionMeterValuesSetInterval)
+        connectorStatus.transactionMeterValuesSetInterval = undefined
       }
     }
   }
@@ -954,8 +946,8 @@ function resetConnectorStatus (status: ConnectorStatus, isConnectorZero: boolean
   status.transactionEnergyActiveImportRegisterValue = 0
 
   // Clear transaction interval
-  if (status.transactionSetInterval != null) {
-    clearInterval(status.transactionSetInterval)
-    status.transactionSetInterval = undefined
+  if (status.transactionMeterValuesSetInterval != null) {
+    clearInterval(status.transactionMeterValuesSetInterval)
+    status.transactionMeterValuesSetInterval = undefined
   }
 }
index 0e95ffa2dd0a631e8cbeefd6a701b25762a58765..0af0ef59421a8a80e71291fc98ebfc13a7cf3561 100644 (file)
@@ -2032,9 +2032,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       // Clean up any running timers
       for (let connectorId = 1; connectorId <= 3; connectorId++) {
         const connector = mockStation.getConnectorStatus(connectorId)
-        if (connector?.transactionTxUpdatedSetInterval != null) {
-          clearInterval(connector.transactionTxUpdatedSetInterval)
-          connector.transactionTxUpdatedSetInterval = undefined
+        if (connector?.transactionMeterValuesSetInterval != null) {
+          clearInterval(connector.transactionMeterValuesSetInterval)
+          connector.transactionMeterValuesSetInterval = undefined
         }
       }
       standardCleanup()
@@ -2054,7 +2054,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
           await startPeriodicMeterValues(ocpp16Station, 1, 60000)
 
           const connector = ocpp16Station.getConnectorStatus(1)
-          assert.strictEqual(connector?.transactionTxUpdatedSetInterval, undefined)
+          assert.strictEqual(connector?.transactionMeterValuesSetInterval, undefined)
         })
       })
 
@@ -2068,7 +2068,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         // Zero interval should not start timer
         // This is verified by the implementation logging debug message
-        assert.strictEqual(connector.transactionTxUpdatedSetInterval, undefined)
+        assert.strictEqual(connector.transactionMeterValuesSetInterval, undefined)
       })
 
       await it('should not start timer when interval is negative', () => {
@@ -2078,7 +2078,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         assert(connector != null)
 
         // Negative interval should not start timer
-        assert.strictEqual(connector.transactionTxUpdatedSetInterval, undefined)
+        assert.strictEqual(connector.transactionMeterValuesSetInterval, undefined)
       })
 
       await it('should handle non-existent connector gracefully', () => {
@@ -2527,6 +2527,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
         connectorStatus.transactionStarted = true
         connectorStatus.transactionId = transactionId
         connectorStatus.transactionEnergyActiveImportRegisterValue = 100
+        connectorStatus.transactionDeauthorized = true
+        connectorStatus.transactionDeauthorizedEnergyWh = 50
       }
 
       // Act
@@ -2538,6 +2540,8 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       if (postStatus != null) {
         assert.strictEqual(postStatus.transactionStarted, false)
         assert.strictEqual(postStatus.transactionId, undefined)
+        assert.strictEqual(postStatus.transactionDeauthorized, undefined)
+        assert.strictEqual(postStatus.transactionDeauthorizedEnergyWh, undefined)
       }
     })
 
@@ -2553,6 +2557,68 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       )
     })
 
+    await it('should not terminate when StopTxOnInvalidId is false', async () => {
+      // Arrange
+      const connectorId = 1
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      assert.notStrictEqual(connectorStatus, undefined)
+      if (connectorStatus != null) {
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+      }
+      OCPP20VariableManager.getInstance().setVariables(mockTracking.station, [
+        {
+          attributeType: AttributeEnumType.Actual,
+          attributeValue: 'false',
+          component: { name: OCPP20ComponentName.TxCtrlr },
+          variable: { name: OCPP20RequiredVariableName.StopTxOnInvalidId },
+        },
+      ])
+
+      // Act
+      await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
+
+      // Assert — only Updated(Deauthorized), no Ended
+      const txEvents = mockTracking.sentRequests.filter(
+        r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string)
+      )
+      assert.strictEqual(txEvents.length, 1)
+      assert.strictEqual(txEvents[0].payload.eventType, OCPP20TransactionEventEnumType.Updated)
+      assert.strictEqual(
+        txEvents[0].payload.triggerReason,
+        OCPP20TriggerReasonEnumType.Deauthorized
+      )
+
+      // Transaction should still be active
+      const postStatus = mockTracking.station.getConnectorStatus(connectorId)
+      if (postStatus != null) {
+        assert.strictEqual(postStatus.transactionStarted, true)
+        assert.strictEqual(postStatus.transactionId, transactionId)
+      }
+
+      OCPP20VariableManager.getInstance().resetRuntimeOverrides()
+    })
+
+    await it('should track deauth state for deferred termination via periodic meter values', () => {
+      const connectorId = 1
+      const transactionId = generateUUID()
+      const connectorStatus = mockTracking.station.getConnectorStatus(connectorId)
+      if (connectorStatus == null) {
+        assert.fail('connectorStatus should not be undefined')
+      }
+      connectorStatus.transactionStarted = true
+      connectorStatus.transactionId = transactionId
+      connectorStatus.transactionEnergyActiveImportRegisterValue = 500
+      connectorStatus.transactionDeauthorized = true
+      connectorStatus.transactionDeauthorizedEnergyWh = 500
+
+      assert.strictEqual(connectorStatus.transactionDeauthorized, true)
+      assert.strictEqual(connectorStatus.transactionDeauthorizedEnergyWh, 500)
+      assert.strictEqual(connectorStatus.transactionStarted, true)
+    })
+
     await it('should propagate error and skip cleanup if Updated event fails', async () => {
       const connectorId = 1
       const transactionId = generateUUID()
index 85102971f3969ffb179ec7e458160ba224d65b09..e12c807fb3097f8cb840fa4cbd8e9f5102826d8d 100644 (file)
@@ -113,9 +113,9 @@ export function clearConnectorTransaction (station: ChargingStation, connectorId
   connector.idTagLocalAuthorized = false
 
   // Clear any transaction interval
-  if (connector.transactionSetInterval != null) {
-    clearInterval(connector.transactionSetInterval)
-    connector.transactionSetInterval = undefined
+  if (connector.transactionMeterValuesSetInterval != null) {
+    clearInterval(connector.transactionMeterValuesSetInterval)
+    connector.transactionMeterValuesSetInterval = undefined
   }
 }
 
index aa32c7c01101055b30c1bc1d82028f0fb836e30e..a6eb510588a62a38e7290cbe2eabbe038c48adc0 100644 (file)
@@ -71,8 +71,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
           bootStatus: 'Available',
           MeterValues: [],
           transactionEventQueue: [],
-          transactionSetInterval: interval1 as unknown as NodeJS.Timeout,
-          transactionTxUpdatedSetInterval: interval2 as unknown as NodeJS.Timeout,
+          transactionMeterValuesSetInterval: interval1 as unknown as NodeJS.Timeout,
         } as unknown as ConnectorStatus)
 
         const station = createMockStationForConfigUtils({ connectors })
@@ -80,9 +79,8 @@ await describe('ChargingStationConfigurationUtils', async () => {
 
         assert.strictEqual(result.length, 2)
         for (const [, connector] of result) {
-          assert.ok(!('transactionSetInterval' in connector))
+          assert.ok(!('transactionMeterValuesSetInterval' in connector))
           assert.ok(!('transactionEventQueue' in connector))
-          assert.ok(!('transactionTxUpdatedSetInterval' in connector))
         }
         assert.strictEqual(result[0][0], 0)
         assert.strictEqual(result[1][0], 1)
@@ -107,9 +105,8 @@ await describe('ChargingStationConfigurationUtils', async () => {
         MeterValues: [],
         transactionEventQueue: undefined,
         transactionId: 42,
-        transactionSetInterval: undefined,
+        transactionMeterValuesSetInterval: undefined,
         transactionStarted: true,
-        transactionTxUpdatedSetInterval: undefined,
       } as unknown as ConnectorStatus)
 
       const station = createMockStationForConfigUtils({ connectors })
@@ -150,8 +147,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
         availability: AvailabilityType.Operative,
         MeterValues: [],
         transactionEventQueue: [],
-        transactionSetInterval: undefined,
-        transactionTxUpdatedSetInterval: undefined,
+        transactionMeterValuesSetInterval: undefined,
       } as unknown as ConnectorStatus)
 
       const evses = new Map<number, EvseStatus>()
@@ -183,11 +179,14 @@ await describe('ChargingStationConfigurationUtils', async () => {
         availability: AvailabilityType.Operative,
         MeterValues: [],
         transactionEventQueue: [],
-        transactionSetInterval: undefined,
-        transactionTxUpdatedSetInterval: undefined,
+        transactionMeterValuesSetInterval: undefined,
       } as unknown as ConnectorStatus)
 
       const evses = new Map<number, EvseStatus>()
+      evses.set(0, {
+        availability: AvailabilityType.Operative,
+        connectors: new Map<number, ConnectorStatus>(),
+      })
       evses.set(1, {
         availability: AvailabilityType.Operative,
         connectors: evseConnectors,
@@ -195,15 +194,15 @@ await describe('ChargingStationConfigurationUtils', async () => {
 
       const station = createMockStationForConfigUtils({ evses })
       const result = buildEvsesStatus(station)
-      const evse1 = result[0][1]
+      const evse1 = result.find(([id]) => id === 1)?.[1]
+      assert.ok(evse1 != null)
       const connectorsStatus = evse1.connectorsStatus as [number, ConnectorStatus][]
 
       assert.strictEqual(connectorsStatus.length, 1)
       assert.strictEqual(connectorsStatus[0][0], 1)
       const connector = connectorsStatus[0][1]
-      assert.ok(!('transactionSetInterval' in connector))
+      assert.ok(!('transactionMeterValuesSetInterval' in connector))
       assert.ok(!('transactionEventQueue' in connector))
-      assert.ok(!('transactionTxUpdatedSetInterval' in connector))
     })
 
     await it('should preserve connector IDs across serialization', () => {
@@ -392,8 +391,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
         availability: AvailabilityType.Operative,
         MeterValues: [],
         transactionEventQueue: [],
-        transactionSetInterval: undefined,
-        transactionTxUpdatedSetInterval: undefined,
+        transactionMeterValuesSetInterval: undefined,
       } as unknown as ConnectorStatus)
 
       const station = createMockStationForConfigUtils({ connectors })
@@ -403,9 +401,8 @@ await describe('ChargingStationConfigurationUtils', async () => {
       assert.strictEqual(result[0].connectorId, 0)
       assert.strictEqual(result[1].connectorId, 1)
       assert.strictEqual(result[1].connector.availability, AvailabilityType.Operative)
-      assert.ok(!('transactionSetInterval' in result[1].connector))
+      assert.ok(!('transactionMeterValuesSetInterval' in result[1].connector))
       assert.ok(!('transactionEventQueue' in result[1].connector))
-      assert.ok(!('transactionTxUpdatedSetInterval' in result[1].connector))
     })
 
     await it('should handle empty connectors map', () => {
@@ -447,8 +444,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
         availability: AvailabilityType.Operative,
         MeterValues: [],
         transactionEventQueue: [],
-        transactionSetInterval: undefined,
-        transactionTxUpdatedSetInterval: undefined,
+        transactionMeterValuesSetInterval: undefined,
       } as unknown as ConnectorStatus)
 
       const evses = new Map<number, EvseStatus>()
@@ -471,7 +467,7 @@ await describe('ChargingStationConfigurationUtils', async () => {
       assert.strictEqual(result[1].evseId, 1)
       assert.strictEqual(result[1].connectors.length, 1)
       assert.strictEqual(result[1].connectors[0].connectorId, 1)
-      assert.ok(!('transactionSetInterval' in result[1].connectors[0].connector))
+      assert.ok(!('transactionMeterValuesSetInterval' in result[1].connectors[0].connector))
       assert.ok(!('transactionEventQueue' in result[1].connectors[0].connector))
     })
 
index 6ee1d7ca50867f2a6ae19781b4583af63f413e86..ff468f2c2da2fdf8a7fd70e6bd317ebb3bab76ef 100644 (file)
Binary files a/ui/web/src/assets/webui.png and b/ui/web/src/assets/webui.png differ