]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp): align OCPP 2.0 meter value builder with 1.6 parity
authorJérôme Benoit <jerome.benoit@sap.com>
Wed, 1 Apr 2026 22:45:21 +0000 (00:45 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Wed, 1 Apr 2026 22:45:21 +0000 (00:45 +0200)
Add per-phase power/current validation and breakdown to
OCPP 2.0 builder, matching OCPP 1.6 implementation.
Reorder measurands: SoC, Voltage, Power, Current, Energy.

Harmonize MeterValues test structure between versions:
extract 1.6 tests to dedicated file, add cross-version
parameterized builder output tests, add missing 2.0
edge case and interval restart test coverage.

src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts
tests/charging-station/ChargingStation-Transactions.test.ts
tests/charging-station/helpers/StationHelpers.ts
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-MeterValues.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts
tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts
tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts

index 901b43ddf4dbac4862823b8730356bed88a7fbd2..f9e47e1fb5e2b6a4616d0f40a61205abacbd76dd 100644 (file)
@@ -6,7 +6,10 @@ import {
   BootReasonEnumType,
   type ChargingStationInfo,
   type ConfigurationKeyType,
+  CurrentType,
   ErrorType,
+  type MeasurandPerPhaseSampledValueTemplates,
+  type MeasurandValues,
   type MeterValueContext,
   type MeterValuePhase,
   MeterValueUnit,
@@ -19,7 +22,7 @@ import {
   RequestCommand,
   type SampledValueTemplate,
 } from '../../../types/index.js'
-import { roundTo } from '../../../utils/index.js'
+import { ACElectricUtils, DCElectricUtils, roundTo } from '../../../utils/index.js'
 import {
   addLineToLineVoltageToMeterValue,
   addMainVoltageToMeterValue,
@@ -32,7 +35,10 @@ import {
   buildVoltageMeasurandValue,
   resolveSampledValueFields,
   updateConnectorEnergyValues,
+  validateCurrentMeasurandPhaseValue,
+  validateCurrentMeasurandValue,
   validateEnergyMeasurandValue,
+  validatePowerMeasurandValue,
   validateSocMeasurandValue,
 } from '../OCPPServiceUtils.js'
 
@@ -146,7 +152,132 @@ export const buildOCPP20MeterValue = (
       )
     }
   }
-  // Energy.Active.Import.Register measurand
+  // Power.Active.Import measurand
+  const powerMeasurand = buildPowerMeasurandValue(
+    chargingStation,
+    connectorId,
+    evseId,
+    measurandsKey
+  )
+  if (powerMeasurand?.values.allPhases != null) {
+    const unitDivider = powerMeasurand.template.unit === MeterValueUnit.KILO_WATT ? 1000 : 1
+    const connectorMaximumAvailablePower =
+      chargingStation.getConnectorMaximumAvailablePower(connectorId)
+    const connectorMaximumPower = Math.round(connectorMaximumAvailablePower)
+    const connectorMinimumPower = Math.round(powerMeasurand.template.minimumValue ?? 0)
+
+    meterValue.sampledValue.push(
+      buildVersionedSampledValue(powerMeasurand.template, powerMeasurand.values.allPhases, context)
+    )
+    const sampledValuesIndex = meterValue.sampledValue.length - 1
+    validatePowerMeasurandValue(
+      chargingStation,
+      connectorId,
+      connectorStatus,
+      meterValue.sampledValue[sampledValuesIndex],
+      connectorMaximumPower / unitDivider,
+      connectorMinimumPower / unitDivider,
+      debug
+    )
+    if (chargingStation.getNumberOfPhases() === 3) {
+      const connectorMaximumPowerPerPhase = Math.round(
+        connectorMaximumAvailablePower / chargingStation.getNumberOfPhases()
+      )
+      const connectorMinimumPowerPerPhase = Math.round(
+        connectorMinimumPower / chargingStation.getNumberOfPhases()
+      )
+      for (let phase = 1; phase <= chargingStation.getNumberOfPhases(); phase++) {
+        const phaseTemplate =
+          powerMeasurand.perPhaseTemplates[
+            `L${phase.toString()}` as keyof MeasurandPerPhaseSampledValueTemplates
+          ]
+        if (phaseTemplate != null) {
+          const phaseValue = `L${phase.toString()}-N` as MeterValuePhase
+          const phasePowerValue =
+            powerMeasurand.values[`L${phase.toString()}` as keyof MeasurandValues]
+          meterValue.sampledValue.push(
+            buildVersionedSampledValue(phaseTemplate, phasePowerValue, context, phaseValue)
+          )
+          const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1
+          validatePowerMeasurandValue(
+            chargingStation,
+            connectorId,
+            connectorStatus,
+            meterValue.sampledValue[sampledValuesPerPhaseIndex],
+            connectorMaximumPowerPerPhase / unitDivider,
+            connectorMinimumPowerPerPhase / unitDivider,
+            debug
+          )
+        }
+      }
+    }
+  }
+  // Current.Import measurand
+  const currentMeasurand = buildCurrentMeasurandValue(
+    chargingStation,
+    connectorId,
+    evseId,
+    measurandsKey
+  )
+  if (currentMeasurand?.values.allPhases != null) {
+    const connectorMaximumAvailablePower =
+      chargingStation.getConnectorMaximumAvailablePower(connectorId)
+    const connectorMaximumAmperage =
+      chargingStation.stationInfo?.currentOutType === CurrentType.AC
+        ? ACElectricUtils.amperagePerPhaseFromPower(
+          chargingStation.getNumberOfPhases(),
+          connectorMaximumAvailablePower,
+          chargingStation.getVoltageOut()
+        )
+        : DCElectricUtils.amperage(connectorMaximumAvailablePower, chargingStation.getVoltageOut())
+    const connectorMinimumAmperage = currentMeasurand.template.minimumValue ?? 0
+
+    meterValue.sampledValue.push(
+      buildVersionedSampledValue(
+        currentMeasurand.template,
+        currentMeasurand.values.allPhases,
+        context
+      )
+    )
+    const sampledValuesIndex = meterValue.sampledValue.length - 1
+    validateCurrentMeasurandValue(
+      chargingStation,
+      connectorId,
+      connectorStatus,
+      meterValue.sampledValue[sampledValuesIndex],
+      connectorMaximumAmperage,
+      connectorMinimumAmperage,
+      debug
+    )
+    for (
+      let phase = 1;
+      chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases();
+      phase++
+    ) {
+      const phaseValue = `L${phase.toString()}` as MeterValuePhase
+      meterValue.sampledValue.push(
+        buildVersionedSampledValue(
+          currentMeasurand.perPhaseTemplates[
+            phaseValue as keyof MeasurandPerPhaseSampledValueTemplates
+          ] ?? currentMeasurand.template,
+          currentMeasurand.values[phaseValue as keyof MeasurandPerPhaseSampledValueTemplates],
+          context,
+          phaseValue
+        )
+      )
+      const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1
+      validateCurrentMeasurandPhaseValue(
+        chargingStation,
+        connectorId,
+        connectorStatus,
+        meterValue.sampledValue[sampledValuesPerPhaseIndex],
+        connectorMaximumAmperage,
+        connectorMinimumAmperage,
+        debug
+      )
+    }
+  }
+  // Energy.Active.Import.Register measurand (default)
   const energyMeasurand = buildEnergyMeasurandValue(
     chargingStation,
     connectorId,
@@ -184,36 +315,6 @@ export const buildOCPP20MeterValue = (
       debug
     )
   }
-  // Power.Active.Import measurand
-  const powerMeasurand = buildPowerMeasurandValue(
-    chargingStation,
-    connectorId,
-    evseId,
-    measurandsKey
-  )
-  if (powerMeasurand?.values.allPhases != null) {
-    const powerSampledValue = buildVersionedSampledValue(
-      powerMeasurand.template,
-      powerMeasurand.values.allPhases,
-      context
-    )
-    meterValue.sampledValue.push(powerSampledValue)
-  }
-  // Current.Import measurand
-  const currentMeasurand = buildCurrentMeasurandValue(
-    chargingStation,
-    connectorId,
-    evseId,
-    measurandsKey
-  )
-  if (currentMeasurand?.values.allPhases != null) {
-    const currentSampledValue = buildVersionedSampledValue(
-      currentMeasurand.template,
-      currentMeasurand.values.allPhases,
-      context
-    )
-    meterValue.sampledValue.push(currentSampledValue)
-  }
   return meterValue
 }
 
index 194851f956ddd52dbf112cdd7cc6c3503ed950fd..66ec8c3460683e37c0c2114bc800f32a175d59e0 100644 (file)
@@ -641,6 +641,34 @@ await describe('ChargingStation Transaction Management', async () => {
       })
     })
 
+    await it('should restart transaction updated interval for OCPP 2.0', async t => {
+      await withMockTimers(t, ['setInterval'], () => {
+        // Arrange
+        const result = createMockChargingStation({
+          connectorsCount: 2,
+          ocppVersion: OCPPVersion.VERSION_20,
+        })
+        station = result.station
+        const connector1 = station.getConnectorStatus(1)
+        if (connector1 != null) {
+          connector1.transactionStarted = true
+          connector1.transactionId = 100
+        }
+        OCPP20ServiceUtils.startUpdatedMeterValues(station, 1, 5000)
+        const firstInterval = connector1?.transactionUpdatedMeterValuesSetInterval
+
+        // Act
+        OCPP20ServiceUtils.stopUpdatedMeterValues(station, 1)
+        OCPP20ServiceUtils.startUpdatedMeterValues(station, 1, 8000)
+        const secondInterval = connector1?.transactionUpdatedMeterValuesSetInterval
+
+        // Assert - interval should be different
+        assert.notStrictEqual(secondInterval, undefined)
+        assert.strictEqual(typeof secondInterval, 'object')
+        assert.notStrictEqual(firstInterval, secondInterval)
+      })
+    })
+
     await it('should create transaction ended interval when startEndedMeterValues() is called for OCPP 2.0', async t => {
       await withMockTimers(t, ['setInterval'], () => {
         // Arrange
index b20995ab2a1c9bc264318fdef9a2ec4fb4905ba3..d2ee2ae87d908f4a575a40902fa61b64fbeaba90 100644 (file)
@@ -21,6 +21,7 @@ import type {
 import {
   AvailabilityType,
   ConnectorStatusEnum,
+  CurrentType,
   OCPPVersion,
   RegistrationStatusEnumType,
 } from '../../../src/types/index.js'
@@ -517,6 +518,9 @@ export function createMockChargingStation (
     getNumberOfEvses (): number {
       return evses.has(0) ? evses.size - 1 : evses.size
     },
+    getNumberOfPhases (): number {
+      return stationInfoOverrides?.numberOfPhases ?? 3
+    },
     getNumberOfRunningTransactions (): number {
       return this.iterateConnectors(true).reduce(
         (count, { connectorStatus }) =>
@@ -534,6 +538,9 @@ export function createMockChargingStation (
         ({ connectorStatus }) => connectorStatus.transactionId === transactionId
       )?.connectorStatus.transactionIdTag
     },
+    getVoltageOut (): number {
+      return stationInfoOverrides?.voltageOut ?? 230
+    },
     getWebSocketPingInterval (): number {
       return websocketPingInterval
     },
@@ -738,13 +745,16 @@ export function createMockChargingStation (
       autoStart,
       baseName,
       chargingStationId: `${baseName}-${index.toString().padStart(5, '0')}`,
+      currentOutType: CurrentType.AC,
       hashId: TEST_CHARGING_STATION_HASH_ID,
       maximumAmperage: 32,
       maximumPower: 22000,
+      numberOfPhases: 3,
       ocppVersion: stationInfoOverrides?.ocppVersion ?? ocppVersion,
       remoteAuthorization: true,
       templateIndex: index,
       templateName: templateFile,
+      voltageOut: 230,
       ...stationInfoOverrides,
     },
 
diff --git a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-MeterValues.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-MeterValues.test.ts
new file mode 100644 (file)
index 0000000..bfc4c8a
--- /dev/null
@@ -0,0 +1,256 @@
+/**
+ * @file Tests for OCPP16ServiceUtils meter value building functions
+ * @module OCPP 1.6 — §4.7 MeterValues (meter value building)
+ * @description Verifies pure static methods on OCPP16ServiceUtils for meter value building:
+ * buildTransactionBeginMeterValue, buildTransactionDataMeterValues, buildTransactionEndMeterValue.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, describe, it } from 'node:test'
+
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import {
+  type OCPP16MeterValue,
+  OCPP16MeterValueContext,
+  OCPP16MeterValueMeasurand,
+  OCPP16MeterValueUnit,
+  OCPPVersion,
+} from '../../../../src/types/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { createMeterValuesTemplate } from './OCPP16TestUtils.js'
+
+await describe('OCPP16ServiceUtils — MeterValues', async () => {
+  afterEach(() => {
+    standardCleanup()
+  })
+
+  // ─── buildTransactionBeginMeterValue ───────────────────────────────────
+
+  await describe('buildTransactionBeginMeterValue', async () => {
+    await it('should return a meter value with Transaction.Begin context when template exists', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000)
+
+      // Assert
+      assert.notStrictEqual(meterValue, undefined)
+      assert.ok(meterValue.timestamp instanceof Date)
+      assert.strictEqual(Array.isArray(meterValue.sampledValue), true)
+      assert.strictEqual(meterValue.sampledValue.length, 1)
+      assert.strictEqual(
+        meterValue.sampledValue[0].context,
+        OCPP16MeterValueContext.TRANSACTION_BEGIN
+      )
+    })
+
+    await it('should apply Wh unit divider of 1 for meterStart', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000)
+
+      // Assert — Wh divider is 1, so value = 5000 / 1 = 5000
+      assert.strictEqual(meterValue.sampledValue[0].value, '5000')
+    })
+
+    await it('should apply kWh unit divider of 1000 for meterStart', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.KILO_WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000)
+
+      // Assert — kWh divider is 1000, so value = 5000 / 1000 = 5
+      assert.strictEqual(meterValue.sampledValue[0].value, '5')
+    })
+
+    await it('should use meterStart 0 when undefined', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, undefined)
+
+      // Assert — undefined meterStart defaults to 0
+      assert.strictEqual(meterValue.sampledValue[0].value, '0')
+    })
+
+    await it('should throw when MeterValues template is empty (missing default measurand)', () => {
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+
+      assert.throws(
+        () => {
+          OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 100)
+        },
+        { message: /Missing MeterValues for default measurand/ }
+      )
+    })
+  })
+
+  // ─── buildTransactionDataMeterValues ───────────────────────────────────
+
+  await describe('buildTransactionDataMeterValues', async () => {
+    await it('should return array containing both begin and end meter values', () => {
+      // Arrange
+      const beginMeterValue: OCPP16MeterValue = {
+        sampledValue: [{ context: OCPP16MeterValueContext.TRANSACTION_BEGIN, value: '0' }],
+        timestamp: new Date('2025-01-01T00:00:00Z'),
+      } as OCPP16MeterValue
+      const endMeterValue: OCPP16MeterValue = {
+        sampledValue: [{ context: OCPP16MeterValueContext.TRANSACTION_END, value: '100' }],
+        timestamp: new Date('2025-01-01T01:00:00Z'),
+      } as OCPP16MeterValue
+
+      // Act
+      const result = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+        beginMeterValue,
+        endMeterValue
+      )
+
+      // Assert
+      assert.strictEqual(result.length, 2)
+      assert.strictEqual(result[0], beginMeterValue)
+      assert.strictEqual(result[1], endMeterValue)
+    })
+
+    await it('should return a new array instance', () => {
+      const beginMeterValue: OCPP16MeterValue = {
+        sampledValue: [],
+        timestamp: new Date(),
+      } as OCPP16MeterValue
+      const endMeterValue: OCPP16MeterValue = {
+        sampledValue: [],
+        timestamp: new Date(),
+      } as OCPP16MeterValue
+
+      const result1 = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+        beginMeterValue,
+        endMeterValue
+      )
+      const result2 = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+        beginMeterValue,
+        endMeterValue
+      )
+
+      // Different array instances
+      assert.notStrictEqual(result1, result2)
+    })
+  })
+
+  // ─── buildTransactionEndMeterValue ─────────────────────────────────────
+
+  await describe('buildTransactionEndMeterValue', async () => {
+    await it('should return a meter value with Transaction.End context', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000)
+
+      // Assert
+      assert.notStrictEqual(meterValue, undefined)
+      assert.ok(meterValue.timestamp instanceof Date)
+      assert.strictEqual(meterValue.sampledValue.length, 1)
+      assert.strictEqual(
+        meterValue.sampledValue[0].context,
+        OCPP16MeterValueContext.TRANSACTION_END
+      )
+    })
+
+    await it('should apply kWh unit divider for end meter value', () => {
+      // Arrange
+      const { station } = createMockChargingStation({
+        ocppVersion: OCPPVersion.VERSION_16,
+        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+      })
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.MeterValues = createMeterValuesTemplate([
+          {
+            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+            unit: OCPP16MeterValueUnit.KILO_WATT_HOUR,
+            value: '0',
+          },
+        ])
+      }
+
+      // Act
+      const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 3000)
+
+      // Assert — kWh divider: 3000 / 1000 = 3
+      assert.strictEqual(meterValue.sampledValue[0].value, '3')
+    })
+  })
+})
index 630ef0928ba4211174bf09e06bbd2d1fe850eb36..cc3d4f24615200aa875b1c59c223fa107ab1a5ec 100644 (file)
@@ -1,11 +1,10 @@
 /**
  * @file Tests for OCPP16ServiceUtils pure utility functions
- * @module OCPP 1.6 — §4.7 MeterValues (meter value building), §9.3 SetChargingProfile
- *   (charging profile management), §3 ChargePoint status (connector status transitions),
- *   §9.4 ClearChargingProfile (Errata 3.25 AND logic), authorization cache updates
- * @description Verifies pure static methods on OCPP16ServiceUtils: meter value building,
- * charging profile management, feature profile checking, command support checks,
- * and authorization cache update behavior.
+ * @module OCPP 1.6 — §9.3 SetChargingProfile (charging profile management), §3 ChargePoint status
+ *   (connector status transitions), §9.4 ClearChargingProfile (Errata 3.25 AND logic),
+ *   authorization cache updates
+ * @description Verifies pure static methods on OCPP16ServiceUtils: charging profile management,
+ * feature profile checking, command support checks, and authorization cache update behavior.
  */
 
 import assert from 'node:assert/strict'
@@ -32,10 +31,6 @@ import {
   type OCPP16ClearChargingProfileRequest,
   type OCPP16IdTagInfo,
   OCPP16IncomingRequestCommand,
-  type OCPP16MeterValue,
-  OCPP16MeterValueContext,
-  OCPP16MeterValueMeasurand,
-  OCPP16MeterValueUnit,
   OCPP16RequestCommand,
   OCPP16StandardParametersKey,
   type OCPP16StatusNotificationRequest,
@@ -46,242 +41,13 @@ import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME, TEST_ID_TAG } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
 import { getTestAuthCache } from '../auth/helpers/MockFactories.js'
-import { createCommandsSupport, createMeterValuesTemplate } from './OCPP16TestUtils.js'
+import { createCommandsSupport } from './OCPP16TestUtils.js'
 
 await describe('OCPP16ServiceUtils — pure functions', async () => {
   afterEach(() => {
     standardCleanup()
   })
 
-  // ─── buildTransactionBeginMeterValue ───────────────────────────────────
-
-  await describe('buildTransactionBeginMeterValue', async () => {
-    await it('should return a meter value with Transaction.Begin context when template exists', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000)
-
-      // Assert
-      assert.notStrictEqual(meterValue, undefined)
-      assert.ok(meterValue.timestamp instanceof Date)
-      assert.strictEqual(Array.isArray(meterValue.sampledValue), true)
-      assert.strictEqual(meterValue.sampledValue.length, 1)
-      assert.strictEqual(
-        meterValue.sampledValue[0].context,
-        OCPP16MeterValueContext.TRANSACTION_BEGIN
-      )
-    })
-
-    await it('should apply Wh unit divider of 1 for meterStart', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000)
-
-      // Assert — Wh divider is 1, so value = 5000 / 1 = 5000
-      assert.strictEqual(meterValue.sampledValue[0].value, '5000')
-    })
-
-    await it('should apply kWh unit divider of 1000 for meterStart', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.KILO_WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000)
-
-      // Assert — kWh divider is 1000, so value = 5000 / 1000 = 5
-      assert.strictEqual(meterValue.sampledValue[0].value, '5')
-    })
-
-    await it('should use meterStart 0 when undefined', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, undefined)
-
-      // Assert — undefined meterStart defaults to 0
-      assert.strictEqual(meterValue.sampledValue[0].value, '0')
-    })
-
-    await it('should throw when MeterValues template is empty (missing default measurand)', () => {
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-
-      assert.throws(
-        () => {
-          OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 100)
-        },
-        { message: /Missing MeterValues for default measurand/ }
-      )
-    })
-  })
-
-  // ─── buildTransactionDataMeterValues ───────────────────────────────────
-
-  await describe('buildTransactionDataMeterValues', async () => {
-    await it('should return array containing both begin and end meter values', () => {
-      // Arrange
-      const beginMeterValue: OCPP16MeterValue = {
-        sampledValue: [{ context: OCPP16MeterValueContext.TRANSACTION_BEGIN, value: '0' }],
-        timestamp: new Date('2025-01-01T00:00:00Z'),
-      } as OCPP16MeterValue
-      const endMeterValue: OCPP16MeterValue = {
-        sampledValue: [{ context: OCPP16MeterValueContext.TRANSACTION_END, value: '100' }],
-        timestamp: new Date('2025-01-01T01:00:00Z'),
-      } as OCPP16MeterValue
-
-      // Act
-      const result = OCPP16ServiceUtils.buildTransactionDataMeterValues(
-        beginMeterValue,
-        endMeterValue
-      )
-
-      // Assert
-      assert.strictEqual(result.length, 2)
-      assert.strictEqual(result[0], beginMeterValue)
-      assert.strictEqual(result[1], endMeterValue)
-    })
-
-    await it('should return a new array instance', () => {
-      const beginMeterValue: OCPP16MeterValue = {
-        sampledValue: [],
-        timestamp: new Date(),
-      } as OCPP16MeterValue
-      const endMeterValue: OCPP16MeterValue = {
-        sampledValue: [],
-        timestamp: new Date(),
-      } as OCPP16MeterValue
-
-      const result1 = OCPP16ServiceUtils.buildTransactionDataMeterValues(
-        beginMeterValue,
-        endMeterValue
-      )
-      const result2 = OCPP16ServiceUtils.buildTransactionDataMeterValues(
-        beginMeterValue,
-        endMeterValue
-      )
-
-      // Different array instances
-      assert.notStrictEqual(result1, result2)
-    })
-  })
-
-  // ─── buildTransactionEndMeterValue ─────────────────────────────────────
-
-  await describe('buildTransactionEndMeterValue', async () => {
-    await it('should return a meter value with Transaction.End context', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000)
-
-      // Assert
-      assert.notStrictEqual(meterValue, undefined)
-      assert.ok(meterValue.timestamp instanceof Date)
-      assert.strictEqual(meterValue.sampledValue.length, 1)
-      assert.strictEqual(
-        meterValue.sampledValue[0].context,
-        OCPP16MeterValueContext.TRANSACTION_END
-      )
-    })
-
-    await it('should apply kWh unit divider for end meter value', () => {
-      // Arrange
-      const { station } = createMockChargingStation({
-        ocppVersion: OCPPVersion.VERSION_16,
-        stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
-      })
-      const connectorStatus = station.getConnectorStatus(1)
-      if (connectorStatus != null) {
-        connectorStatus.MeterValues = createMeterValuesTemplate([
-          {
-            measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
-            unit: OCPP16MeterValueUnit.KILO_WATT_HOUR,
-            value: '0',
-          },
-        ])
-      }
-
-      // Act
-      const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 3000)
-
-      // Assert — kWh divider: 3000 / 1000 = 3
-      assert.strictEqual(meterValue.sampledValue[0].value, '3')
-    })
-  })
-
   // ─── clearChargingProfiles ──────────────────────────────────────────────
 
   await describe('clearChargingProfiles', async () => {
index 89d1459bd37f04c5dc9add6f8a86fd099a995a79..6b49cd29ac488effff8f53836bc673e4cb7584d1 100644 (file)
@@ -2792,6 +2792,31 @@ await describe('OCPP20 TransactionEvent ServiceUtils', async () => {
       // Assert
       assert.strictEqual(result.length, 0)
     })
+
+    await it('should return empty array when EVSE has no MeterValues template', () => {
+      // Arrange
+      const transactionId = generateUUID()
+      const connectorStatus = station.getConnectorStatus(1)
+      if (connectorStatus != null) {
+        connectorStatus.transactionId = transactionId
+        connectorStatus.transactionStarted = true
+        connectorStatus.transactionEnergyActiveImportRegisterValue = 0
+      }
+
+      addConfigurationKey(
+        station,
+        `${OCPP20ComponentName.SampledDataCtrlr}.${OCPP20RequiredVariableName.TxStartedMeasurands}`,
+        'Energy.Active.Import.Register',
+        undefined,
+        { save: false }
+      )
+
+      // Act
+      const result = OCPP20ServiceUtils.buildTransactionStartedMeterValues(station, transactionId)
+
+      // Assert
+      assert.strictEqual(result.length, 0)
+    })
   })
 
   await describe('buildTransactionEndedMeterValues', async () => {
index 09b04f7121f82fb482957d4b9214b7bfcbbaf644..031f2e2ff04f6563eb99c6a67e75a9b085dee65b 100644 (file)
@@ -16,11 +16,14 @@ import { afterEach, beforeEach, describe, it } from 'node:test'
 
 import type { ChargingStation } from '../../../src/charging-station/index.js'
 
+import { addConfigurationKey } from '../../../src/charging-station/index.js'
 import { buildMeterValue } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js'
 import {
+  MeterValueContext,
   MeterValueMeasurand,
   OCPPVersion,
   type SampledValueTemplate,
+  StandardParametersKey,
 } from '../../../src/types/index.js'
 import { Constants } from '../../../src/utils/index.js'
 import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js'
@@ -37,6 +40,30 @@ const energyTemplate: SampledValueTemplate = {
   value: '0',
 } as unknown as SampledValueTemplate
 
+const socTemplate: SampledValueTemplate = {
+  measurand: MeterValueMeasurand.STATE_OF_CHARGE,
+  unit: 'Percent',
+  value: '75',
+} as unknown as SampledValueTemplate
+
+const voltageTemplate: SampledValueTemplate = {
+  measurand: MeterValueMeasurand.VOLTAGE,
+  unit: 'V',
+  value: '230',
+} as unknown as SampledValueTemplate
+
+const powerTemplate: SampledValueTemplate = {
+  measurand: MeterValueMeasurand.POWER_ACTIVE_IMPORT,
+  unit: 'W',
+  value: '11000',
+} as unknown as SampledValueTemplate
+
+const currentTemplate: SampledValueTemplate = {
+  measurand: MeterValueMeasurand.CURRENT_IMPORT,
+  unit: 'A',
+  value: '16',
+} as unknown as SampledValueTemplate
+
 await describe('buildMeterValue', async () => {
   let station: ChargingStation
 
@@ -150,4 +177,109 @@ await describe('buildMeterValue', async () => {
       )
     })
   })
+
+  // Builder output tests — version-parameterized
+  const VERSIONS = [
+    { transactionId: TEST_TRANSACTION_ID, useEvses: false, version: OCPPVersion.VERSION_16 },
+    { transactionId: TEST_TRANSACTION_ID_STRING, useEvses: true, version: OCPPVersion.VERSION_201 },
+  ] as const
+
+  for (const { transactionId, useEvses, version } of VERSIONS) {
+    await describe(`builder output — ${version}`, async () => {
+      const createStation = (templates: SampledValueTemplate[]): ChargingStation => {
+        const opts = useEvses
+          ? {
+              baseName: TEST_CHARGING_STATION_BASE_NAME,
+              connectorsCount: 1,
+              evseConfiguration: { evsesCount: 1 },
+              heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+              stationInfo: { ocppVersion: version },
+              websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL,
+            }
+          : {
+              baseName: TEST_CHARGING_STATION_BASE_NAME,
+              connectorsCount: 1,
+              heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL,
+              stationInfo: { ocppVersion: version },
+              websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL,
+            }
+        const { station: s } = createMockChargingStation(opts)
+        const connectorStatus = s.getConnectorStatus(1)
+        if (connectorStatus != null) {
+          connectorStatus.MeterValues = templates
+          connectorStatus.transactionId = transactionId
+        }
+        return s
+      }
+
+      await it('should propagate context to all sampled values', () => {
+        // Arrange
+        const s = createStation([energyTemplate])
+
+        // Act
+        const meterValue = buildMeterValue(
+          s,
+          transactionId,
+          0,
+          undefined,
+          MeterValueContext.TRANSACTION_BEGIN
+        )
+
+        // Assert
+        for (const sampledValue of meterValue.sampledValue) {
+          assert.strictEqual(sampledValue.context, MeterValueContext.TRANSACTION_BEGIN)
+        }
+      })
+
+      await it('should build sampled values in correct measurand order', () => {
+        // Arrange
+        const s = createStation([
+          socTemplate,
+          voltageTemplate,
+          powerTemplate,
+          currentTemplate,
+          energyTemplate,
+        ])
+        addConfigurationKey(
+          s,
+          StandardParametersKey.MeterValuesSampledData,
+          'SoC,Voltage,Power.Active.Import,Current.Import,Energy.Active.Import.Register'
+        )
+
+        // Act
+        const meterValue = buildMeterValue(s, transactionId, 0)
+
+        // Assert
+        const measurands = meterValue.sampledValue.map(sv => sv.measurand)
+        const socIdx = measurands.indexOf(MeterValueMeasurand.STATE_OF_CHARGE)
+        const voltageIdx = measurands.indexOf(MeterValueMeasurand.VOLTAGE)
+        const powerIdx = measurands.indexOf(MeterValueMeasurand.POWER_ACTIVE_IMPORT)
+        const currentIdx = measurands.indexOf(MeterValueMeasurand.CURRENT_IMPORT)
+        const energyIdx = measurands.indexOf(MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
+        assert.ok(socIdx < voltageIdx, 'SoC should come before Voltage')
+        assert.ok(voltageIdx < powerIdx, 'Voltage should come before Power')
+        assert.ok(powerIdx < currentIdx, 'Power should come before Current')
+        assert.ok(currentIdx < energyIdx, 'Current should come before Energy')
+      })
+
+      await it('should produce version-specific sampled value format', () => {
+        // Arrange
+        const s = createStation([energyTemplate])
+
+        // Act
+        const meterValue = buildMeterValue(s, transactionId, 0)
+
+        // Assert
+        assert.ok(meterValue.sampledValue.length > 0, 'should have at least one sampled value')
+        const sampledValue = meterValue.sampledValue[0]
+        if (version === OCPPVersion.VERSION_16) {
+          assert.strictEqual(typeof sampledValue.value, 'string')
+          assert.ok('unit' in sampledValue)
+        } else {
+          assert.strictEqual(typeof sampledValue.value, 'number')
+          assert.ok('unitOfMeasure' in sampledValue)
+        }
+      })
+    })
+  }
 })