From e3ea37160d9fd43484f6cb0ef743ddb4fb3352f1 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 31 Mar 2026 00:44:20 +0200 Subject: [PATCH] refactor(ocpp): enforce strict version separation in OCPPServiceUtils Extract version-specific logic from cross-version utilities: - P1: Move buildStatusNotificationRequest into OCPP16ServiceUtils and OCPP20ServiceUtils as version-specific static methods - P2: Move buildTransactionEndMeterValue to OCPP16ServiceUtils, remove dead OCPP 2.0 branch that was never called - P3: Split buildMeterValue into thin dispatcher plus dedicated buildMeterValueForOCPP16/buildMeterValueForOCPP20 internal functions - P4: Simplify checkConnectorStatusTransition to select transition tables by version then apply single shared lookup logic Harmonize tests to match new module structure: - Rename OCPPServiceUtils-StopTransaction.test.ts to OCPPServiceOperations.test.ts (functions moved to that module) - Move mapStopReasonToOCPP20 tests to OCPPServiceUtils-pure.test.ts (function still lives in OCPPServiceUtils) - Add dedicated unit tests for buildStatusNotificationRequest in both OCPP16ServiceUtils.test.ts and new OCPP20ServiceUtils-StatusNotification.test.ts - Fix redundant describe nesting in OCPP20 test file --- .../ocpp/1.6/OCPP16RequestService.ts | 9 +- .../ocpp/1.6/OCPP16ResponseService.ts | 6 +- .../ocpp/1.6/OCPP16ServiceUtils.ts | 50 +- .../ocpp/2.0/OCPP20RequestService.ts | 8 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 31 + src/charging-station/ocpp/OCPPServiceUtils.ts | 905 ++++++++---------- src/charging-station/ocpp/index.ts | 7 +- .../ocpp/1.6/OCPP16ServiceUtils.test.ts | 60 +- ...P20ServiceUtils-StatusNotification.test.ts | 146 +++ ....test.ts => OCPPServiceOperations.test.ts} | 39 +- .../ocpp/OCPPServiceUtils-pure.test.ts | 34 +- 11 files changed, 728 insertions(+), 567 deletions(-) create mode 100644 tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-StatusNotification.test.ts rename tests/charging-station/ocpp/{OCPPServiceUtils-StopTransaction.test.ts => OCPPServiceOperations.test.ts} (89%) diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index 63891195..255bbc7c 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -19,8 +19,6 @@ import { import { Constants, generateUUID, logger } from '../../../utils/index.js' import { OCPPRequestService } from '../OCPPRequestService.js' import { - buildStatusNotificationRequest, - buildTransactionEndMeterValue, createPayloadValidatorMap, isRequestCommandSupported, sendAndSetConnectorStatus, @@ -215,8 +213,7 @@ export class OCPP16RequestService extends OCPPRequestService { ...commandParams, } as unknown as Request case OCPP16RequestCommand.STATUS_NOTIFICATION: - return buildStatusNotificationRequest( - chargingStation, + return OCPP16ServiceUtils.buildStatusNotificationRequest( commandParams as unknown as OCPP16StatusNotificationRequest ) as unknown as Request case OCPP16RequestCommand.STOP_TRANSACTION: @@ -237,11 +234,11 @@ export class OCPP16RequestService extends OCPPRequestService { transactionData: OCPP16ServiceUtils.buildTransactionDataMeterValues( chargingStation.getConnectorStatus(connectorId) ?.transactionBeginMeterValue as OCPP16MeterValue, - buildTransactionEndMeterValue( + OCPP16ServiceUtils.buildTransactionEndMeterValue( chargingStation, connectorId, energyActiveImportRegister - ) as OCPP16MeterValue + ) ), }), ...commandParams, diff --git a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts index 32cc33db..c0f7b452 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ResponseService.ts @@ -19,7 +19,6 @@ import { type OCPP16AuthorizeResponse, type OCPP16BootNotificationResponse, OCPP16ChargePointStatus, - type OCPP16MeterValue, type OCPP16MeterValuesRequest, type OCPP16MeterValuesResponse, OCPP16RequestCommand, @@ -38,7 +37,6 @@ import { import { Constants, convertToInt, logger, truncateId } from '../../../utils/index.js' import { OCPPResponseService } from '../OCPPResponseService.js' import { - buildTransactionEndMeterValue, createPayloadValidatorMap, isRequestCommandSupported, restoreConnectorStatus, @@ -497,11 +495,11 @@ export class OCPP16ResponseService extends OCPPResponseService { >(chargingStation, OCPP16RequestCommand.METER_VALUES, { connectorId: transactionConnectorId, meterValue: [ - buildTransactionEndMeterValue( + OCPP16ServiceUtils.buildTransactionEndMeterValue( chargingStation, transactionConnectorId, requestPayload.meterStop - ) as OCPP16MeterValue, + ), ], transactionId: requestPayload.transactionId, })) diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 026ba05e..7f6aa814 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -13,7 +13,9 @@ import { hasFeatureProfile, hasReservationExpired, } from '../../../charging-station/index.js' +import { BaseError } from '../../../exception/index.js' import { + ChargePointErrorCode, type ConfigurationKey, type GenericResponse, type MeterValuesRequest, @@ -28,6 +30,7 @@ import { OCPP16IncomingRequestCommand, type OCPP16MeterValue, OCPP16MeterValueContext, + OCPP16MeterValueMeasurand, OCPP16MeterValueUnit, OCPP16RequestCommand, type OCPP16SampledValue, @@ -55,7 +58,6 @@ import { buildEmptyMeterValue, buildMeterValue, buildSampledValue, - buildTransactionEndMeterValue, getSampledValueTemplate, PayloadValidatorConfig, PayloadValidatorOptions, @@ -103,6 +105,20 @@ export class OCPP16ServiceUtils { [OCPP16RequestCommand.STOP_TRANSACTION, 'StopTransaction'], ] + /** + * @param commandParams - Status notification parameters + * @returns Formatted OCPP 1.6 StatusNotification request payload + */ + public static buildStatusNotificationRequest ( + commandParams: OCPP16StatusNotificationRequest + ): OCPP16StatusNotificationRequest { + return { + connectorId: commandParams.connectorId, + errorCode: ChargePointErrorCode.NO_ERROR, + status: commandParams.status, + } satisfies OCPP16StatusNotificationRequest + } + /** * Builds a meter value for the beginning of a transaction. * @param chargingStation - Target charging station @@ -149,6 +165,36 @@ export class OCPP16ServiceUtils { return meterValues } + /** + * @param chargingStation - Target charging station + * @param connectorId - Connector ID associated with the transaction + * @param meterStop - Final meter reading in Wh at transaction end + * @returns MeterValue containing the transaction end energy reading + */ + public static buildTransactionEndMeterValue ( + chargingStation: ChargingStation, + connectorId: number, + meterStop: number | undefined + ): OCPP16MeterValue { + const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId) + if (sampledValueTemplate == null) { + throw new BaseError( + `Missing MeterValues for default measurand '${OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}` + ) + } + const unitDivider = sampledValueTemplate.unit === OCPP16MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 + const meterValue = buildEmptyMeterValue() as OCPP16MeterValue + meterValue.sampledValue.push( + buildSampledValue( + OCPPVersion.VERSION_16, + sampledValueTemplate, + roundTo((meterStop ?? 0) / unitDivider, 4), + OCPP16MeterValueContext.TRANSACTION_END + ) as OCPP16SampledValue + ) + return meterValue + } + /** * Changes the availability of connectors and updates their status. * @param chargingStation - Target charging station @@ -749,7 +795,7 @@ export class OCPP16ServiceUtils { chargingStation.stationInfo.ocppStrictCompliance === true && chargingStation.stationInfo.outOfOrderEndMeterValues === false ) { - const transactionEndMeterValue = buildTransactionEndMeterValue( + const transactionEndMeterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue( chargingStation, connectorId, chargingStation.getEnergyActiveImportRegisterByTransactionId(rawTransactionId) diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts index f5435a7b..0b1a4e15 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestService.ts @@ -18,11 +18,7 @@ import { } from '../../../types/index.js' import { generateUUID, logger } from '../../../utils/index.js' import { OCPPRequestService } from '../OCPPRequestService.js' -import { - buildStatusNotificationRequest, - createPayloadValidatorMap, - isRequestCommandSupported, -} from '../OCPPServiceUtils.js' +import { createPayloadValidatorMap, isRequestCommandSupported } from '../OCPPServiceUtils.js' import { generatePkcs10Csr } from './Asn1DerUtils.js' import { OCPP20Constants } from './OCPP20Constants.js' import { buildTransactionEvent, OCPP20ServiceUtils } from './OCPP20ServiceUtils.js' @@ -202,7 +198,7 @@ export class OCPP20RequestService extends OCPPRequestService { return requestPayload as unknown as Request } case OCPP20RequestCommand.STATUS_NOTIFICATION: - return buildStatusNotificationRequest( + return OCPP20ServiceUtils.buildStatusNotificationRequest( chargingStation, commandParams as unknown as OCPP20StatusNotificationRequest ) as unknown as Request diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 4f875bd6..64e304db 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -8,6 +8,7 @@ import { ErrorType, OCPP20ChargingStateEnumType, OCPP20ComponentName, + type OCPP20ConnectorStatusEnumType, type OCPP20EVSEType, OCPP20IncomingRequestCommand, type OCPP20MeterValue, @@ -25,6 +26,7 @@ import { OCPP20TriggerReasonEnumType, OCPPVersion, ReasonCodeEnumType, + RequestCommand, type UUIDv4, } from '../../../types/index.js' import { @@ -100,6 +102,35 @@ export class OCPP20ServiceUtils { [OCPP20RequestCommand.TRANSACTION_EVENT, 'TransactionEvent'], ] + /** + * @param chargingStation - Target charging station for EVSE resolution + * @param commandParams - Status notification parameters + * @returns Formatted OCPP 2.0 StatusNotification request payload + */ + public static buildStatusNotificationRequest ( + chargingStation: ChargingStation, + commandParams: OCPP20StatusNotificationRequest + ): OCPP20StatusNotificationRequest { + const params = commandParams as Record + const connectorId = params.connectorId as number + const connectorStatus = (params.connectorStatus ?? params.status) as ConnectorStatusEnum + const evseId = params.evseId as number | undefined + const resolvedEvseId = evseId ?? chargingStation.getEvseIdByConnectorId(connectorId) + if (resolvedEvseId === undefined) { + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + `Cannot build status notification payload: evseId is undefined for connector ${connectorId.toString()}`, + RequestCommand.STATUS_NOTIFICATION + ) + } + return { + connectorId, + connectorStatus: connectorStatus as OCPP20ConnectorStatusEnumType, + evseId: resolvedEvseId, + timestamp: new Date(), + } satisfies OCPP20StatusNotificationRequest + } + /** * Build meter values for the start of a transaction. * @param chargingStation - Target charging station diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index 37ac2898..81f42a01 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -10,7 +10,6 @@ import type { StopTransactionReason } from '../../types/index.js' import { type ChargingStation, getConfigurationKey } from '../../charging-station/index.js' import { BaseError, OCPPError } from '../../exception/index.js' import { - ChargePointErrorCode, ChargingStationEvents, type ConfigurationKeyType, type ConnectorStatus, @@ -31,13 +30,10 @@ import { MeterValueUnit, type OCPP16MeterValue, type OCPP16SampledValue, - type OCPP16StatusNotificationRequest, OCPP16StopTransactionReason, - type OCPP20ConnectorStatusEnumType, type OCPP20MeterValue, OCPP20ReasonEnumType, type OCPP20SampledValue, - type OCPP20StatusNotificationRequest, OCPP20TriggerReasonEnumType, OCPPVersion, RequestCommand, @@ -85,56 +81,6 @@ interface SingleValueMeasurandData { value: number } -/** - * Builds a StatusNotification request payload for the appropriate OCPP version. - * @param chargingStation - Target charging station - * @param commandParams - Status notification parameters including connector ID and status - * @returns Formatted StatusNotification request payload - */ -export const buildStatusNotificationRequest = ( - chargingStation: ChargingStation, - commandParams: StatusNotificationRequest -): StatusNotificationRequest => { - switch (chargingStation.stationInfo?.ocppVersion) { - case OCPPVersion.VERSION_16: { - const params = commandParams as OCPP16StatusNotificationRequest - return { - connectorId: params.connectorId, - errorCode: ChargePointErrorCode.NO_ERROR, - status: params.status, - } satisfies OCPP16StatusNotificationRequest - } - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: { - const params = commandParams as Record - const connectorId = params.connectorId as number - const connectorStatus = (params.connectorStatus ?? params.status) as ConnectorStatusEnum - const evseId = params.evseId as number | undefined - const resolvedEvseId = evseId ?? chargingStation.getEvseIdByConnectorId(connectorId) - if (resolvedEvseId === undefined) { - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - `Cannot build status notification payload: evseId is undefined for connector ${connectorId.toString()}`, - RequestCommand.STATUS_NOTIFICATION - ) - } - return { - connectorId, - connectorStatus: connectorStatus as OCPP20ConnectorStatusEnumType, - evseId: resolvedEvseId, - timestamp: new Date(), - } satisfies OCPP20StatusNotificationRequest - } - default: - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Cannot build status notification payload: OCPP version ${chargingStation.stationInfo?.ocppVersion} not supported`, - RequestCommand.STATUS_NOTIFICATION - ) - } -} - /** * Sends a StatusNotification request and updates the connector status locally. * @param chargingStation - Target charging station @@ -275,39 +221,18 @@ const checkConnectorStatusTransition = ( status: ConnectorStatusEnum ): boolean => { const fromStatus = chargingStation.getConnectorStatus(connectorId)?.status - let transitionAllowed = false + let chargingStationTransitions: readonly { from?: ConnectorStatusEnum; to: ConnectorStatusEnum }[] + let connectorTransitions: readonly { from?: ConnectorStatusEnum; to: ConnectorStatusEnum }[] switch (chargingStation.stationInfo?.ocppVersion) { - case OCPPVersion.VERSION_16: { - if ( - (connectorId === 0 && - OCPP16Constants.ChargePointStatusChargingStationTransitions.findIndex( - transition => transition.from === fromStatus && transition.to === status - ) !== -1) || - (connectorId > 0 && - OCPP16Constants.ChargePointStatusConnectorTransitions.findIndex( - transition => transition.from === fromStatus && transition.to === status - ) !== -1) - ) { - transitionAllowed = true - } + case OCPPVersion.VERSION_16: + chargingStationTransitions = OCPP16Constants.ChargePointStatusChargingStationTransitions + connectorTransitions = OCPP16Constants.ChargePointStatusConnectorTransitions break - } case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: { - if ( - (connectorId === 0 && - OCPP20Constants.ChargingStationStatusTransitions.findIndex( - transition => transition.from === fromStatus && transition.to === status - ) !== -1) || - (connectorId > 0 && - OCPP20Constants.ConnectorStatusTransitions.findIndex( - transition => transition.from === fromStatus && transition.to === status - ) !== -1) - ) { - transitionAllowed = true - } + case OCPPVersion.VERSION_201: + chargingStationTransitions = OCPP20Constants.ChargingStationStatusTransitions + connectorTransitions = OCPP20Constants.ConnectorStatusTransitions break - } default: throw new OCPPError( ErrorType.INTERNAL_ERROR, @@ -316,6 +241,10 @@ const checkConnectorStatusTransition = ( RequestCommand.STATUS_NOTIFICATION ) } + const transitions = connectorId === 0 ? chargingStationTransitions : connectorTransitions + const transitionAllowed = transitions.some( + transition => transition.from === fromStatus && transition.to === status + ) if (!transitionAllowed) { logger.warn( `${chargingStation.logPrefix()} OCPP ${ @@ -1214,468 +1143,434 @@ export const buildMeterValue = ( return buildEmptyMeterValue() } switch (chargingStation.stationInfo?.ocppVersion) { - case OCPPVersion.VERSION_16: { - const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) - if (connectorId == null) { - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - `Cannot build MeterValues: no connector found for transaction ${String(transactionId)}`, - RequestCommand.METER_VALUES - ) - } - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - const meterValue = buildEmptyMeterValue() as OCPP16MeterValue - const buildVersionedSampledValue = ( - sampledValueTemplate: SampledValueTemplate, - value: number, - context?: MeterValueContext, - phase?: MeterValuePhase - ): OCPP16SampledValue => { - return buildSampledValueForOCPP16(sampledValueTemplate, value, context, phase) - } - // SoC measurand - const socMeasurand = buildSocMeasurandValue( + case OCPPVersion.VERSION_16: + return buildMeterValueForOCPP16( chargingStation, - connectorId, - undefined, - measurandsKey + transactionId, + interval, + measurandsKey, + context, + debug ) - if (socMeasurand != null) { - const socSampledValue = buildVersionedSampledValue( - socMeasurand.template, - socMeasurand.value - ) - meterValue.sampledValue.push(socSampledValue) - validateSocMeasurandValue( - chargingStation, - connectorId, - socSampledValue, - socMeasurand.template.minimumValue ?? 0, - 100, - debug - ) - } - // Voltage measurand - const voltageMeasurand = buildVoltageMeasurandValue( + case OCPPVersion.VERSION_20: + case OCPPVersion.VERSION_201: + return buildMeterValueForOCPP20( chargingStation, - connectorId, - undefined, - measurandsKey + transactionId, + interval, + measurandsKey, + context, + debug ) - if (voltageMeasurand != null) { - addMainVoltageToMeterValue( - chargingStation, - meterValue, - voltageMeasurand, - buildVersionedSampledValue - ) - for ( - let phase = 1; - chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases(); - phase++ - ) { - addPhaseVoltageToMeterValue( - chargingStation, - connectorId, - meterValue, - voltageMeasurand, - phase, - buildVersionedSampledValue - ) - addLineToLineVoltageToMeterValue( - chargingStation, - connectorId, - meterValue, - voltageMeasurand, - phase, - buildVersionedSampledValue - ) - } - } - // Power.Active.Import measurand - const powerMeasurand = buildPowerMeasurandValue( + default: + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `Cannot build meterValue: OCPP version ${chargingStation.stationInfo?.ocppVersion} not supported`, + RequestCommand.METER_VALUES + ) + } +} + +const buildMeterValueForOCPP16 = ( + chargingStation: ChargingStation, + transactionId: number | string, + interval: number, + measurandsKey?: ConfigurationKeyType, + context?: MeterValueContext, + debug = false +): OCPP16MeterValue => { + const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) + if (connectorId == null) { + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + `Cannot build MeterValues: no connector found for transaction ${String(transactionId)}`, + RequestCommand.METER_VALUES + ) + } + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const meterValue = buildEmptyMeterValue() as OCPP16MeterValue + const buildVersionedSampledValue = ( + sampledValueTemplate: SampledValueTemplate, + value: number, + context?: MeterValueContext, + phase?: MeterValuePhase + ): OCPP16SampledValue => { + return buildSampledValueForOCPP16(sampledValueTemplate, value, context, phase) + } + // SoC measurand + const socMeasurand = buildSocMeasurandValue( + chargingStation, + connectorId, + undefined, + measurandsKey + ) + if (socMeasurand != null) { + const socSampledValue = buildVersionedSampledValue(socMeasurand.template, socMeasurand.value) + meterValue.sampledValue.push(socSampledValue) + validateSocMeasurandValue( + chargingStation, + connectorId, + socSampledValue, + socMeasurand.template.minimumValue ?? 0, + 100, + debug + ) + } + // Voltage measurand + const voltageMeasurand = buildVoltageMeasurandValue( + chargingStation, + connectorId, + undefined, + measurandsKey + ) + if (voltageMeasurand != null) { + addMainVoltageToMeterValue( + chargingStation, + meterValue, + voltageMeasurand, + buildVersionedSampledValue + ) + for ( + let phase = 1; + chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases(); + phase++ + ) { + addPhaseVoltageToMeterValue( chargingStation, connectorId, - undefined, - measurandsKey + meterValue, + voltageMeasurand, + phase, + buildVersionedSampledValue ) - if (powerMeasurand != 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) - ) - 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, undefined, 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( + addLineToLineVoltageToMeterValue( chargingStation, connectorId, - undefined, - measurandsKey + meterValue, + voltageMeasurand, + phase, + buildVersionedSampledValue ) - if (currentMeasurand != 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) - ) - 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 + } + } + // Power.Active.Import measurand + const powerMeasurand = buildPowerMeasurandValue( + chargingStation, + connectorId, + undefined, + measurandsKey + ) + if (powerMeasurand != 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) + ) + 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( - currentMeasurand.perPhaseTemplates[ - phaseValue as keyof MeasurandPerPhaseSampledValueTemplates - ] ?? currentMeasurand.template, - currentMeasurand.values[phaseValue as keyof MeasurandPerPhaseSampledValueTemplates], - undefined, - phaseValue - ) + buildVersionedSampledValue(phaseTemplate, phasePowerValue, undefined, phaseValue) ) const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1 - validateCurrentMeasurandPhaseValue( + validatePowerMeasurandValue( chargingStation, connectorId, connectorStatus, meterValue.sampledValue[sampledValuesPerPhaseIndex], - connectorMaximumAmperage, - connectorMinimumAmperage, + connectorMaximumPowerPerPhase / unitDivider, + connectorMinimumPowerPerPhase / unitDivider, debug ) } } - // Energy.Active.Import.Register measurand (default) - const energyMeasurand = buildEnergyMeasurandValue( - chargingStation, - connectorId, - interval, - undefined, - measurandsKey - ) - if (energyMeasurand != null) { - updateConnectorEnergyValues(connectorStatus, energyMeasurand.value) - const unitDivider = - energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 - const energySampledValue = buildVersionedSampledValue( - energyMeasurand.template, - roundTo( - chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) / - unitDivider, - 2 - ) - ) - meterValue.sampledValue.push(energySampledValue) - const connectorMaximumAvailablePower = - chargingStation.getConnectorMaximumAvailablePower(connectorId) - const connectorMaximumEnergyRounded = roundTo( - (connectorMaximumAvailablePower * interval) / (3600 * 1000), - 2 - ) - const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2) - validateEnergyMeasurandValue( - chargingStation, - connectorId, - energySampledValue, - energyMeasurand.value, - connectorMinimumEnergyRounded, - connectorMaximumEnergyRounded, - interval, - debug - ) - } - return meterValue } - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: { - const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) - const evseId = chargingStation.getEvseIdByTransactionId(transactionId) - if (connectorId == null || evseId == null) { - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - `Cannot build MeterValues: no connector/EVSE found for transaction ${String(transactionId)}`, - RequestCommand.METER_VALUES - ) - } - const connectorStatus = chargingStation.getConnectorStatus(connectorId) - const meterValue = buildEmptyMeterValue() as OCPP20MeterValue - const buildVersionedSampledValue = ( - sampledValueTemplate: SampledValueTemplate, - value: number, - context?: MeterValueContext, - phase?: MeterValuePhase - ): OCPP20SampledValue => { - return buildSampledValueForOCPP20(sampledValueTemplate, value, context, phase) - } - // SoC measurand - const socMeasurand = buildSocMeasurandValue( - chargingStation, - connectorId, - evseId, - measurandsKey - ) - if (socMeasurand != null) { - const socSampledValue = buildVersionedSampledValue( - socMeasurand.template, - socMeasurand.value, - context - ) - meterValue.sampledValue.push(socSampledValue) - validateSocMeasurandValue( - chargingStation, - connectorId, - socSampledValue, - socMeasurand.template.minimumValue ?? 0, - 100, - debug - ) - } - // Voltage measurand - const voltageMeasurand = buildVoltageMeasurandValue( - chargingStation, - connectorId, - evseId, - measurandsKey - ) - if (voltageMeasurand != null) { - addMainVoltageToMeterValue( - chargingStation, - meterValue, - voltageMeasurand, - buildVersionedSampledValue, - context - ) - for ( - let phase = 1; - chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases(); - phase++ - ) { - addPhaseVoltageToMeterValue( - chargingStation, - connectorId, - meterValue, - voltageMeasurand, - phase, - buildVersionedSampledValue, - measurandsKey, - context - ) - addLineToLineVoltageToMeterValue( - chargingStation, - connectorId, - meterValue, - voltageMeasurand, - phase, - buildVersionedSampledValue, - measurandsKey, - context - ) - } - } - // Energy.Active.Import.Register measurand - const energyMeasurand = buildEnergyMeasurandValue( - chargingStation, - connectorId, - interval, - evseId, - measurandsKey - ) - if (energyMeasurand != null) { - updateConnectorEnergyValues(connectorStatus, energyMeasurand.value) - const unitDivider = - energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 - const energySampledValue = buildVersionedSampledValue( - energyMeasurand.template, - roundTo( - chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) / - unitDivider, - 2 - ), - context - ) - meterValue.sampledValue.push(energySampledValue) - const connectorMaximumAvailablePower = - chargingStation.getConnectorMaximumAvailablePower(connectorId) - const connectorMaximumEnergyRounded = roundTo( - (connectorMaximumAvailablePower * interval) / (3600 * 1000), - 2 + } + // Current.Import measurand + const currentMeasurand = buildCurrentMeasurandValue( + chargingStation, + connectorId, + undefined, + measurandsKey + ) + if (currentMeasurand != null) { + const connectorMaximumAvailablePower = + chargingStation.getConnectorMaximumAvailablePower(connectorId) + const connectorMaximumAmperage = + chargingStation.stationInfo?.currentOutType === CurrentType.AC + ? ACElectricUtils.amperagePerPhaseFromPower( + chargingStation.getNumberOfPhases(), + connectorMaximumAvailablePower, + chargingStation.getVoltageOut() ) - const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2) - validateEnergyMeasurandValue( - chargingStation, - connectorId, - energySampledValue, - energyMeasurand.value, - connectorMinimumEnergyRounded, - connectorMaximumEnergyRounded, - interval, - debug + : DCElectricUtils.amperage(connectorMaximumAvailablePower, chargingStation.getVoltageOut()) + const connectorMinimumAmperage = currentMeasurand.template.minimumValue ?? 0 + + meterValue.sampledValue.push( + buildVersionedSampledValue(currentMeasurand.template, currentMeasurand.values.allPhases) + ) + 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], + undefined, + phaseValue ) - } - // 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( + const sampledValuesPerPhaseIndex = meterValue.sampledValue.length - 1 + validateCurrentMeasurandPhaseValue( chargingStation, connectorId, - evseId, - measurandsKey + connectorStatus, + meterValue.sampledValue[sampledValuesPerPhaseIndex], + connectorMaximumAmperage, + connectorMinimumAmperage, + debug ) - if (currentMeasurand?.values.allPhases != null) { - const currentSampledValue = buildVersionedSampledValue( - currentMeasurand.template, - currentMeasurand.values.allPhases, - context - ) - meterValue.sampledValue.push(currentSampledValue) - } - return meterValue } - default: - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Cannot build meterValue: OCPP version ${chargingStation.stationInfo?.ocppVersion} not supported`, - RequestCommand.METER_VALUES + } + // Energy.Active.Import.Register measurand (default) + const energyMeasurand = buildEnergyMeasurandValue( + chargingStation, + connectorId, + interval, + undefined, + measurandsKey + ) + if (energyMeasurand != null) { + updateConnectorEnergyValues(connectorStatus, energyMeasurand.value) + const unitDivider = energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 + const energySampledValue = buildVersionedSampledValue( + energyMeasurand.template, + roundTo( + chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) / unitDivider, + 2 ) + ) + meterValue.sampledValue.push(energySampledValue) + const connectorMaximumAvailablePower = + chargingStation.getConnectorMaximumAvailablePower(connectorId) + const connectorMaximumEnergyRounded = roundTo( + (connectorMaximumAvailablePower * interval) / (3600 * 1000), + 2 + ) + const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2) + validateEnergyMeasurandValue( + chargingStation, + connectorId, + energySampledValue, + energyMeasurand.value, + connectorMinimumEnergyRounded, + connectorMaximumEnergyRounded, + interval, + debug + ) } + return meterValue } -/** - * Builds a MeterValue for the end of a transaction with the final energy register value. - * @param chargingStation - Target charging station - * @param connectorId - Connector ID associated with the transaction - * @param meterStop - Final meter reading in Wh at transaction end - * @returns MeterValue containing the transaction end energy reading - */ -export const buildTransactionEndMeterValue = ( +const buildMeterValueForOCPP20 = ( chargingStation: ChargingStation, - connectorId: number, - meterStop: number | undefined -): MeterValue => { - const sampledValueTemplate = getSampledValueTemplate(chargingStation, connectorId) - if (sampledValueTemplate == null) { - throw new BaseError( - `Missing MeterValues for default measurand '${MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER}' in template on connector id ${connectorId.toString()}` + transactionId: number | string, + interval: number, + measurandsKey?: ConfigurationKeyType, + context?: MeterValueContext, + debug = false +): OCPP20MeterValue => { + const connectorId = chargingStation.getConnectorIdByTransactionId(transactionId) + const evseId = chargingStation.getEvseIdByTransactionId(transactionId) + if (connectorId == null || evseId == null) { + throw new OCPPError( + ErrorType.INTERNAL_ERROR, + `Cannot build MeterValues: no connector/EVSE found for transaction ${String(transactionId)}`, + RequestCommand.METER_VALUES ) } - const unitDivider = sampledValueTemplate.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 - switch (chargingStation.stationInfo?.ocppVersion) { - case OCPPVersion.VERSION_16: { - const meterValue = buildEmptyMeterValue() as OCPP16MeterValue - meterValue.sampledValue.push( - buildSampledValueForOCPP16( - sampledValueTemplate, - roundTo((meterStop ?? 0) / unitDivider, 4), - MeterValueContext.TRANSACTION_END - ) + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const meterValue = buildEmptyMeterValue() as OCPP20MeterValue + const buildVersionedSampledValue = ( + sampledValueTemplate: SampledValueTemplate, + value: number, + context?: MeterValueContext, + phase?: MeterValuePhase + ): OCPP20SampledValue => { + return buildSampledValueForOCPP20(sampledValueTemplate, value, context, phase) + } + // SoC measurand + const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId, evseId, measurandsKey) + if (socMeasurand != null) { + const socSampledValue = buildVersionedSampledValue( + socMeasurand.template, + socMeasurand.value, + context + ) + meterValue.sampledValue.push(socSampledValue) + validateSocMeasurandValue( + chargingStation, + connectorId, + socSampledValue, + socMeasurand.template.minimumValue ?? 0, + 100, + debug + ) + } + // Voltage measurand + const voltageMeasurand = buildVoltageMeasurandValue( + chargingStation, + connectorId, + evseId, + measurandsKey + ) + if (voltageMeasurand != null) { + addMainVoltageToMeterValue( + chargingStation, + meterValue, + voltageMeasurand, + buildVersionedSampledValue, + context + ) + for ( + let phase = 1; + chargingStation.getNumberOfPhases() === 3 && phase <= chargingStation.getNumberOfPhases(); + phase++ + ) { + addPhaseVoltageToMeterValue( + chargingStation, + connectorId, + meterValue, + voltageMeasurand, + phase, + buildVersionedSampledValue, + measurandsKey, + context ) - return meterValue - } - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: { - const meterValue = buildEmptyMeterValue() as OCPP20MeterValue - meterValue.sampledValue.push( - buildSampledValueForOCPP20( - sampledValueTemplate, - roundTo((meterStop ?? 0) / unitDivider, 4), - MeterValueContext.TRANSACTION_END - ) + addLineToLineVoltageToMeterValue( + chargingStation, + connectorId, + meterValue, + voltageMeasurand, + phase, + buildVersionedSampledValue, + measurandsKey, + context ) - return meterValue } - default: - throw new OCPPError( - ErrorType.INTERNAL_ERROR, - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `Cannot build meterValue: OCPP version ${chargingStation.stationInfo?.ocppVersion} not supported`, - RequestCommand.METER_VALUES - ) } + // Energy.Active.Import.Register measurand + const energyMeasurand = buildEnergyMeasurandValue( + chargingStation, + connectorId, + interval, + evseId, + measurandsKey + ) + if (energyMeasurand != null) { + updateConnectorEnergyValues(connectorStatus, energyMeasurand.value) + const unitDivider = energyMeasurand.template.unit === MeterValueUnit.KILO_WATT_HOUR ? 1000 : 1 + const energySampledValue = buildVersionedSampledValue( + energyMeasurand.template, + roundTo( + chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId) / unitDivider, + 2 + ), + context + ) + meterValue.sampledValue.push(energySampledValue) + const connectorMaximumAvailablePower = + chargingStation.getConnectorMaximumAvailablePower(connectorId) + const connectorMaximumEnergyRounded = roundTo( + (connectorMaximumAvailablePower * interval) / (3600 * 1000), + 2 + ) + const connectorMinimumEnergyRounded = roundTo(energyMeasurand.template.minimumValue ?? 0, 2) + validateEnergyMeasurandValue( + chargingStation, + connectorId, + energySampledValue, + energyMeasurand.value, + connectorMinimumEnergyRounded, + connectorMaximumEnergyRounded, + interval, + 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 } const checkMeasurandPowerDivider = ( diff --git a/src/charging-station/ocpp/index.ts b/src/charging-station/ocpp/index.ts index ed86ff63..60d32f07 100644 --- a/src/charging-station/ocpp/index.ts +++ b/src/charging-station/ocpp/index.ts @@ -17,9 +17,4 @@ export { stopRunningTransactions, stopTransactionOnConnector, } from './OCPPServiceOperations.js' -export { - buildMeterValue, - buildStatusNotificationRequest, - buildTransactionEndMeterValue, - sendAndSetConnectorStatus, -} from './OCPPServiceUtils.js' +export { buildMeterValue, sendAndSetConnectorStatus } from './OCPPServiceUtils.js' diff --git a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts index 40cb28d4..720a7c9e 100644 --- a/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts +++ b/tests/charging-station/ocpp/1.6/OCPP16ServiceUtils.test.ts @@ -11,11 +11,12 @@ import { afterEach, describe, it } from 'node:test' import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' import { - buildTransactionEndMeterValue, isIncomingRequestCommandSupported, isRequestCommandSupported, } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js' import { + ChargePointErrorCode, + OCPP16ChargePointStatus, type OCPP16ChargingProfile, OCPP16ChargingProfileKindType, OCPP16ChargingProfilePurposeType, @@ -29,6 +30,7 @@ import { OCPP16MeterValueUnit, OCPP16RequestCommand, OCPP16StandardParametersKey, + type OCPP16StatusNotificationRequest, OCPP16SupportedFeatureProfiles, OCPPVersion, } from '../../../../src/types/index.js' @@ -233,7 +235,7 @@ await describe('OCPP16ServiceUtils — pure functions', async () => { } // Act - const meterValue = buildTransactionEndMeterValue(station, 1, 10000) + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000) // Assert assert.notStrictEqual(meterValue, undefined) @@ -263,7 +265,7 @@ await describe('OCPP16ServiceUtils — pure functions', async () => { } // Act - const meterValue = buildTransactionEndMeterValue(station, 1, 3000) + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 3000) // Assert — kWh divider: 3000 / 1000 = 3 assert.strictEqual(meterValue.sampledValue[0].value, '3') @@ -667,6 +669,58 @@ await describe('OCPP16ServiceUtils — pure functions', async () => { }) }) + // ─── buildStatusNotificationRequest ───────────────────────────────────── + + await describe('buildStatusNotificationRequest', async () => { + await it('should return payload with NO_ERROR error code', () => { + const input: OCPP16StatusNotificationRequest = { + connectorId: 1, + errorCode: ChargePointErrorCode.NO_ERROR, + status: OCPP16ChargePointStatus.Available, + } + + const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input) + + assert.strictEqual(result.errorCode, ChargePointErrorCode.NO_ERROR) + }) + + await it('should preserve connectorId from input', () => { + const input: OCPP16StatusNotificationRequest = { + connectorId: 2, + errorCode: ChargePointErrorCode.NO_ERROR, + status: OCPP16ChargePointStatus.Charging, + } + + const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input) + + assert.strictEqual(result.connectorId, 2) + }) + + await it('should preserve status from input', () => { + const input: OCPP16StatusNotificationRequest = { + connectorId: 1, + errorCode: ChargePointErrorCode.NO_ERROR, + status: OCPP16ChargePointStatus.Charging, + } + + const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input) + + assert.strictEqual(result.status, OCPP16ChargePointStatus.Charging) + }) + + await it('should always set errorCode to NO_ERROR regardless of input errorCode', () => { + const input: OCPP16StatusNotificationRequest = { + connectorId: 1, + errorCode: ChargePointErrorCode.CONNECTOR_LOCK_FAILURE, + status: OCPP16ChargePointStatus.Faulted, + } + + const result = OCPP16ServiceUtils.buildStatusNotificationRequest(input) + + assert.strictEqual(result.errorCode, ChargePointErrorCode.NO_ERROR) + }) + }) + // ─── isConfigurationKeyVisible ───────────────────────────────────────── await describe('isConfigurationKeyVisible', async () => { diff --git a/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-StatusNotification.test.ts b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-StatusNotification.test.ts new file mode 100644 index 00000000..4dd47608 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20ServiceUtils-StatusNotification.test.ts @@ -0,0 +1,146 @@ +/** + * @file Tests for OCPP20ServiceUtils buildStatusNotificationRequest + * @description Unit tests for OCPP 2.0 StatusNotification request building, + * including EVSE resolution, connector status mapping, and error handling. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { OCPP20ServiceUtils } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js' +import { OCPPError } from '../../../../src/exception/index.js' +import { + OCPP20ConnectorStatusEnumType, + type OCPP20StatusNotificationRequest, + OCPPVersion, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' + +await describe('OCPP20ServiceUtils', async () => { + let mockStation: ChargingStation + + beforeEach(() => { + const { station } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 2, + evseConfiguration: { evsesCount: 2 }, + heartbeatInterval: Constants.DEFAULT_HEARTBEAT_INTERVAL, + stationInfo: { + ocppStrictCompliance: true, + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL, + }) + mockStation = station + }) + + afterEach(() => { + standardCleanup() + }) + + await describe('buildStatusNotificationRequest', async () => { + await it('should resolve evseId from chargingStation when not provided', () => { + const input = { + connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, + } as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.strictEqual(typeof result.evseId, 'number') + assert.notStrictEqual(result.evseId, undefined) + }) + + await it('should use provided evseId when present', () => { + const input = { + connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, + evseId: 42, + } as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.strictEqual(result.evseId, 42) + }) + + await it('should throw OCPPError when evseId cannot be resolved', () => { + // Arrange + const { station: noEvseStation } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 0, + stationInfo: { + ocppStrictCompliance: true, + ocppVersion: OCPPVersion.VERSION_201, + }, + }) + const input = { + connectorId: 999, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, + } as OCPP20StatusNotificationRequest + + // Act & Assert + assert.throws( + () => { + OCPP20ServiceUtils.buildStatusNotificationRequest(noEvseStation, input) + }, + (error: unknown) => { + assert.ok(error instanceof OCPPError) + return true + } + ) + }) + + await it('should include timestamp in response', () => { + const input = { + connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Occupied, + evseId: 1, + } as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.ok(result.timestamp instanceof Date) + }) + + await it('should map connectorStatus correctly', () => { + const input = { + connectorId: 1, + connectorStatus: OCPP20ConnectorStatusEnumType.Occupied, + evseId: 1, + } as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.strictEqual(result.connectorStatus, OCPP20ConnectorStatusEnumType.Occupied) + }) + + await it('should preserve connectorId in response', () => { + const input = { + connectorId: 2, + connectorStatus: OCPP20ConnectorStatusEnumType.Available, + evseId: 2, + } as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.strictEqual(result.connectorId, 2) + }) + + await it('should accept status field as alias for connectorStatus', () => { + const input = { + connectorId: 1, + evseId: 1, + status: OCPP20ConnectorStatusEnumType.Faulted, + } as unknown as OCPP20StatusNotificationRequest + + const result = OCPP20ServiceUtils.buildStatusNotificationRequest(mockStation, input) + + assert.strictEqual(result.connectorStatus, OCPP20ConnectorStatusEnumType.Faulted) + }) + }) +}) diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts b/tests/charging-station/ocpp/OCPPServiceOperations.test.ts similarity index 89% rename from tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts rename to tests/charging-station/ocpp/OCPPServiceOperations.test.ts index 73a6f368..4f99f564 100644 --- a/tests/charging-station/ocpp/OCPPServiceUtils-StopTransaction.test.ts +++ b/tests/charging-station/ocpp/OCPPServiceOperations.test.ts @@ -1,7 +1,8 @@ /** - * @file Tests for OCPPServiceUtils stop transaction functions - * @description Verifies stopTransactionOnConnector and stopRunningTransactions - * version-dispatching functions + * @file Tests for OCPPServiceOperations version-dispatching functions + * @description Verifies startTransactionOnConnector, stopTransactionOnConnector, + * stopRunningTransactions, and flushQueuedTransactionMessages + * cross-version dispatchers */ import assert from 'node:assert/strict' @@ -16,12 +17,7 @@ import { stopRunningTransactions, stopTransactionOnConnector, } from '../../../src/charging-station/ocpp/OCPPServiceOperations.js' -import { mapStopReasonToOCPP20 } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' -import { - type OCPP20TransactionEventRequest, - OCPPVersion, - type StopTransactionReason, -} from '../../../src/types/index.js' +import { type OCPP20TransactionEventRequest, OCPPVersion } from '../../../src/types/index.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' import { createMockChargingStation } from '../ChargingStationTestUtils.js' @@ -85,7 +81,7 @@ function setupTransaction ( connectorStatus.idTagAuthorized = true } -await describe('OCPPServiceUtils — stop transaction functions', async () => { +await describe('OCPPServiceOperations', async () => { afterEach(() => { standardCleanup() }) @@ -310,27 +306,4 @@ await describe('OCPPServiceUtils — stop transaction functions', async () => { assert.strictEqual(connectorStatus.transactionEventQueue.length, 0) }) }) - - await describe('mapStopReasonToOCPP20', async () => { - await it('should map Other to Other/AbnormalCondition', () => { - const result = mapStopReasonToOCPP20('Other' as StopTransactionReason) - - assert.strictEqual(result.stoppedReason, 'Other') - assert.strictEqual(result.triggerReason, 'AbnormalCondition') - }) - - await it('should map undefined to Local/StopAuthorized', () => { - const result = mapStopReasonToOCPP20(undefined) - - assert.strictEqual(result.stoppedReason, 'Local') - assert.strictEqual(result.triggerReason, 'StopAuthorized') - }) - - await it('should map Remote to Remote/RemoteStop', () => { - const result = mapStopReasonToOCPP20('Remote' as StopTransactionReason) - - assert.strictEqual(result.stoppedReason, 'Remote') - assert.strictEqual(result.triggerReason, 'RemoteStop') - }) - }) }) diff --git a/tests/charging-station/ocpp/OCPPServiceUtils-pure.test.ts b/tests/charging-station/ocpp/OCPPServiceUtils-pure.test.ts index 236284dd..15b3eeb3 100644 --- a/tests/charging-station/ocpp/OCPPServiceUtils-pure.test.ts +++ b/tests/charging-station/ocpp/OCPPServiceUtils-pure.test.ts @@ -5,7 +5,8 @@ * Covers: * - ajvErrorsToErrorType — maps AJV validation errors to OCPP ErrorType * - convertDateToISOString — recursively converts Date objects to ISO strings in-place - * - OCPPServiceUtils.isConnectorIdValid — validates connector ID ranges + * - isConnectorIdValid — validates connector ID ranges + * - mapStopReasonToOCPP20 — maps OCPP 1.6 stop reasons to OCPP 2.0 equivalents */ import type { ErrorObject } from 'ajv' @@ -19,8 +20,14 @@ import { ajvErrorsToErrorType, convertDateToISOString, isConnectorIdValid, + mapStopReasonToOCPP20, } from '../../../src/charging-station/ocpp/OCPPServiceUtils.js' -import { ErrorType, IncomingRequestCommand, type JsonType } from '../../../src/types/index.js' +import { + ErrorType, + IncomingRequestCommand, + type JsonType, + type StopTransactionReason, +} from '../../../src/types/index.js' import { standardCleanup } from '../../helpers/TestLifecycleHelpers.js' /** @@ -162,4 +169,27 @@ await describe('OCPPServiceUtils — pure functions', async () => { assert.strictEqual(result, false) }) }) + + await describe('mapStopReasonToOCPP20', async () => { + await it('should map Other to Other/AbnormalCondition', () => { + const result = mapStopReasonToOCPP20('Other' as StopTransactionReason) + + assert.strictEqual(result.stoppedReason, 'Other') + assert.strictEqual(result.triggerReason, 'AbnormalCondition') + }) + + await it('should map undefined to Local/StopAuthorized', () => { + const result = mapStopReasonToOCPP20(undefined) + + assert.strictEqual(result.stoppedReason, 'Local') + assert.strictEqual(result.triggerReason, 'StopAuthorized') + }) + + await it('should map Remote to Remote/RemoteStop', () => { + const result = mapStopReasonToOCPP20('Remote' as StopTransactionReason) + + assert.strictEqual(result.stoppedReason, 'Remote') + assert.strictEqual(result.triggerReason, 'RemoteStop') + }) + }) }) -- 2.43.0