From: Jérôme Benoit Date: Wed, 1 Apr 2026 22:45:21 +0000 (+0200) Subject: feat(ocpp): align OCPP 2.0 meter value builder with 1.6 parity X-Git-Tag: ocpp-server@v4.2.0~11 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=21556f732886a9c5c234c143a81668fede3d9991;p=e-mobility-charging-stations-simulator.git feat(ocpp): align OCPP 2.0 meter value builder with 1.6 parity 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. --- diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts b/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts index 901b43dd..f9e47e1f 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts @@ -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 } diff --git a/tests/charging-station/ChargingStation-Transactions.test.ts b/tests/charging-station/ChargingStation-Transactions.test.ts index 194851f9..66ec8c34 100644 --- a/tests/charging-station/ChargingStation-Transactions.test.ts +++ b/tests/charging-station/ChargingStation-Transactions.test.ts @@ -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 diff --git a/tests/charging-station/helpers/StationHelpers.ts b/tests/charging-station/helpers/StationHelpers.ts index b20995ab..d2ee2ae8 100644 --- a/tests/charging-station/helpers/StationHelpers.ts +++ b/tests/charging-station/helpers/StationHelpers.ts @@ -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 index 00000000..bfc4c8a4 --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils-MeterValues.test.ts @@ -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') + }) + }) +}) diff --git a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts index 630ef092..cc3d4f24 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts @@ -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 () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts index 89d1459b..6b49cd29 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-TransactionEvent.test.ts @@ -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 () => { diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts index 09b04f71..031f2e2f 100644 --- a/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts +++ b/tests/charging-station/ocpp/OCPPServiceUtils-meterValues.test.ts @@ -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) + } + }) + }) + } })