]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp2): align MeterValues implementation with OCPP 2.0.1 spec (#1744)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sun, 22 Mar 2026 15:36:32 +0000 (16:36 +0100)
committerGitHub <noreply@github.com>
Sun, 22 Mar 2026 15:36:32 +0000 (16:36 +0100)
* fix(ocpp2): use AlignedDataInterval for standalone MeterValues

Add getAlignedDataInterval() helper to OCPP20ServiceUtils that reads
AlignedDataCtrlr.Interval from the variable registry (default 900s).

Replace getTxUpdatedInterval() with getAlignedDataInterval() in the
broadcast channel handleMeterValues OCPP 2.0 branch. Standalone
MeterValuesRequest is non-transaction data per OCPP 2.0.1 spec and
should use the aligned data interval, not the tx-updated interval.

* fix(ocpp2): include meter value in TransactionEvent Started

Add buildTransactionBeginMeterValues() to OCPP20ServiceUtils following
the buildFinalMeterValues() pattern. Builds an OCPP20MeterValue with
Transaction.Begin context and Energy.Active.Import.Register measurand.

Wire it into both TransactionEvent(Started) call sites:
- OCPPServiceUtils.startTransactionOnConnector (ATG/broadcast channel)
- OCPP20IncomingRequestService RequestStartTransaction event listener

This aligns OCPP 2.0 with the OCPP 1.6 beginEndMeterValues behavior
per OCPP 2.0.1 spec SampledDataTxStartedMeasurands requirement.

* test(ocpp2): add tests for buildTransactionBeginMeterValues

Test Transaction.Begin context, energy register value, default to 0
when undefined, and empty array when energy is negative.

* refactor(ocpp2): address PR review comments

- Extract buildEnergyMeterValues private helper to eliminate DRY
  violation between buildTransactionBeginMeterValues and
  buildFinalMeterValues
- Add AlignedDataInterval to OCPP20RequiredVariableName enum replacing
  raw string literal in getAlignedDataInterval
- Clarify zero-energy test name to document that 0 Wh is a valid
  Transaction.Begin reading
- Add meterValue assertions to RequestStartTransaction test verifying
  Transaction.Begin context and Energy.Active.Import.Register measurand

* refactor(ocpp2): extract readVariableAsIntervalMs to eliminate DRY

getAlignedDataInterval and getTxUpdatedInterval were structurally
identical. Extract the shared variable-reading logic into a private
readVariableAsIntervalMs helper that accepts component name, variable
name, and default seconds. Both public methods become one-liner
delegates.

* refactor(ocpp2): extract terminateTransaction and resolveActiveTransaction

requestDeauthorizeTransaction and requestStopTransaction shared
identical transaction termination logic (build final meter values,
send TransactionEvent Ended, stop periodic, reset connector status).

Extract resolveActiveTransaction for the shared precondition check
and transactionId string resolution, and terminateTransaction for
the shared Ended event + cleanup workflow. Both public methods now
focus only on their unique behavior.

* fix(test): use enum constants instead of string literals in RequestStartTransaction test

Replace 'Transaction.Begin' and 'Energy.Active.Import.Register' string
literals with OCPP20ReadingContextEnumType.TRANSACTION_BEGIN and
OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER to match
codebase conventions.

* refactor(test): replace string literals with enum constants in OCPP 2.0 tests

Replace hardcoded string literals with their corresponding OCPP 2.0.1
enum values across test files for type safety and consistency:

- RequestStopTransaction: Transaction.End, Energy.Active.Import.Register
- SchemaValidation: Immediate, OnIdle, Heartbeat, BootNotification
- enforceMessageLimits: TooManyElements, TooLargeElement
- CertificateManager: Accepted, Failed, NotFound
- ChangeAvailability: UnknownEvse
- GetBaseReport: Accepted (SetVariableStatusEnumType)
- CertificateSigned: InternalError
- ServiceUtils-TransactionEvent: TransactionEvent command name

* fix(test): replace bogus mock return values with correctly typed empty responses

requestDeauthorizeTransaction mocks returned { status: 'Accepted' }
but OCPP20TransactionEventResponse has no status field. Replace with
properly typed empty response objects since the return value is not
asserted in these tests.

* refactor(ocpp2): align method names with OCPP 2.0.1 spec terminology

Rename methods to match OCPP 2.0.1 TransactionEvent terminology:
- buildTransactionBeginMeterValues -> buildTransactionStartedMeterValues
- buildFinalMeterValues -> buildTransactionEndedMeterValues
- beginMeterValues variable -> startedMeterValues
- finalMeterValues variable -> endedMeterValues

The spec uses Started/Updated/Ended for TransactionEvent types, not
Begin/Final.

* refactor(test): replace 'Operative' string literal with enum constants

Use OCPP20OperationalStatusEnumType.Operative in OCPP 2.0 test files
and the cross-version AvailabilityType.Operative in MessageChannelUtils
test to match ConnectorStatus.availability typed field.

16 files changed:
src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/types/ocpp/2.0/Variables.ts
tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-CertificateSigned.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-ChangeAvailability.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-GetBaseReport.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStartTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-RequestStopTransaction.test.ts
tests/charging-station/ocpp/2.0/OCPP20ResponseService-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20SchemaValidation.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-enforceMessageLimits.test.ts
tests/utils/MessageChannelUtils.test.ts

index f90a1c4e0ac3329242b3cf76a20c14adb53382d5..559f9cd7d6fae0dfbd9ac05519d72cf354f8404b 100644 (file)
@@ -475,7 +475,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
       this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_20 ||
       this.chargingStation.stationInfo?.ocppVersion === OCPPVersion.VERSION_201
     ) {
-      const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(this.chargingStation)
+      const alignedDataInterval = OCPP20ServiceUtils.getAlignedDataInterval(this.chargingStation)
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
       const evseId = this.chargingStation.getEvseIdByConnectorId(connectorId!)
       // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
@@ -494,7 +494,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
               // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
               connectorId!,
               transactionId,
-              txUpdatedInterval
+              alignedDataInterval
             ),
           ],
           ...requestPayload,
index 124f9c4dfc741d059dc9f322bedf467f20aeb052..e7dfc92c125ab71c40a1d6aece45833084ce3892 100644 (file)
@@ -394,13 +394,19 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService {
         if (response.status === RequestStartStopStatusEnumType.Accepted) {
           const connectorId = chargingStation.getConnectorIdByTransactionId(response.transactionId)
           if (connectorId != null) {
+            const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+            const startedMeterValues =
+              connectorStatus != null
+                ? OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus)
+                : []
             OCPP20ServiceUtils.sendTransactionEvent(
               chargingStation,
               OCPP20TransactionEventEnumType.Started,
               OCPP20TriggerReasonEnumType.RemoteStart,
               connectorId,
               // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
-              response.transactionId!
+              response.transactionId!,
+              startedMeterValues.length > 0 ? { meterValue: startedMeterValues } : undefined
             ).catch((error: unknown) => {
               logger.error(
                 `${chargingStation.logPrefix()} ${moduleName}.constructor: TransactionEvent(Started) error:`,
index 0333fd53f66405f7fe0b1095a0f02685c7db1bbf..711de44638f4a173fe2d6583154c7257c191fd25 100644 (file)
@@ -89,6 +89,13 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     [OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'],
   ]
 
+  static buildTransactionStartedMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] {
+    return OCPP20ServiceUtils.buildEnergyMeterValues(
+      connectorStatus,
+      OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+    )
+  }
+
   /**
    * OCPP 2.0 Incoming Request Service validator configurations
    * @returns Array of validator configuration tuples
@@ -234,28 +241,22 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     return currentResults
   }
 
-  /**
-   * Gets the TxUpdatedInterval configuration value for periodic TransactionEvent(Updated) messages.
-   * Reads the SampledDataCtrlr.TxUpdatedInterval variable and falls back to
-   * Constants.DEFAULT_TX_UPDATED_INTERVAL if not configured.
-   * @param chargingStation - The charging station instance
-   * @returns The interval in milliseconds
-   */
+  public static getAlignedDataInterval (chargingStation: ChargingStation): number {
+    return OCPP20ServiceUtils.readVariableAsIntervalMs(
+      chargingStation,
+      OCPP20ComponentName.AlignedDataCtrlr,
+      OCPP20RequiredVariableName.AlignedDataInterval,
+      900
+    )
+  }
+
   public static getTxUpdatedInterval (chargingStation: ChargingStation): number {
-    const variableManager = OCPP20VariableManager.getInstance()
-    const results = variableManager.getVariables(chargingStation, [
-      {
-        component: { name: OCPP20ComponentName.SampledDataCtrlr },
-        variable: { name: OCPP20RequiredVariableName.TxUpdatedInterval },
-      },
-    ])
-    if (results.length > 0 && results[0].attributeValue != null) {
-      const intervalSeconds = parseInt(results[0].attributeValue, 10)
-      if (!isNaN(intervalSeconds) && intervalSeconds > 0) {
-        return secondsToMilliseconds(intervalSeconds)
-      }
-    }
-    return secondsToMilliseconds(Constants.DEFAULT_TX_UPDATED_INTERVAL)
+    return OCPP20ServiceUtils.readVariableAsIntervalMs(
+      chargingStation,
+      OCPP20ComponentName.SampledDataCtrlr,
+      OCPP20RequiredVariableName.TxUpdatedInterval,
+      Constants.DEFAULT_TX_UPDATED_INTERVAL
+    )
   }
 
   /**
@@ -301,53 +302,31 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     connectorId: number,
     evseId?: number
   ): Promise<OCPP20TransactionEventResponse> {
-    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
-    if (
-      (connectorStatus?.transactionStarted === true ||
-        connectorStatus?.transactionPending === true) &&
-      connectorStatus.transactionId != null
-    ) {
-      const transactionId =
-        typeof connectorStatus.transactionId === 'string'
-          ? connectorStatus.transactionId
-          : connectorStatus.transactionId.toString()
-
-      await this.sendTransactionEvent(
-        chargingStation,
-        OCPP20TransactionEventEnumType.Updated,
-        OCPP20TriggerReasonEnumType.Deauthorized,
-        connectorId,
-        transactionId,
-        {
-          chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE,
-          evseId,
-        }
-      )
-
-      const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
-
-      const response = await this.sendTransactionEvent(
-        chargingStation,
-        OCPP20TransactionEventEnumType.Ended,
-        OCPP20TriggerReasonEnumType.Deauthorized,
-        connectorId,
-        transactionId,
-        {
-          evseId,
-          meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined,
-          stoppedReason: OCPP20ReasonEnumType.DeAuthorized,
-        }
-      )
+    const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction(
+      chargingStation,
+      connectorId
+    )
 
-      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
-      resetConnectorStatus(connectorStatus)
-      await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
+    await this.sendTransactionEvent(
+      chargingStation,
+      OCPP20TransactionEventEnumType.Updated,
+      OCPP20TriggerReasonEnumType.Deauthorized,
+      connectorId,
+      transactionId,
+      {
+        chargingState: OCPP20ChargingStateEnumType.SuspendedEVSE,
+        evseId,
+      }
+    )
 
-      return response
-    }
-    throw new OCPPError(
-      ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
-      `No active transaction on connector ${connectorId.toString()}`
+    return this.terminateTransaction(
+      chargingStation,
+      connectorId,
+      connectorStatus,
+      transactionId,
+      OCPP20TriggerReasonEnumType.Deauthorized,
+      OCPP20ReasonEnumType.DeAuthorized,
+      evseId
     )
   }
 
@@ -358,47 +337,19 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     triggerReason: OCPP20TriggerReasonEnumType = OCPP20TriggerReasonEnumType.RemoteStop,
     stoppedReason: OCPP20ReasonEnumType = OCPP20ReasonEnumType.Remote
   ): Promise<OCPP20TransactionEventResponse> {
-    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
-    if (
-      (connectorStatus?.transactionStarted === true ||
-        connectorStatus?.transactionPending === true) &&
-      connectorStatus.transactionId != null
-    ) {
-      let transactionId: string
-      if (typeof connectorStatus.transactionId === 'string') {
-        transactionId = connectorStatus.transactionId
-      } else {
-        transactionId = connectorStatus.transactionId.toString()
-        logger.warn(
-          `${chargingStation.logPrefix()} ${moduleName}.requestStopTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0`
-        )
-      }
-
-      // F03.FR.04: Build final meter values for TransactionEvent(Ended)
-      const finalMeterValues = this.buildFinalMeterValues(connectorStatus)
-
-      const response = await this.sendTransactionEvent(
-        chargingStation,
-        OCPP20TransactionEventEnumType.Ended,
-        triggerReason,
-        connectorId,
-        transactionId,
-        {
-          evseId,
-          meterValue: finalMeterValues.length > 0 ? finalMeterValues : undefined,
-          stoppedReason,
-        }
-      )
-
-      OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
-      resetConnectorStatus(connectorStatus)
-      await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
+    const { connectorStatus, transactionId } = OCPP20ServiceUtils.resolveActiveTransaction(
+      chargingStation,
+      connectorId
+    )
 
-      return response
-    }
-    throw new OCPPError(
-      ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
-      `No active transaction on connector ${connectorId.toString()}`
+    return this.terminateTransaction(
+      chargingStation,
+      connectorId,
+      connectorStatus,
+      transactionId,
+      triggerReason,
+      stoppedReason,
+      evseId
     )
   }
 
@@ -657,14 +608,17 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
     }
   }
 
-  private static buildFinalMeterValues (connectorStatus: ConnectorStatus): OCPP20MeterValue[] {
-    const finalMeterValues: OCPP20MeterValue[] = []
+  private static buildEnergyMeterValues (
+    connectorStatus: ConnectorStatus,
+    context: OCPP20ReadingContextEnumType
+  ): OCPP20MeterValue[] {
+    const meterValues: OCPP20MeterValue[] = []
     const energyValue = connectorStatus.transactionEnergyActiveImportRegisterValue ?? 0
     if (energyValue >= 0) {
-      finalMeterValues.push({
+      meterValues.push({
         sampledValue: [
           {
-            context: OCPP20ReadingContextEnumType.TRANSACTION_END,
+            context,
             measurand: OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
             value: energyValue,
           },
@@ -672,7 +626,96 @@ export class OCPP20ServiceUtils extends OCPPServiceUtils {
         timestamp: new Date(),
       })
     }
-    return finalMeterValues
+    return meterValues
+  }
+
+  private static buildTransactionEndedMeterValues (
+    connectorStatus: ConnectorStatus
+  ): OCPP20MeterValue[] {
+    return OCPP20ServiceUtils.buildEnergyMeterValues(
+      connectorStatus,
+      OCPP20ReadingContextEnumType.TRANSACTION_END
+    )
+  }
+
+  private static readVariableAsIntervalMs (
+    chargingStation: ChargingStation,
+    componentName: string,
+    variableName: string,
+    defaultSeconds: number
+  ): number {
+    const variableManager = OCPP20VariableManager.getInstance()
+    const results = variableManager.getVariables(chargingStation, [
+      {
+        component: { name: componentName },
+        variable: { name: variableName },
+      },
+    ])
+    if (results.length > 0 && results[0].attributeValue != null) {
+      const intervalSeconds = parseInt(results[0].attributeValue, 10)
+      if (!isNaN(intervalSeconds) && intervalSeconds > 0) {
+        return secondsToMilliseconds(intervalSeconds)
+      }
+    }
+    return secondsToMilliseconds(defaultSeconds)
+  }
+
+  private static resolveActiveTransaction (
+    chargingStation: ChargingStation,
+    connectorId: number
+  ): { connectorStatus: ConnectorStatus; transactionId: string } {
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (
+      (connectorStatus?.transactionStarted === true ||
+        connectorStatus?.transactionPending === true) &&
+      connectorStatus.transactionId != null
+    ) {
+      let transactionId: string
+      if (typeof connectorStatus.transactionId === 'string') {
+        transactionId = connectorStatus.transactionId
+      } else {
+        transactionId = connectorStatus.transactionId.toString()
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.resolveActiveTransaction: Non-string transaction ID ${transactionId} converted to string for OCPP 2.0`
+        )
+      }
+      return { connectorStatus, transactionId }
+    }
+    throw new OCPPError(
+      ErrorType.PROPERTY_CONSTRAINT_VIOLATION,
+      `No active transaction on connector ${connectorId.toString()}`
+    )
+  }
+
+  private static async terminateTransaction (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    connectorStatus: ConnectorStatus,
+    transactionId: string,
+    triggerReason: OCPP20TriggerReasonEnumType,
+    stoppedReason: OCPP20ReasonEnumType,
+    evseId?: number
+  ): Promise<OCPP20TransactionEventResponse> {
+    const endedMeterValues = this.buildTransactionEndedMeterValues(connectorStatus)
+
+    const response = await this.sendTransactionEvent(
+      chargingStation,
+      OCPP20TransactionEventEnumType.Ended,
+      triggerReason,
+      connectorId,
+      transactionId,
+      {
+        evseId,
+        meterValue: endedMeterValues.length > 0 ? endedMeterValues : undefined,
+        stoppedReason,
+      }
+    )
+
+    OCPP20ServiceUtils.stopPeriodicMeterValues(chargingStation, connectorId)
+    resetConnectorStatus(connectorStatus)
+    await sendAndSetConnectorStatus(chargingStation, connectorId, ConnectorStatusEnum.Available)
+
+    return response
   }
 }
 export function buildTransactionEvent (
index 6c4a881dd0821e4c75dd119ac41afe99e73d2c5a..6ab76a06f7d70fb37c227caf74dda4132328b89d 100644 (file)
@@ -436,6 +436,10 @@ export const startTransactionOnConnector = async (
         }
         OCPP20ServiceUtils.resetTransactionSequenceNumber(chargingStation, connectorId)
       }
+      const startedMeterValues =
+        connectorStatus != null
+          ? OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus)
+          : []
       const response = await OCPP20ServiceUtils.sendTransactionEvent(
         chargingStation,
         OCPP20TransactionEventEnumType.Started,
@@ -445,6 +449,7 @@ export const startTransactionOnConnector = async (
         {
           idToken:
             idTag != null ? { idToken: idTag, type: OCPP20IdTokenEnumType.Central } : undefined,
+          ...(startedMeterValues.length > 0 && { meterValue: startedMeterValues }),
         }
       )
       return {
index b57d63c0946b239fac89182c082a6c45ac986c39..619ea2daf83091ebffe8783bc368eab78b2ed001 100644 (file)
@@ -38,6 +38,7 @@ export enum OCPP20OptionalVariableName {
 }
 
 export enum OCPP20RequiredVariableName {
+  AlignedDataInterval = 'Interval',
   AuthorizeRemoteStart = 'AuthorizeRemoteStart',
   BytesPerMessage = 'BytesPerMessage',
   CertificateEntries = 'CertificateEntries',
index bee5d608f2f066457afd26a6b6c706c8536720d9..c0f1fb8da052348f6cab91fd4460414e2cbbf1fc 100644 (file)
@@ -10,6 +10,7 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 import { OCPP20CertificateManager } from '../../../../src/charging-station/ocpp/2.0/OCPP20CertificateManager.js'
 import {
   type CertificateHashDataType,
+  DeleteCertificateStatusEnumType,
   HashAlgorithmEnumType,
   InstallCertificateUseEnumType,
 } from '../../../../src/types/index.js'
@@ -128,7 +129,13 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => {
 
       assert.notStrictEqual(result, undefined)
       assert.notStrictEqual(result.status, undefined)
-      assert.ok(['Accepted', 'Failed', 'NotFound'].includes(result.status))
+      assert.ok(
+        [
+          DeleteCertificateStatusEnumType.Accepted,
+          DeleteCertificateStatusEnumType.Failed,
+          DeleteCertificateStatusEnumType.NotFound,
+        ].includes(result.status)
+      )
     })
 
     await it('should return NotFound for non-existent certificate', async () => {
@@ -142,7 +149,7 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => {
       const result = await manager.deleteCertificate(TEST_STATION_HASH_ID, hashData)
 
       assert.notStrictEqual(result, undefined)
-      assert.strictEqual(result.status, 'NotFound')
+      assert.strictEqual(result.status, DeleteCertificateStatusEnumType.NotFound)
     })
 
     await it('should handle filesystem errors gracefully', async () => {
@@ -156,7 +163,11 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => {
       const result = await manager.deleteCertificate('invalid-station-id', hashData)
 
       assert.notStrictEqual(result, undefined)
-      assert.ok(['Failed', 'NotFound'].includes(result.status))
+      assert.ok(
+        [DeleteCertificateStatusEnumType.Failed, DeleteCertificateStatusEnumType.NotFound].includes(
+          result.status
+        )
+      )
     })
   })
 
index f2c809640f779415980c83f1ae6e82cc2c6a9f70..5dff150a7de9fe5deb2da4dccf43e6b5178672e9 100644 (file)
@@ -19,6 +19,7 @@ import {
   type OCPP20CertificateSignedResponse,
   OCPP20RequestCommand,
   OCPPVersion,
+  ReasonCodeEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
@@ -198,7 +199,7 @@ await describe('I04 - CertificateSigned', async () => {
       assert.notStrictEqual(response, undefined)
       assert.strictEqual(response.status, GenericStatus.Rejected)
       assert.notStrictEqual(response.statusInfo, undefined)
-      assert.strictEqual(response.statusInfo?.reasonCode, 'InternalError')
+      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.InternalError)
     })
   })
 
index 9408511bc34ab972280b44d21a1b6b26f7a7897c..2f56cd737970fadcbd9a12b2d7507236bd505253 100644 (file)
@@ -14,6 +14,7 @@ import {
   ChangeAvailabilityStatusEnumType,
   OCPP20OperationalStatusEnumType,
   OCPPVersion,
+  ReasonCodeEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
 import {
@@ -116,7 +117,7 @@ await describe('G03 - ChangeAvailability', async () => {
 
     assert.strictEqual(response.status, ChangeAvailabilityStatusEnumType.Rejected)
     assert.notStrictEqual(response.statusInfo, undefined)
-    assert.strictEqual(response.statusInfo?.reasonCode, 'UnknownEvse')
+    assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnknownEvse)
   })
 
   await it('should accept when already in requested state (idempotent)', () => {
index 29eb13c8122e3180631c47c4be7a700237c3d593..690c68b99a4ff77f7619dc5ba8db7a36c0995541 100644 (file)
@@ -29,6 +29,7 @@ import {
   OCPPVersion,
   ReportBaseEnumType,
   type ReportDataType,
+  SetVariableStatusEnumType,
   StandardParametersKey,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
@@ -292,7 +293,7 @@ await describe('B07 - Get Base Report', async () => {
         variable: { name: OCPP20RequiredVariableName.TimeSource },
       },
     ])
-    assert.strictEqual(setResult[0].attributeStatus, 'Accepted')
+    assert.strictEqual(setResult[0].attributeStatus, SetVariableStatusEnumType.Accepted)
 
     // Build report; value should be truncated to length 10
     const reportData = testableService.buildReportData(station, ReportBaseEnumType.FullInventory)
index 60eb3de08c482079ae9a1db9f25d4c13f5579e45..61cf02ca08792a9027a6df5b699b55f80a55d2dc 100644 (file)
@@ -24,6 +24,8 @@ import {
   OCPP20ChargingProfilePurposeEnumType,
   OCPP20IdTokenEnumType,
   OCPP20IncomingRequestCommand,
+  OCPP20MeasurandEnumType,
+  OCPP20ReadingContextEnumType,
   OCPP20RequestCommand,
   OCPP20TransactionEventEnumType,
   OCPP20TriggerReasonEnumType,
@@ -449,6 +451,18 @@ await describe('F01 & F02 - Remote Start Transaction', async () => {
       ]
       assert.strictEqual(args[1], OCPP20RequestCommand.TRANSACTION_EVENT)
       assert.strictEqual(args[2].eventType, OCPP20TransactionEventEnumType.Started)
+      assert.ok(
+        Array.isArray(args[2].meterValue) && args[2].meterValue.length > 0,
+        'TransactionEvent(Started) should include non-empty meterValue array'
+      )
+      assert.strictEqual(
+        args[2].meterValue[0].sampledValue[0].context,
+        OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+      )
+      assert.strictEqual(
+        args[2].meterValue[0].sampledValue[0].measurand,
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+      )
     })
 
     await it('should NOT call TransactionEvent when response is Rejected', () => {
index 85ff25fa581c25fb08dd69988deb0ad99e384e86..f4f79bac1c7468c41d1a36e7b904bca69c03c9ae 100644 (file)
@@ -23,6 +23,8 @@ import { OCPPAuthServiceFactory } from '../../../../src/charging-station/ocpp/au
 import {
   OCPP20IdTokenEnumType,
   OCPP20IncomingRequestCommand,
+  OCPP20MeasurandEnumType,
+  OCPP20ReadingContextEnumType,
   OCPP20ReasonEnumType,
   OCPP20RequestCommand,
   OCPP20TransactionEventEnumType,
@@ -425,8 +427,11 @@ await describe('F03 - Remote Stop Transaction', async () => {
 
       const sampledValue = meterValue.sampledValue[0]
       assert.strictEqual(sampledValue.value, 12345.67)
-      assert.strictEqual(sampledValue.context, 'Transaction.End')
-      assert.strictEqual(sampledValue.measurand, 'Energy.Active.Import.Register')
+      assert.strictEqual(sampledValue.context, OCPP20ReadingContextEnumType.TRANSACTION_END)
+      assert.strictEqual(
+        sampledValue.measurand,
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+      )
     })
   })
 })
index 492fa10599b392c08c26973df80aefc5b1cc6efa..ee3edecaacd5d5b8fe37168817525629d722c41a 100644 (file)
@@ -111,7 +111,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -132,7 +132,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -156,7 +156,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -178,7 +178,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       chargingPriority: 5,
@@ -197,7 +197,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {}
     const requestPayload = buildTransactionEventRequest(TEST_TRANSACTION_ID)
@@ -214,7 +214,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -235,7 +235,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
@@ -256,7 +256,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       totalCost: 12.5,
@@ -303,7 +303,7 @@ await describe('D01 - TransactionEvent Response', async () => {
     const mockDeauthTransaction = mock.method(
       OCPP20ServiceUtils,
       'requestDeauthorizeTransaction',
-      () => Promise.resolve({ status: 'Accepted' })
+      () => Promise.resolve({} as OCPP20TransactionEventResponse)
     )
     const payload: OCPP20TransactionEventResponse = {
       idTokenInfo: {
index f0ca0785723ed32ebd748b33889638d9d1dfbb92..58f6ef2374e976db69b4467b1af2e6a2563e049d 100644 (file)
@@ -20,7 +20,12 @@ import { fileURLToPath } from 'node:url'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
 import { OCPP20ResponseService } from '../../../../src/charging-station/ocpp/2.0/OCPP20ResponseService.js'
 import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
-import { OCPP20IncomingRequestCommand, OCPP20RequestCommand } from '../../../../src/types/index.js'
+import {
+  MessageTriggerEnumType,
+  OCPP20IncomingRequestCommand,
+  OCPP20RequestCommand,
+  ResetEnumType,
+} from '../../../../src/types/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 
 const AjvConstructor = _Ajv.default
@@ -198,15 +203,18 @@ await describe('OCPP 2.0 schema validation — negative tests', async () => {
 
   await it('should pass validation for valid Reset payloads', () => {
     const validate = makeValidator('ResetRequest.json')
-    assert.strictEqual(validate({ type: 'Immediate' }), true)
-    assert.strictEqual(validate({ type: 'OnIdle' }), true)
-    assert.strictEqual(validate({ evseId: 1, type: 'OnIdle' }), true)
+    assert.strictEqual(validate({ type: ResetEnumType.Immediate }), true)
+    assert.strictEqual(validate({ type: ResetEnumType.OnIdle }), true)
+    assert.strictEqual(validate({ evseId: 1, type: ResetEnumType.OnIdle }), true)
   })
 
   await it('should pass validation for valid TriggerMessage payloads', () => {
     const validate = makeValidator('TriggerMessageRequest.json')
-    assert.strictEqual(validate({ requestedMessage: 'Heartbeat' }), true)
-    assert.strictEqual(validate({ requestedMessage: 'BootNotification' }), true)
+    assert.strictEqual(validate({ requestedMessage: MessageTriggerEnumType.Heartbeat }), true)
+    assert.strictEqual(
+      validate({ requestedMessage: MessageTriggerEnumType.BootNotification }),
+      true
+    )
   })
 
   await describe('schema registration coverage', async () => {
index 44ad5a9dfe6de095834bf856fc826de3449da00e..0e95ffa2dd0a631e8cbeefd6a701b25762a58765 100644 (file)
@@ -14,6 +14,7 @@ import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
 import type { ChargingStation } from '../../../../src/charging-station/index.js'
+import type { ConnectorStatus } from '../../../../src/types/ConnectorStatus.js'
 import type { EmptyObject } from '../../../../src/types/index.js'
 
 import {
@@ -31,8 +32,10 @@ import {
   OCPP20IdTokenEnumType,
   type OCPP20IdTokenType,
   OCPP20MeasurandEnumType,
+  OCPP20OperationalStatusEnumType,
   OCPP20ReadingContextEnumType,
   OCPP20ReasonEnumType,
+  OCPP20RequestCommand,
   OCPP20RequiredVariableName,
   OCPP20TransactionEventEnumType,
   type OCPP20TransactionType,
@@ -2110,7 +2113,7 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
 
         // Verify the request was sent with correct trigger reason
         assert.strictEqual(sentRequests.length, 1)
-        assert.strictEqual(sentRequests[0].command, 'TransactionEvent')
+        assert.strictEqual(sentRequests[0].command, OCPP20RequestCommand.TRANSACTION_EVENT)
         assert.strictEqual(
           sentRequests[0].payload.eventType,
           OCPP20TransactionEventEnumType.Updated
@@ -2458,7 +2461,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 1)
 
       // Assert
-      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      const txEvents = mockTracking.sentRequests.filter(
+        r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string)
+      )
       assert.strictEqual(txEvents.length, 2)
 
       const updatedEvent = txEvents[0].payload
@@ -2488,7 +2493,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       await OCPP20ServiceUtils.requestDeauthorizeTransaction(mockTracking.station, connectorId, 2)
 
       // Assert
-      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      const txEvents = mockTracking.sentRequests.filter(
+        r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string)
+      )
       assert.strictEqual(txEvents.length, 2)
 
       const endedPayload = txEvents[1].payload
@@ -2608,7 +2615,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       await OCPP20ServiceUtils.requestStopTransaction(mockTracking.station, connectorId, 1)
 
       // Assert
-      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      const txEvents = mockTracking.sentRequests.filter(
+        r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string)
+      )
       assert.strictEqual(txEvents.length, 1)
 
       const endedEvent = txEvents[0].payload
@@ -2641,7 +2650,9 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       )
 
       // Assert
-      const txEvents = mockTracking.sentRequests.filter(r => r.command === 'TransactionEvent')
+      const txEvents = mockTracking.sentRequests.filter(
+        r => r.command === (OCPP20RequestCommand.TRANSACTION_EVENT as string)
+      )
       assert.strictEqual(txEvents.length, 1)
 
       const endedEvent = txEvents[0].payload
@@ -2650,4 +2661,53 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       assert.strictEqual(endedEvent.stoppedReason, customStoppedReason)
     })
   })
+
+  await describe('buildTransactionStartedMeterValues', async () => {
+    await it('should build meter value with Transaction.Begin context and energy register', () => {
+      const connectorStatus = {
+        availability: OCPP20OperationalStatusEnumType.Operative,
+        MeterValues: [],
+        transactionEnergyActiveImportRegisterValue: 1234,
+      } as unknown as ConnectorStatus
+
+      const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus)
+
+      assert.strictEqual(result.length, 1)
+      assert.strictEqual(result[0].sampledValue.length, 1)
+      assert.strictEqual(
+        result[0].sampledValue[0].context,
+        OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+      )
+      assert.strictEqual(
+        result[0].sampledValue[0].measurand,
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+      )
+      assert.strictEqual(result[0].sampledValue[0].value, 1234)
+      assert.ok(result[0].timestamp instanceof Date)
+    })
+
+    await it('should include meter value with 0 energy when register value is undefined (zero is a valid begin reading)', () => {
+      const connectorStatus = {
+        availability: OCPP20OperationalStatusEnumType.Operative,
+        MeterValues: [],
+      } as unknown as ConnectorStatus
+
+      const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus)
+
+      assert.strictEqual(result.length, 1)
+      assert.strictEqual(result[0].sampledValue[0].value, 0)
+    })
+
+    await it('should return empty array when energy register value is negative', () => {
+      const connectorStatus = {
+        availability: OCPP20OperationalStatusEnumType.Operative,
+        MeterValues: [],
+        transactionEnergyActiveImportRegisterValue: -1,
+      } as unknown as ConnectorStatus
+
+      const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(connectorStatus)
+
+      assert.strictEqual(result.length, 0)
+    })
+  })
 })
index 285c8bca4d9f4c363f8d94e68fbf871dc54b1239..5b91c7330d532078c0556d9054c90fd023e7f928 100644 (file)
@@ -7,6 +7,7 @@ import assert from 'node:assert/strict'
 import { afterEach, describe, it } from 'node:test'
 
 import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
+import { ReasonCodeEnumType } from '../../../../src/types/index.js'
 import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 
 interface MockLogger {
@@ -170,7 +171,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
       assert.strictEqual(result.rejected, true)
       assert.strictEqual(result.results.length, 3)
       for (const r of result.results as RejectedResult[]) {
-        assert.strictEqual(r.reasonCode, 'TooManyElements')
+        assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements)
         assert.ok(r.info.includes('ItemsPerMessage limit 2'))
       }
     })
@@ -194,7 +195,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
       assert.strictEqual(result.rejected, true)
       assert.strictEqual(result.results.length, 2)
       for (const r of result.results as RejectedResult[]) {
-        assert.strictEqual(r.reasonCode, 'TooManyElements')
+        assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements)
       }
     })
 
@@ -259,7 +260,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
       assert.strictEqual(result.rejected, true)
       assert.strictEqual(result.results.length, 1)
       const r = (result.results as RejectedResult[])[0]
-      assert.strictEqual(r.reasonCode, 'TooLargeElement')
+      assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooLargeElement)
       assert.ok(r.info.includes('BytesPerMessage limit 1'))
     })
 
@@ -282,7 +283,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
       assert.strictEqual(result.rejected, true)
       assert.strictEqual(result.results.length, 2)
       for (const r of result.results as RejectedResult[]) {
-        assert.strictEqual(r.reasonCode, 'TooLargeElement')
+        assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooLargeElement)
       }
     })
 
@@ -326,7 +327,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
 
       assert.strictEqual(result.rejected, true)
       for (const r of result.results as RejectedResult[]) {
-        assert.strictEqual(r.reasonCode, 'TooManyElements')
+        assert.strictEqual(r.reasonCode, ReasonCodeEnumType.TooManyElements)
       }
     })
   })
@@ -377,7 +378,7 @@ await describe('OCPP20ServiceUtils.enforceMessageLimits', async () => {
       )
 
       assert.strictEqual(capturedReasons.length, 1)
-      assert.strictEqual(capturedReasons[0].reasonCode, 'TooLargeElement')
+      assert.strictEqual(capturedReasons[0].reasonCode, ReasonCodeEnumType.TooLargeElement)
       assert.strictEqual(typeof capturedReasons[0].info, 'string')
       assert.ok(capturedReasons[0].info.length > 0)
     })
index 293fec2f8f501656bdc0e801df3a7dca538cc6a7..76f0b23c07869bffd9bd697f256d300be726f432 100644 (file)
@@ -10,7 +10,7 @@ import { afterEach, describe, it } from 'node:test'
 import type { ChargingStation } from '../../src/charging-station/index.js'
 import type { Statistics, TimestampedData } from '../../src/types/index.js'
 
-import { ChargingStationWorkerMessageEvents } from '../../src/types/index.js'
+import { AvailabilityType, ChargingStationWorkerMessageEvents } from '../../src/types/index.js'
 import {
   buildAddedMessage,
   buildDeletedMessage,
@@ -37,14 +37,14 @@ function createMockStationForMessages (): ChargingStation {
       [
         0,
         {
-          availability: 'Operative',
+          availability: AvailabilityType.Operative,
           MeterValues: [],
         },
       ],
       [
         1,
         {
-          availability: 'Operative',
+          availability: AvailabilityType.Operative,
           MeterValues: [],
         },
       ],