From 7d69543150e590831d93769443cd82d457c825bc Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Tue, 7 Apr 2026 19:05:43 +0200 Subject: [PATCH] feat(ocpp): add signed meter values support for OCPP 1.6 and 2.0.x (#1775) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * feat(types): export OCPP16MeterValueFormat and add OCPP16SignedMeterValue * feat(ocpp1.6): add vendor configuration keys for signed meter values * feat(ocpp2.0): add variable registry entries for signed meter readings * feat(mock-server): handle signed meter value payloads * feat(ocpp): add simulated signed meter data generator * feat(ocpp): add PublicKeyWithSignedMeterValue enum and state tracking * feat(ocpp2.0): populate signedMeterValue in sampled value building * feat(ocpp1.6): add signed meter value support to sampled value building * feat(templates): add station templates with signed meter value config * docs: document signed meter values configuration * fix(lint): apply formatter fixes to signed meter value files * fix(ocpp): fix publicKey state tracking and reset in signed meter values * fix(ocpp): fix OCMF payload, hash, and public key encoding * fix(ocpp): add defensive guards and improve signing code quality * fix(ocpp2.0): respect SignStartedReadings and SignUpdatedReadings sub-switches * [autofix.ci] apply automated fixes * fix(ocpp): consistent publicKey state update, version-gated vendor keys, use enum constants * [autofix.ci] apply automated fixes * refactor(ocpp): use union types in common code and correct enum classification per OCPP specs - Use VendorParametersKey (union) in ChargingStation.ts, not OCPP16VendorParametersKey - Remove VERSION_16 guard: OCPP2_PARAMETER_KEY_MAP resolves keys per version - Add 6 mapping entries to OCPP2_PARAMETER_KEY_MAP for signed MV config keys - Classify enums per OCPP 2.0.1 base spec: SignReadings and PublicKeyWithSignedMeterValue in OCPP20OptionalVariableName (Required: no) - Classify Application Note-only variables (SignStartedReadings, SignUpdatedReadings, PublicKey, SigningMethod) in OCPP20VendorVariableName - Replace all string literals with enum references in variable registry * [autofix.ci] apply automated fixes * fix(ocpp): fix corrupted Measurands entry, Wh/kWh unit handling, context mapping, and enum validation - Fix AlignedDataCtrlr.Measurands variable corrupted by lint auto-sort (was SignUpdatedReadings) - Add meterValueUnit to SignedMeterDataParams to handle kWh input without double-division - Replace unsafe context cast with explicit OCPP20-to-generator context mapping - Extract parsePublicKeyWithSignedMeterValue helper (hoisted Set, shared across OCPP versions) - Use MeterValueUnit enum constant instead of string literal for kWh comparison * [autofix.ci] apply automated fixes * refactor(ocpp): use MeterValueContext/MeterValueUnit enums and barrel imports instead of string literals * style: apply prettier formatting to SignedMeterDataGenerator test * fix(ocpp): add vendorSpecific flag, guard publicKey inclusion on key availability, add kWh test * refactor(ocpp): use PublicKeyWithSignedMeterValueEnumType.Never instead of string literal * test(ocpp): add JSDoc header, TX=P/Clock tests, non-energy measurand and transactionData force tests * test(ocpp1.6): add periodic signing tests with mock timers for startUpdatedMeterValues * refactor(test): use beforeEach for station creation in OCPP16SignedMeterValues per style guide * fix(ocpp): treat undefined context as periodic for SignUpdatedReadings, conditional try/catch for signing-forced transactionData, use node:assert/strict import * refactor(ocpp2.0): add defaultValue and enumeration to PublicKeyWithSignedMeterValue registry entry * refactor(ocpp1.6): remove redundant nullish coalescing on publicKeyHex * refactor(ocpp): use BaseError, return tuple in OCPP 2.0 builder, extract config reading in OCPP 1.6 * refactor(ocpp): harmonize naming, signatures, and data structures across signing paths - SignedMeterData extends JsonObject: eliminates as-cast to OCPP16SignedMeterValue - Rename publicKeyConfig to publicKeyWithSignedMeterValue: consistent with OCPP 2.0 interface - Rename meterValueWh to meterValue: unit-agnostic (generator handles via meterValueUnit) - Remove unused OCPP16SignedMeterValue import * fix(mock-server): log signed meter values in TransactionEvent.Ended and add test * fix(ocpp): include Transaction.Begin MV in TransactionEvent(Ended) per spec §4.1.2, set signingMethod to empty string per spec §3.2.1 * refactor(ocpp): rename SignedMeter* files to follow OCPP* naming convention * refactor(ocpp): extract generic SignedSampledValueResult to shared utils * refactor(ocpp): extract shared SigningConfig interface, OCPP20SampledValueSigningConfig extends it * refactor(ocpp): use roundTo helper instead of Number/toFixed for kWh conversion * style(mock-server): reorder signed MV tests to follow Started/Updated/Ended lifecycle * refactor(ocpp): move SampledValueSigningConfig to shared utils, remove version-specific type import from common code * fix(ocpp): map StartTxnSampledData to SampledDataCtrlr.TxStartedMeasurands, remove VERSION_16 guard * [autofix.ci] apply automated fixes * style: remove unnecessary parenthetical from eslint cspell comment * style: rename Configuration.test.ts to OCPP16VendorParametersKey.test.ts to reflect content * refactor(ocpp): rename ocpp20SigningConfig/State to signingConfig/State (version-agnostic) * style: harmonize blank lines in OCPP20VariableRegistry between component sections * [autofix.ci] apply automated fixes * style: remove unjustified blank lines before inline comments within same component section * fix(ocpp2.0): add missing DeviceDataCtrlr section comment in variable registry --------- Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> --- README.md | 19 + eslint.config.js | 19 + .../keba-ocpp2-signed.station-template.json | 151 +++++ ...irtual-simple-signed.station-template.json | 149 +++++ src/charging-station/ChargingStation.ts | 42 ++ src/charging-station/ConfigurationKeyUtils.ts | 32 ++ src/charging-station/Helpers.ts | 1 + .../ocpp/1.6/OCPP16RequestBuilders.ts | 24 + .../ocpp/1.6/OCPP16RequestService.ts | 62 ++- .../ocpp/1.6/OCPP16ServiceUtils.ts | 145 ++++- .../ocpp/2.0/OCPP20RequestBuilders.ts | 49 +- .../ocpp/2.0/OCPP20ServiceUtils.ts | 19 +- .../ocpp/2.0/OCPP20VariableRegistry.ts | 147 +++-- src/charging-station/ocpp/OCPPServiceUtils.ts | 96 +++- .../ocpp/OCPPSignedMeterDataGenerator.ts | 79 +++ .../ocpp/OCPPSignedMeterValueUtils.ts | 49 ++ src/charging-station/ocpp/index.ts | 13 + src/types/ConnectorStatus.ts | 1 + src/types/index.ts | 4 + src/types/ocpp/1.6/Configuration.ts | 8 + src/types/ocpp/1.6/MeterValues.ts | 17 +- src/types/ocpp/2.0/Variables.ts | 6 + src/types/ocpp/Configuration.ts | 6 + .../ocpp/1.6/OCPP16SignedMeterValues.test.ts | 494 ++++++++++++++++ .../ocpp/2.0/OCPP20SignedMeterValues.test.ts | 526 ++++++++++++++++++ .../ocpp/OCPPSignedMeterDataGenerator.test.ts | 154 +++++ .../ocpp/OCPPSignedMeterValueUtils.test.ts | 77 +++ tests/ocpp-server/server.py | 21 + tests/ocpp-server/test_server.py | 118 ++++ tests/types/ocpp/1.6/MeterValues.test.ts | 36 ++ .../1.6/OCPP16VendorParametersKey.test.ts | 58 ++ 31 files changed, 2556 insertions(+), 66 deletions(-) create mode 100644 src/assets/station-templates/keba-ocpp2-signed.station-template.json create mode 100644 src/assets/station-templates/virtual-simple-signed.station-template.json create mode 100644 src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts create mode 100644 src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts create mode 100644 tests/charging-station/ocpp/1.6/OCPP16SignedMeterValues.test.ts create mode 100644 tests/charging-station/ocpp/2.0/OCPP20SignedMeterValues.test.ts create mode 100644 tests/charging-station/ocpp/OCPPSignedMeterDataGenerator.test.ts create mode 100644 tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts create mode 100644 tests/types/ocpp/1.6/MeterValues.test.ts create mode 100644 tests/types/ocpp/1.6/OCPP16VendorParametersKey.test.ts diff --git a/README.md b/README.md index 9729a47d..e7cdadc9 100644 --- a/README.md +++ b/README.md @@ -672,6 +672,17 @@ All kind of OCPP parameters are supported in charging station configuration or c - _none_ +#### Vendor-specific Configuration Keys + +- :white_check_mark: AlignedDataSignReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: AlignedDataSignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: MeterPublicKey[ConnectorID] (type: string) (units: -) **(vendor-specific)** +- :white_check_mark: PublicKeyWithSignedMeterValue (type: optionlist) (units: -) **(vendor-specific)** +- :white_check_mark: SampledDataSignReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: SampledDataSignStartedReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: SampledDataSignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: StartTxnSampledData (type: memberlist) (units: -) **(vendor-specific)** + ### Version 2.0.x #### AlignedDataCtrlr @@ -682,6 +693,7 @@ All kind of OCPP parameters are supported in charging station configuration or c - :white_check_mark: Measurands (type: memberlist) (units: -) - :white_check_mark: SendDuringIdle (type: boolean) (units: -) - :white_check_mark: SignReadings (type: boolean) (units: -) +- :white_check_mark: SignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)** - :white_check_mark: TxEndedInterval (type: integer) (units: seconds) - :white_check_mark: TxEndedMeasurands (type: memberlist) (units: -) @@ -765,6 +777,11 @@ All kind of OCPP parameters are supported in charging station configuration or c - :white_check_mark: SimulateSignatureVerificationFailure (type: boolean) (units: -) **(vendor-specific)** +#### FiscalMetering + +- :white_check_mark: PublicKey (type: string) (units: -) **(vendor-specific)** +- :white_check_mark: SigningMethod (type: string) (units: -) **(vendor-specific)** + #### ISO15118Ctrlr - :white_check_mark: CentralContractValidationAllowed (type: boolean) (units: -) @@ -833,6 +850,8 @@ All kind of OCPP parameters are supported in charging station configuration or c - :white_check_mark: Enabled (type: boolean) (units: -) - :white_check_mark: RegisterValuesWithoutPhases (type: boolean) (units: -) - :white_check_mark: SignReadings (type: boolean) (units: -) +- :white_check_mark: SignStartedReadings (type: boolean) (units: -) **(vendor-specific)** +- :white_check_mark: SignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)** - :white_check_mark: TxEndedInterval (type: integer) (units: seconds) - :white_check_mark: TxEndedMeasurands (type: memberlist) (units: -) - :white_check_mark: TxStartedMeasurands (type: memberlist) (units: -) diff --git a/eslint.config.js b/eslint.config.js index 43fcfd7a..02c7b77d 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -96,6 +96,25 @@ export default defineConfig([ 'UIMCP', 'Streamable', 'modelcontextprotocol', + // Signed meter values + 'OCMF', + 'ocmf', + 'secp', + 'brainpool', + 'Eichrecht', + 'eichrecht', + 'signingmethod', + 'SIGNINGMETHOD', + 'encodingmethod', + 'ENCODINGMETHOD', + 'signedmeterdata', + 'SIGNEDMETERDATA', + 'fiscalmetering', + 'FISCALMETERING', + 'publickeywithsignedmetervalue', + 'PUBLICKEYWITHSIGNEDMETERVALUE', + 'sampleddatasignreadings', + 'SAMPLEDDATASIGNREADINGS', ], }, }, diff --git a/src/assets/station-templates/keba-ocpp2-signed.station-template.json b/src/assets/station-templates/keba-ocpp2-signed.station-template.json new file mode 100644 index 00000000..7d078a58 --- /dev/null +++ b/src/assets/station-templates/keba-ocpp2-signed.station-template.json @@ -0,0 +1,151 @@ +{ + "supervisionUrls": ["ws://localhost:9000"], + "supervisionUrlOcppConfiguration": true, + "supervisionUrlOcppKey": "CentralSystemAddress", + "idTagsFile": "idtags.json", + "baseName": "CS-KEBA-OCPP2-SIGNED", + "chargePointModel": "KC-P30-ESS400C2-E0R", + "chargePointVendor": "Keba AG", + "firmwareVersion": "1.10.1", + "power": 22080, + "powerUnit": "W", + "numberOfConnectors": 1, + "randomConnectors": false, + "amperageLimitationOcppKey": "MaxAvailableCurrent", + "amperageLimitationUnit": "mA", + "ocppVersion": "2.0.1", + "Configuration": { + "configurationKey": [ + { + "key": "AuthCtrlr.AuthorizeRemoteStart", + "readonly": false, + "value": "false" + }, + { + "key": "AuthCtrlr.LocalAuthorizationOffline", + "readonly": false, + "value": "true" + }, + { + "key": "AuthCtrlr.LocalPreAuthorization", + "readonly": false, + "value": "false" + }, + { + "key": "AuthCtrlr.OfflineTxForUnknownIdEnabled", + "readonly": false, + "value": "false" + }, + { + "key": "OCPPCommCtrlr.WebSocketPingInterval", + "readonly": false, + "value": "60" + }, + { + "key": "LocalAuthListCtrlr.Enabled", + "readonly": false, + "value": "false" + }, + { + "key": "OCPPCommCtrlr.MessageAttemptInterval.TransactionEvent", + "readonly": false, + "value": "20" + }, + { + "key": "OCPPCommCtrlr.MessageAttempts.TransactionEvent", + "readonly": false, + "value": "3" + }, + { + "key": "ReservationCtrlr.Enabled", + "readonly": false, + "value": "false" + }, + { + "key": "ReservationCtrlr.NonEvseSpecific", + "readonly": false, + "value": "false" + }, + { + "key": "SampledDataCtrlr.TxUpdatedInterval", + "readonly": false, + "value": "30" + }, + { + "key": "SampledDataCtrlr.TxUpdatedMeasurands", + "readonly": false, + "value": "Energy.Active.Import.Register,Power.Active.Import,Current.Import,Voltage" + }, + { + "key": "TxCtrlr.EVConnectionTimeOut", + "readonly": false, + "value": "180" + }, + { + "key": "SampledDataCtrlr.SignReadings", + "readonly": false, + "value": "true" + }, + { + "key": "SampledDataCtrlr.SignStartedReadings", + "readonly": false, + "value": "true" + }, + { + "key": "SampledDataCtrlr.SignUpdatedReadings", + "readonly": false, + "value": "false" + }, + { + "key": "OCPPCommCtrlr.PublicKeyWithSignedMeterValue", + "readonly": false, + "value": "EveryMeterValue" + }, + { + "key": "FiscalMetering.PublicKey", + "readonly": true, + "value": "3056301006072a8648ce3d020106052b8104000a03420004460a02ba2766d9c44f023ecc0e4e58644a87add1aadd6317e5fe4dccdb29b163a01d8a6297c84bc530f86431e92f8d46ab37830247c05cbd92fac252929e7f61" + } + ] + }, + "AutomaticTransactionGenerator": { + "enable": false, + "minDuration": 30, + "maxDuration": 60, + "minDelayBetweenTwoTransactions": 15, + "maxDelayBetweenTwoTransactions": 30, + "probabilityOfStart": 1, + "stopAfterHours": 0.3, + "requireAuthorize": true + }, + "Evses": { + "0": { + "Connectors": { + "0": {} + } + }, + "1": { + "Connectors": { + "1": { + "MeterValues": [ + { + "unit": "V", + "measurand": "Voltage" + }, + { + "unit": "W", + "measurand": "Power.Active.Import" + }, + { + "unit": "A", + "measurand": "Current.Import" + }, + { + "unit": "Wh" + } + ] + } + } + } + } +} diff --git a/src/assets/station-templates/virtual-simple-signed.station-template.json b/src/assets/station-templates/virtual-simple-signed.station-template.json new file mode 100644 index 00000000..f1bd28a4 --- /dev/null +++ b/src/assets/station-templates/virtual-simple-signed.station-template.json @@ -0,0 +1,149 @@ +{ + "idTagsFile": "idtags.json", + "baseName": "CS-BASIC-SIGNED", + "chargePointModel": "Simulator simple", + "chargePointVendor": "Ovomaltin", + "power": 50000, + "powerUnit": "W", + "powerSharedByConnectors": true, + "currentOutType": "DC", + "numberOfConnectors": 3, + "randomConnectors": false, + "Configuration": { + "configurationKey": [ + { + "key": "MeterValuesSampledData", + "readonly": false, + "value": "SoC,Energy.Active.Import.Register,Voltage" + }, + { + "key": "MeterValueSampleInterval", + "readonly": false, + "value": "30" + }, + { + "key": "SupportedFeatureProfiles", + "readonly": true, + "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation" + }, + { + "key": "LocalAuthListEnabled", + "readonly": false, + "value": "false" + }, + { + "key": "AuthorizeRemoteTxRequests", + "readonly": false, + "value": "false" + }, + { + "key": "WebSocketPingInterval", + "readonly": false, + "value": "60" + }, + { + "key": "ReserveConnectorZeroSupported", + "readonly": false, + "value": "false" + }, + { + "key": "SampledDataSignReadings", + "readonly": false, + "value": "true" + }, + { + "key": "SampledDataSignStartedReadings", + "readonly": false, + "value": "true" + }, + { + "key": "SampledDataSignUpdatedReadings", + "readonly": false, + "value": "false" + }, + { + "key": "AlignedDataSignReadings", + "readonly": false, + "value": "false" + }, + { + "key": "AlignedDataSignUpdatedReadings", + "readonly": false, + "value": "false" + }, + { + "key": "PublicKeyWithSignedMeterValue", + "readonly": false, + "value": "EveryMeterValue" + }, + { + "key": "MeterPublicKey1", + "readonly": true, + "value": "3056301006072a8648ce3d020106052b8104000a03420004460a02ba2766d9c44f023ecc0e4e58644a87add1aadd6317e5fe4dccdb29b163a01d8a6297c84bc530f86431e92f8d46ab37830247c05cbd92fac252929e7f61" + }, + { + "key": "StartTxnSampledData", + "readonly": false, + "value": "Energy.Active.Import.Register" + } + ] + }, + "AutomaticTransactionGenerator": { + "enable": false, + "minDuration": 60, + "maxDuration": 80, + "minDelayBetweenTwoTransactions": 15, + "maxDelayBetweenTwoTransactions": 30, + "probabilityOfStart": 1, + "stopAfterHours": 0.3, + "requireAuthorize": true + }, + "Connectors": { + "0": {}, + "1": { + "bootStatus": "Available", + "MeterValues": [ + { + "unit": "Percent", + "context": "Sample.Periodic", + "measurand": "SoC", + "location": "EV" + }, + { + "unit": "Wh", + "context": "Sample.Periodic" + } + ] + }, + "2": { + "bootStatus": "Preparing", + "MeterValues": [ + { + "unit": "Percent", + "context": "Sample.Periodic", + "measurand": "SoC", + "location": "EV" + }, + { + "unit": "Wh", + "context": "Sample.Periodic" + } + ] + }, + "3": { + "bootStatus": "Faulted", + "MeterValues": [ + { + "unit": "Percent", + "context": "Sample.Periodic", + "measurand": "SoC", + "location": "EV" + }, + { + "unit": "Wh", + "context": "Sample.Periodic" + } + ] + } + } +} diff --git a/src/charging-station/ChargingStation.ts b/src/charging-station/ChargingStation.ts index 716b8e42..b36e4ba8 100644 --- a/src/charging-station/ChargingStation.ts +++ b/src/charging-station/ChargingStation.ts @@ -46,6 +46,7 @@ import { OCPPVersion, type OutgoingRequest, PowerUnits, + PublicKeyWithSignedMeterValueEnumType, RegistrationStatusEnumType, RequestCommand, type Reservation, @@ -58,6 +59,7 @@ import { type StopTransactionReason, SupervisionUrlDistribution, SupportedFeatureProfiles, + VendorParametersKey, type Voltage, WebSocketCloseEventStatusCode, type WSError, @@ -2031,6 +2033,46 @@ export class ChargingStation extends EventEmitter { save: false, }) } + if (getConfigurationKey(this, VendorParametersKey.SampledDataSignReadings) == null) { + addConfigurationKey(this, VendorParametersKey.SampledDataSignReadings, 'false', { + readonly: false, + }) + } + if (getConfigurationKey(this, VendorParametersKey.AlignedDataSignReadings) == null) { + addConfigurationKey(this, VendorParametersKey.AlignedDataSignReadings, 'false', { + readonly: false, + }) + } + if (getConfigurationKey(this, VendorParametersKey.SampledDataSignStartedReadings) == null) { + addConfigurationKey(this, VendorParametersKey.SampledDataSignStartedReadings, 'false', { + readonly: false, + }) + } + if (getConfigurationKey(this, VendorParametersKey.SampledDataSignUpdatedReadings) == null) { + addConfigurationKey(this, VendorParametersKey.SampledDataSignUpdatedReadings, 'false', { + readonly: false, + }) + } + if (getConfigurationKey(this, VendorParametersKey.AlignedDataSignUpdatedReadings) == null) { + addConfigurationKey(this, VendorParametersKey.AlignedDataSignUpdatedReadings, 'false', { + readonly: false, + }) + } + if (getConfigurationKey(this, VendorParametersKey.PublicKeyWithSignedMeterValue) == null) { + addConfigurationKey( + this, + VendorParametersKey.PublicKeyWithSignedMeterValue, + PublicKeyWithSignedMeterValueEnumType.Never, + { + readonly: false, + } + ) + } + if (getConfigurationKey(this, VendorParametersKey.StartTxnSampledData) == null) { + addConfigurationKey(this, VendorParametersKey.StartTxnSampledData, '', { + readonly: false, + }) + } if ( isNotEmptyString(this.stationInfo?.amperageLimitationOcppKey) && getConfigurationKey(this, this.stationInfo.amperageLimitationOcppKey) == null diff --git a/src/charging-station/ConfigurationKeyUtils.ts b/src/charging-station/ConfigurationKeyUtils.ts index 7fe1e319..f040cf5f 100644 --- a/src/charging-station/ConfigurationKeyUtils.ts +++ b/src/charging-station/ConfigurationKeyUtils.ts @@ -6,6 +6,7 @@ import { OCPP20ComponentName, OCPPVersion, StandardParametersKey, + VendorParametersKey, } from '../types/index.js' import { logger } from '../utils/index.js' @@ -75,6 +76,37 @@ const OCPP2_PARAMETER_KEY_MAP = new Map ({ + context, + format: OCPP16MeterValueFormat.SIGNED_DATA, + location: OCPP16MeterValueLocation.OUTLET, + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + value: JSON.stringify(signedData), +}) diff --git a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts index eeb4d938..f8bd81dd 100644 --- a/src/charging-station/ocpp/1.6/OCPP16RequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16RequestService.ts @@ -214,7 +214,8 @@ export class OCPP16RequestService extends OCPPRequestService { commandParams as unknown as OCPP16StatusNotificationRequest ) as unknown as Request case OCPP16RequestCommand.STOP_TRANSACTION: - chargingStation.stationInfo?.transactionDataMeterValues === true && + ;(chargingStation.stationInfo?.transactionDataMeterValues === true || + OCPP16ServiceUtils.isSigningEnabled(chargingStation)) && (connectorId = chargingStation.getConnectorIdByTransactionId( commandParams.transactionId as number )) @@ -222,24 +223,49 @@ export class OCPP16RequestService extends OCPPRequestService { commandParams.transactionId as number, true ) - return { - idTag: chargingStation.getTransactionIdTag(commandParams.transactionId as number), - meterStop: energyActiveImportRegister, - timestamp: new Date(), - ...(chargingStation.stationInfo?.transactionDataMeterValues === true && - connectorId != null && { - transactionData: OCPP16ServiceUtils.buildTransactionDataMeterValues( - chargingStation.getConnectorStatus(connectorId) - ?.transactionBeginMeterValue as OCPP16MeterValue, - OCPP16ServiceUtils.buildTransactionEndMeterValue( - chargingStation, - connectorId, - energyActiveImportRegister + { + let transactionData: OCPP16MeterValue[] | undefined + const transactionDataExplicit = + chargingStation.stationInfo?.transactionDataMeterValues === true + const signingForcesTransactionData = OCPP16ServiceUtils.isSigningEnabled(chargingStation) + if ((transactionDataExplicit || signingForcesTransactionData) && connectorId != null) { + if (transactionDataExplicit) { + transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues( + chargingStation.getConnectorStatus(connectorId) + ?.transactionBeginMeterValue as OCPP16MeterValue, + OCPP16ServiceUtils.buildTransactionEndMeterValue( + chargingStation, + connectorId, + energyActiveImportRegister + ) ) - ), - }), - ...commandParams, - } as unknown as Request + } else { + try { + transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues( + chargingStation.getConnectorStatus(connectorId) + ?.transactionBeginMeterValue as OCPP16MeterValue, + OCPP16ServiceUtils.buildTransactionEndMeterValue( + chargingStation, + connectorId, + energyActiveImportRegister + ) + ) + } catch (error) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Failed to build signed transaction data meter values for StopTransaction:`, + error + ) + } + } + } + return { + idTag: chargingStation.getTransactionIdTag(commandParams.transactionId as number), + meterStop: energyActiveImportRegister, + timestamp: new Date(), + ...(transactionData != null && { transactionData }), + ...commandParams, + } as unknown as Request + } default: { // OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError(). const errorMsg = `Unsupported OCPP command ${commandName as string} for payload building` diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index f5f21524..e5c2d33f 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -10,6 +10,7 @@ import { import { type ChargingStation, + getConfigurationKey, hasFeatureProfile, hasReservationExpired, } from '../../../charging-station/index.js' @@ -34,10 +35,12 @@ import { OCPP16MeterValueMeasurand, OCPP16MeterValueUnit, OCPP16RequestCommand, + type OCPP16SampledValue, OCPP16StandardParametersKey, type OCPP16StatusNotificationRequest, OCPP16StopTransactionReason, type OCPP16SupportedFeatureProfiles, + OCPP16VendorParametersKey, OCPPVersion, RequestCommand, type StartTransactionRequest, @@ -64,8 +67,15 @@ import { getSampledValueTemplate, PayloadValidatorOptions, } from '../OCPPServiceUtils.js' +import { generateSignedMeterData } from '../OCPPSignedMeterDataGenerator.js' +import { + parsePublicKeyWithSignedMeterValue, + shouldIncludePublicKey, + type SignedSampledValueResult, + type SigningConfig, +} from '../OCPPSignedMeterValueUtils.js' import { OCPP16Constants } from './OCPP16Constants.js' -import { buildOCPP16SampledValue } from './OCPP16RequestBuilders.js' +import { buildOCPP16SampledValue, buildSignedOCPP16SampledValue } from './OCPP16RequestBuilders.js' const moduleName = 'OCPP16ServiceUtils' @@ -147,6 +157,31 @@ export class OCPP16ServiceUtils { ) ) } + if ( + OCPP16ServiceUtils.isSigningEnabled(chargingStation) && + getConfigurationKey(chargingStation, OCPP16VendorParametersKey.SampledDataSignStartedReadings) + ?.value === 'true' + ) { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const transactionId = connectorStatus?.transactionId ?? 0 + const publicKeySentInTransaction = connectorStatus?.publicKeySentInTransaction ?? false + const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector( + chargingStation, + connectorId + ) + const signedResult = OCPP16ServiceUtils.buildSignedSampledValue( + signingCfg, + meterStart ?? 0, + OCPP16MeterValueContext.TRANSACTION_BEGIN, + transactionId, + publicKeySentInTransaction, + meterValue.timestamp + ) + meterValue.sampledValue.push(signedResult.sampledValue) + if (signedResult.publicKeyIncluded && connectorStatus != null) { + connectorStatus.publicKeySentInTransaction = true + } + } return meterValue } @@ -192,6 +227,27 @@ export class OCPP16ServiceUtils { OCPP16MeterValueContext.TRANSACTION_END ) ) + if (OCPP16ServiceUtils.isSigningEnabled(chargingStation)) { + const connectorStatus = chargingStation.getConnectorStatus(connectorId) + const transactionId = connectorStatus?.transactionId ?? 0 + const publicKeySentInTransaction = connectorStatus?.publicKeySentInTransaction ?? false + const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector( + chargingStation, + connectorId + ) + const signedResult = OCPP16ServiceUtils.buildSignedSampledValue( + signingCfg, + meterStop ?? 0, + OCPP16MeterValueContext.TRANSACTION_END, + transactionId, + publicKeySentInTransaction, + meterValue.timestamp + ) + meterValue.sampledValue.push(signedResult.sampledValue) + if (signedResult.publicKeyIncluded && connectorStatus != null) { + connectorStatus.publicKeySentInTransaction = true + } + } return meterValue } @@ -604,6 +660,17 @@ export class OCPP16ServiceUtils { return key.visible } + /** + * @param chargingStation - Target charging station + * @returns Whether signed meter value generation is enabled (SampledDataSignReadings=true) + */ + public static isSigningEnabled (chargingStation: ChargingStation): boolean { + return ( + getConfigurationKey(chargingStation, OCPP16VendorParametersKey.SampledDataSignReadings) + ?.value === 'true' + ) + } + /** * Stops a transaction remotely on the given connector. * @param chargingStation - Target charging station @@ -734,6 +801,34 @@ export class OCPP16ServiceUtils { connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(() => { const transactionId = convertToInt(connectorStatus.transactionId) const meterValue = buildMeterValue(chargingStation, transactionId, interval) + if ( + OCPP16ServiceUtils.isSigningEnabled(chargingStation) && + getConfigurationKey( + chargingStation, + OCPP16VendorParametersKey.SampledDataSignUpdatedReadings + )?.value === 'true' + ) { + const energyWh = chargingStation.getEnergyActiveImportRegisterByTransactionId( + connectorStatus.transactionId + ) + const publicKeySentInTransaction = connectorStatus.publicKeySentInTransaction ?? false + const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector( + chargingStation, + connectorId + ) + const signedResult = OCPP16ServiceUtils.buildSignedSampledValue( + signingCfg, + energyWh, + OCPP16MeterValueContext.SAMPLE_PERIODIC, + transactionId, + publicKeySentInTransaction, + (meterValue as OCPP16MeterValue).timestamp + ) + ;(meterValue as OCPP16MeterValue).sampledValue.push(signedResult.sampledValue) + if (signedResult.publicKeyIncluded) { + connectorStatus.publicKeySentInTransaction = true + } + } chargingStation.ocppRequestService .requestHandler( chargingStation, @@ -831,6 +926,35 @@ export class OCPP16ServiceUtils { } } + private static buildSignedSampledValue ( + signingConfig: SigningConfig, + meterValue: number, + context: OCPP16MeterValueContext, + transactionId: number | string, + publicKeySentInTransaction: boolean, + timestamp: Date + ): SignedSampledValueResult { + const includePublicKey = shouldIncludePublicKey( + signingConfig.publicKeyWithSignedMeterValue, + publicKeySentInTransaction + ) + + const signedData = generateSignedMeterData( + { + context, + meterSerialNumber: signingConfig.meterSerialNumber, + meterValue, + timestamp, + transactionId, + }, + includePublicKey ? signingConfig.publicKeyHex : undefined + ) + return { + publicKeyIncluded: includePublicKey && signingConfig.publicKeyHex != null, + sampledValue: buildSignedOCPP16SampledValue(context, signedData), + } + } + private static readonly composeChargingSchedule = ( chargingSchedule: OCPP16ChargingSchedule, compositeInterval: Interval @@ -906,4 +1030,23 @@ export class OCPP16ServiceUtils { return chargingSchedule } } + + private static readSigningConfigForConnector ( + chargingStation: ChargingStation, + connectorId: number + ): SigningConfig { + return { + meterSerialNumber: chargingStation.stationInfo?.meterSerialNumber ?? 'SIMULATOR', + publicKeyHex: getConfigurationKey( + chargingStation, + `${OCPP16VendorParametersKey.MeterPublicKey}${connectorId.toString()}` + )?.value, + publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue( + getConfigurationKey( + chargingStation, + OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue + )?.value + ), + } + } } diff --git a/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts b/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts index 4033bff5..80e1a4fb 100644 --- a/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts +++ b/src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts @@ -7,12 +7,22 @@ import { type MeterValuePhase, OCPP16StopTransactionReason, type OCPP20BootNotificationRequest, + OCPP20MeasurandEnumType, OCPP20ReasonEnumType, type OCPP20SampledValue, OCPP20TriggerReasonEnumType, type SampledValueTemplate, } from '../../../types/index.js' import { resolveSampledValueFields } from '../OCPPServiceUtils.js' +import { + generateSignedMeterData, + type SignedMeterDataParams, +} from '../OCPPSignedMeterDataGenerator.js' +import { + type SampledValueSigningConfig, + shouldIncludePublicKey, + type SignedSampledValueResult, +} from '../OCPPSignedMeterValueUtils.js' export const buildOCPP20BootNotificationRequest = ( stationInfo: ChargingStationInfo, @@ -43,16 +53,18 @@ export const buildOCPP20BootNotificationRequest = ( * @param value - The measured value. * @param context - The reading context. * @param phase - The phase of the measurement. - * @returns The built OCPP 2.0 sampled value. + * @param signingConfig - Optional signing configuration for generating signedMeterValue. + * @returns The built OCPP 2.0 sampled value with signing metadata. */ export function buildOCPP20SampledValue ( sampledValueTemplate: SampledValueTemplate, value: number, context?: MeterValueContext, - phase?: MeterValuePhase -): OCPP20SampledValue { + phase?: MeterValuePhase, + signingConfig?: SampledValueSigningConfig +): SignedSampledValueResult { const fields = resolveSampledValueFields(sampledValueTemplate, value, context, phase) - return { + const sampledValue: OCPP20SampledValue = { context: fields.context, location: fields.location, measurand: fields.measurand, @@ -60,6 +72,35 @@ export function buildOCPP20SampledValue ( value: fields.value, ...(fields.phase != null && { phase: fields.phase }), } as OCPP20SampledValue + + let publicKeyIncluded = false + + if ( + signingConfig?.enabled === true && + fields.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER + ) { + const includePublicKey = shouldIncludePublicKey( + signingConfig.publicKeyWithSignedMeterValue, + signingConfig.publicKeySentInTransaction + ) + const signedMeterDataParams: SignedMeterDataParams = { + context: fields.context, + meterSerialNumber: signingConfig.meterSerialNumber, + meterValue: fields.value, + meterValueUnit: fields.unit, + timestamp: signingConfig.timestamp ?? new Date(), + transactionId: signingConfig.transactionId, + } + sampledValue.signedMeterValue = { + ...generateSignedMeterData( + signedMeterDataParams, + includePublicKey ? signingConfig.publicKeyHex : undefined + ), + } + publicKeyIncluded = includePublicKey && signingConfig.publicKeyHex != null + } + + return { publicKeyIncluded, sampledValue } } export const mapStopReasonToOCPP20 = ( diff --git a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts index 3598d176..210eb7c3 100644 --- a/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts +++ b/src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts @@ -6,6 +6,7 @@ import { type ConnectorStatus, ConnectorStatusEnum, ErrorType, + type MeterValue, OCPP20AuthorizationStatusEnumType, OCPP20ChargingStateEnumType, OCPP20ComponentName, @@ -871,6 +872,9 @@ export class OCPP20ServiceUtils { chargingStation, transactionId ) + if (isNotEmptyArray(startedMeterValues) && connectorStatus != null) { + connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue + } const response = await OCPP20ServiceUtils.sendTransactionEvent( chargingStation, OCPP20TransactionEventEnumType.Started, @@ -1135,6 +1139,9 @@ export class OCPP20ServiceUtils { const connectorStatus = chargingStation.getConnectorStatus(connectorId) const endedMeterValues = (connectorStatus?.transactionEndedMeterValues ?? []) as OCPP20MeterValue[] + const beginMeterValue = connectorStatus?.transactionBeginMeterValue as + | OCPP20MeterValue + | undefined try { const measurandsKey = buildConfigKey( @@ -1149,14 +1156,22 @@ export class OCPP20ServiceUtils { OCPP20ReadingContextEnumType.TRANSACTION_END ) as OCPP20MeterValue if (isNotEmptyArray(finalMeterValue.sampledValue)) { - return [...endedMeterValues, finalMeterValue] + return [ + ...(beginMeterValue != null ? [beginMeterValue] : []), + ...endedMeterValues, + finalMeterValue, + ] } } catch (error) { logger.warn( `${chargingStation.logPrefix()} ${moduleName}.buildTransactionEndedMeterValues: ${(error as Error).message}` ) } - return isNotEmptyArray(endedMeterValues) ? endedMeterValues : [] + const meterValues: OCPP20MeterValue[] = [ + ...(beginMeterValue != null ? [beginMeterValue] : []), + ...endedMeterValues, + ] + return isNotEmptyArray(meterValues) ? meterValues : [] } private static readVariableAsIntervalMs ( diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index efafaab8..bc39b2a5 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -19,6 +19,7 @@ import { OCPP20UnitEnumType, OCPP20VendorVariableName, PersistenceEnumType, + PublicKeyWithSignedMeterValueEnumType, ReasonCodeEnumType, type VariableName, } from '../../../types/index.js' @@ -146,17 +147,18 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'SendDuringIdle', }, - [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, 'SignReadings')]: { - component: OCPP20ComponentName.AlignedDataCtrlr, - dataType: DataEnumType.boolean, - defaultValue: 'false', - description: - 'If set to true, the Charging Station SHALL include signed meter values in the SampledValueType in the MeterValuesRequest to the CSMS.', - mutability: MutabilityEnumType.ReadWrite, - persistence: PersistenceEnumType.Persistent, - supportedAttributes: [AttributeEnumType.Actual], - variable: 'SignReadings', - }, + [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20OptionalVariableName.SignReadings)]: + { + component: OCPP20ComponentName.AlignedDataCtrlr, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'If set to true, the Charging Station SHALL include signed meter values in the SampledValueType in the MeterValuesRequest to the CSMS.', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20OptionalVariableName.SignReadings, + }, [buildRegistryKey( OCPP20ComponentName.AlignedDataCtrlr, OCPP20RequiredVariableName.AlignedDataInterval @@ -272,6 +274,21 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: OCPP20RequiredVariableName.TxEndedMeasurands, }, + [buildRegistryKey( + OCPP20ComponentName.AlignedDataCtrlr, + OCPP20VendorVariableName.SignUpdatedReadings + )]: { + component: OCPP20ComponentName.AlignedDataCtrlr, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Updated) for those measurands configured in AlignedDataTxUpdatedMeasurands. Only has effect if AlignedDataCtrlr.SignReadings is true.', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20VendorVariableName.SignUpdatedReadings, + vendorSpecific: true, + }, // AuthCacheCtrlr Component [buildRegistryKey(OCPP20ComponentName.AuthCacheCtrlr, 'Available')]: { @@ -371,7 +388,6 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'DisableRemoteAuthorization', }, - [buildRegistryKey(OCPP20ComponentName.AuthCtrlr, 'OfflineTxForUnknownIdEnabled')]: { component: OCPP20ComponentName.AuthCtrlr, dataType: DataEnumType.boolean, @@ -720,6 +736,7 @@ export const VARIABLE_REGISTRY: Record = { variable: OCPP20RequiredVariableName.TimeSource, }, + // DeviceDataCtrlr Component // Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step. [buildRegistryKey( OCPP20ComponentName.DeviceDataCtrlr, @@ -757,7 +774,6 @@ export const VARIABLE_REGISTRY: Record = { unit: OCPP20UnitEnumType.CHARS, variable: OCPP20OptionalVariableName.ReportingValueSize, }, - [buildRegistryKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize)]: { component: OCPP20ComponentName.DeviceDataCtrlr, dataType: DataEnumType.integer, @@ -773,7 +789,6 @@ export const VARIABLE_REGISTRY: Record = { unit: OCPP20UnitEnumType.CHARS, variable: OCPP20OptionalVariableName.ValueSize, }, - [buildRegistryKey( OCPP20ComponentName.DeviceDataCtrlr, OCPP20RequiredVariableName.BytesPerMessage @@ -1056,6 +1071,32 @@ export const VARIABLE_REGISTRY: Record = { vendorSpecific: true, }, + // FiscalMetering Component + [buildRegistryKey(OCPP20ComponentName.FiscalMetering, OCPP20VendorVariableName.PublicKey)]: { + component: OCPP20ComponentName.FiscalMetering, + dataType: DataEnumType.string, + defaultValue: '', + description: + 'Public key for the fiscal meter connected to the EVSE. The raw hex key value; the OCA oca:base16:asn1: encoding and Base64 are applied by the signing implementation.', + mutability: MutabilityEnumType.ReadOnly, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20VendorVariableName.PublicKey, + vendorSpecific: true, + }, + [buildRegistryKey(OCPP20ComponentName.FiscalMetering, OCPP20VendorVariableName.SigningMethod)]: { + component: OCPP20ComponentName.FiscalMetering, + dataType: DataEnumType.string, + defaultValue: 'ECDSA-secp256r1-SHA256', + description: + 'Method used to create the digital signature for signed meter values. See OCA Application Note v1.0 Table 12 for valid values.', + mutability: MutabilityEnumType.ReadOnly, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20VendorVariableName.SigningMethod, + vendorSpecific: true, + }, + // ISO15118Ctrlr Component [buildRegistryKey(OCPP20ComponentName.ISO15118Ctrlr, 'CentralContractValidationAllowed')]: { component: OCPP20ComponentName.ISO15118Ctrlr, @@ -1438,16 +1479,6 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'FieldLength', }, - [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue')]: { - component: OCPP20ComponentName.OCPPCommCtrlr, - dataType: DataEnumType.OptionList, - description: - 'This Configuration Variable can be used to configure whether a public key needs to be sent with a signed meter value.', - mutability: MutabilityEnumType.ReadWrite, - persistence: PersistenceEnumType.Persistent, - supportedAttributes: [AttributeEnumType.Actual], - variable: 'PublicKeyWithSignedMeterValue', - }, [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'QueueAllMessages')]: { component: OCPP20ComponentName.OCPPCommCtrlr, dataType: DataEnumType.boolean, @@ -1477,6 +1508,21 @@ export const VARIABLE_REGISTRY: Record = { unit: OCPP20UnitEnumType.SECONDS, variable: OCPP20OptionalVariableName.HeartbeatInterval, }, + [buildRegistryKey( + OCPP20ComponentName.OCPPCommCtrlr, + OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue + )]: { + component: OCPP20ComponentName.OCPPCommCtrlr, + dataType: DataEnumType.OptionList, + defaultValue: PublicKeyWithSignedMeterValueEnumType.Never, + description: + 'This Configuration Variable can be used to configure whether a public key needs to be sent with a signed meter value.', + enumeration: Object.values(PublicKeyWithSignedMeterValueEnumType), + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue, + }, [buildRegistryKey( OCPP20ComponentName.OCPPCommCtrlr, OCPP20OptionalVariableName.RetryBackOffRandomRange @@ -1743,17 +1789,6 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: 'RegisterValuesWithoutPhases', }, - [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings')]: { - component: OCPP20ComponentName.SampledDataCtrlr, - dataType: DataEnumType.boolean, - defaultValue: 'false', - description: - 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest to the CSMS', - mutability: MutabilityEnumType.ReadWrite, - persistence: PersistenceEnumType.Persistent, - supportedAttributes: [AttributeEnumType.Actual], - variable: 'SignReadings', - }, [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20MeasurandEnumType.CURRENT_IMPORT)]: { component: OCPP20ComponentName.SampledDataCtrlr, @@ -1809,6 +1844,18 @@ export const VARIABLE_REGISTRY: Record = { variable: OCPP20MeasurandEnumType.VOLTAGE, vendorSpecific: true, }, + [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20OptionalVariableName.SignReadings)]: + { + component: OCPP20ComponentName.SampledDataCtrlr, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest to the CSMS', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20OptionalVariableName.SignReadings, + }, [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20RequiredVariableName.Enabled)]: { component: OCPP20ComponentName.SampledDataCtrlr, dataType: DataEnumType.boolean, @@ -1947,6 +1994,36 @@ export const VARIABLE_REGISTRY: Record = { supportedAttributes: [AttributeEnumType.Actual], variable: OCPP20RequiredVariableName.TxUpdatedMeasurands, }, + [buildRegistryKey( + OCPP20ComponentName.SampledDataCtrlr, + OCPP20VendorVariableName.SignStartedReadings + )]: { + component: OCPP20ComponentName.SampledDataCtrlr, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Started or Updated) to the CSMS for those measurands configured in SampledDataTxStartedMeasurands.', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20VendorVariableName.SignStartedReadings, + vendorSpecific: true, + }, + [buildRegistryKey( + OCPP20ComponentName.SampledDataCtrlr, + OCPP20VendorVariableName.SignUpdatedReadings + )]: { + component: OCPP20ComponentName.SampledDataCtrlr, + dataType: DataEnumType.boolean, + defaultValue: 'false', + description: + 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Updated) to the CSMS for those measurands configured in SampledDataTxUpdatedMeasurands. This setting only has an effect if SampledDataCtrlr.SignReadings is set to true.', + mutability: MutabilityEnumType.ReadWrite, + persistence: PersistenceEnumType.Persistent, + supportedAttributes: [AttributeEnumType.Actual], + variable: OCPP20VendorVariableName.SignUpdatedReadings, + vendorSpecific: true, + }, // SecurityCtrlr Component [buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'AdditionalRootCertificateCheck')]: { diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index b022c19a..da9c0c91 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url' import type { BootReasonEnumType } from '../../types/index.js' +import { buildConfigKey } from '../../charging-station/ConfigurationKeyUtils.js' import { type ChargingStation, getConfigurationKey } from '../../charging-station/index.js' import { BaseError, OCPPError } from '../../exception/index.js' import { @@ -29,11 +30,14 @@ import { MeterValueMeasurand, MeterValuePhase, MeterValueUnit, + OCPP20ComponentName, + OCPP20ReadingContextEnumType, OCPPVersion, RequestCommand, type SampledValue, type SampledValueTemplate, StandardParametersKey, + VendorParametersKey, } from '../../types/index.js' import { ACElectricUtils, @@ -62,6 +66,10 @@ import { buildOCPP20SampledValue, } from './2.0/OCPP20RequestBuilders.js' import { OCPPConstants } from './OCPPConstants.js' +import { + parsePublicKeyWithSignedMeterValue, + type SampledValueSigningConfig, +} from './OCPPSignedMeterValueUtils.js' const moduleName = 'OCPPServiceUtils' @@ -893,6 +901,8 @@ export const buildMeterValue = ( context?: MeterValueContext, phase?: MeterValuePhase ) => SampledValue + let signingConfig: SampledValueSigningConfig | undefined + const signingState = { publicKeyIncluded: false } switch (chargingStation.stationInfo?.ocppVersion) { case OCPPVersion.VERSION_16: if (connectorId == null) { @@ -914,7 +924,85 @@ export const buildMeterValue = ( RequestCommand.METER_VALUES ) } - buildVersionedSampledValue = buildOCPP20SampledValue + { + const signReadings = + getConfigurationKey( + chargingStation, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.SignReadings) + )?.value === 'true' + + if (signReadings) { + let signingEnabledForContext = true + if (context === OCPP20ReadingContextEnumType.TRANSACTION_BEGIN) { + signingEnabledForContext = + getConfigurationKey( + chargingStation, + buildConfigKey( + OCPP20ComponentName.SampledDataCtrlr, + VendorParametersKey.SignStartedReadings + ) + )?.value === 'true' + } else if ( + context == null || + context === OCPP20ReadingContextEnumType.SAMPLE_PERIODIC || + context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK + ) { + signingEnabledForContext = + getConfigurationKey( + chargingStation, + buildConfigKey( + OCPP20ComponentName.SampledDataCtrlr, + VendorParametersKey.SignUpdatedReadings + ) + )?.value === 'true' + } + + if (signingEnabledForContext) { + const publicKeyWithSignedMeterValueStr = getConfigurationKey( + chargingStation, + buildConfigKey( + OCPP20ComponentName.OCPPCommCtrlr, + StandardParametersKey.PublicKeyWithSignedMeterValue + ) + )?.value + const publicKeyHex = getConfigurationKey( + chargingStation, + buildConfigKey(OCPP20ComponentName.FiscalMetering, VendorParametersKey.PublicKey) + )?.value + signingConfig = { + enabled: true, + meterSerialNumber: chargingStation.stationInfo.meterSerialNumber ?? 'UNKNOWN', + publicKeyHex, + publicKeySentInTransaction: + chargingStation.getConnectorStatus(connectorId)?.publicKeySentInTransaction ?? + false, + publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue( + publicKeyWithSignedMeterValueStr + ), + transactionId, + } + } + } + + buildVersionedSampledValue = ( + sampledValueTemplate: SampledValueTemplate, + value: number, + ctx?: MeterValueContext, + phase?: MeterValuePhase + ) => { + const result = buildOCPP20SampledValue( + sampledValueTemplate, + value, + ctx, + phase, + signingConfig + ) + if (result.publicKeyIncluded) { + signingState.publicKeyIncluded = true + } + return result.sampledValue + } + } break default: throw new OCPPError( @@ -926,6 +1014,9 @@ export const buildMeterValue = ( } const connectorStatus = chargingStation.getConnectorStatus(connectorId) const meterValue: { sampledValue: SampledValue[]; timestamp: Date } = buildEmptyMeterValue() + if (signingConfig != null) { + signingConfig.timestamp = meterValue.timestamp + } // SoC measurand const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId, evseId, measurandsKey) if (socMeasurand != null) { @@ -1170,6 +1261,9 @@ export const buildMeterValue = ( { interval } ) } + if (signingState.publicKeyIncluded && connectorStatus != null) { + connectorStatus.publicKeySentInTransaction = true + } return meterValue as MeterValue } diff --git a/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts new file mode 100644 index 00000000..c9f2dc84 --- /dev/null +++ b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts @@ -0,0 +1,79 @@ +import { createHash } from 'node:crypto' + +import { type JsonObject, MeterValueContext, MeterValueUnit } from '../../types/index.js' +import { roundTo } from '../../utils/index.js' + +export interface SignedMeterData extends JsonObject { + encodingMethod: string + publicKey: string + signedMeterData: string + signingMethod: string +} + +export interface SignedMeterDataParams { + context: MeterValueContext + meterSerialNumber: string + meterValue: number + meterValueUnit?: MeterValueUnit + timestamp: Date + transactionId: number | string +} + +const SIGNING_METHOD = 'ECDSA-secp256r1-SHA256' +const ENCODING_METHOD = 'OCMF' + +const contextToTxCode = (context: MeterValueContext): string => { + switch (context) { + case MeterValueContext.TRANSACTION_BEGIN: + return 'B' + case MeterValueContext.TRANSACTION_END: + return 'E' + default: + return 'P' + } +} + +export const buildPublicKeyValue = (hexKey: string): string => { + return Buffer.from(`oca:base16:asn1:${hexKey}`).toString('base64') +} + +export const generateSignedMeterData = ( + params: SignedMeterDataParams, + publicKeyHex?: string +): SignedMeterData => { + const txCode = contextToTxCode(params.context) + const meterValueKwh = + params.meterValueUnit === MeterValueUnit.KILO_WATT_HOUR + ? roundTo(params.meterValue, 3) + : roundTo(params.meterValue / 1000, 3) + + const ocmfPayload = { + FV: '1.0', + GI: 'SIMULATOR', + GS: params.meterSerialNumber, + GV: '1.0', + PG: `T${String(params.transactionId)}`, + RD: [ + { + RI: '1-0:1.8.0', + RT: 'AC', + RU: 'kWh', + RV: meterValueKwh, + ST: 'G', + TM: params.timestamp.toISOString(), + TX: txCode, + }, + ], + } + + const simulatedSignature = createHash('sha256').update(JSON.stringify(ocmfPayload)).digest('hex') + + const ocmfString = `OCMF|${JSON.stringify(ocmfPayload)}|{"SA":"${SIGNING_METHOD}","SD":"${simulatedSignature}"}` + + return { + encodingMethod: ENCODING_METHOD, + publicKey: publicKeyHex != null ? buildPublicKeyValue(publicKeyHex) : '', + signedMeterData: Buffer.from(ocmfString).toString('base64'), + signingMethod: '', + } +} diff --git a/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts new file mode 100644 index 00000000..48d4867c --- /dev/null +++ b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts @@ -0,0 +1,49 @@ +import { BaseError } from '../../exception/index.js' +import { PublicKeyWithSignedMeterValueEnumType, type SampledValue } from '../../types/index.js' + +export interface SampledValueSigningConfig extends SigningConfig { + enabled: boolean + publicKeySentInTransaction: boolean + timestamp?: Date + transactionId: number | string +} + +export interface SignedSampledValueResult { + publicKeyIncluded: boolean + sampledValue: T +} + +export interface SigningConfig { + meterSerialNumber: string + publicKeyHex?: string + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType +} + +const PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES = new Set( + Object.values(PublicKeyWithSignedMeterValueEnumType) +) + +export const parsePublicKeyWithSignedMeterValue = ( + value: string | undefined +): PublicKeyWithSignedMeterValueEnumType => + value != null && PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES.has(value) + ? (value as PublicKeyWithSignedMeterValueEnumType) + : PublicKeyWithSignedMeterValueEnumType.Never + +export const shouldIncludePublicKey = ( + config: PublicKeyWithSignedMeterValueEnumType, + publicKeySentInTransaction: boolean +): boolean => { + switch (config) { + case PublicKeyWithSignedMeterValueEnumType.EveryMeterValue: + return true + case PublicKeyWithSignedMeterValueEnumType.Never: + return false + case PublicKeyWithSignedMeterValueEnumType.OncePerTransaction: + return !publicKeySentInTransaction + default: + throw new BaseError( + `Unsupported PublicKeyWithSignedMeterValueEnumType value: ${String(config)}` + ) + } +} diff --git a/src/charging-station/ocpp/index.ts b/src/charging-station/ocpp/index.ts index 7b1458d8..9a5865c4 100644 --- a/src/charging-station/ocpp/index.ts +++ b/src/charging-station/ocpp/index.ts @@ -13,3 +13,16 @@ export { stopTransactionOnConnector, } from './OCPPServiceOperations.js' export { buildBootNotificationRequest, buildMeterValue } from './OCPPServiceUtils.js' +export { + buildPublicKeyValue, + generateSignedMeterData, + type SignedMeterData, + type SignedMeterDataParams, +} from './OCPPSignedMeterDataGenerator.js' +export { + parsePublicKeyWithSignedMeterValue, + type SampledValueSigningConfig, + shouldIncludePublicKey, + type SignedSampledValueResult, + type SigningConfig, +} from './OCPPSignedMeterValueUtils.js' diff --git a/src/types/ConnectorStatus.ts b/src/types/ConnectorStatus.ts index ec846b36..fab168e8 100644 --- a/src/types/ConnectorStatus.ts +++ b/src/types/ConnectorStatus.ts @@ -24,6 +24,7 @@ export interface ConnectorStatus { localAuthorizeIdTag?: string locked?: boolean MeterValues: SampledValueTemplate[] + publicKeySentInTransaction?: boolean remoteStartId?: number reservation?: Reservation status?: ConnectorStatusEnum diff --git a/src/types/index.ts b/src/types/index.ts index 1d41b5a4..bd253b7c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -70,11 +70,13 @@ export { export { OCPP16StandardParametersKey, OCPP16SupportedFeatureProfiles, + OCPP16VendorParametersKey, } from './ocpp/1.6/Configuration.js' export { OCPP16DiagnosticsStatus } from './ocpp/1.6/DiagnosticsStatus.js' export { type OCPP16MeterValue, OCPP16MeterValueContext, + OCPP16MeterValueFormat, OCPP16MeterValueLocation, OCPP16MeterValueMeasurand, OCPP16MeterValuePhase, @@ -82,6 +84,7 @@ export { type OCPP16MeterValuesResponse, OCPP16MeterValueUnit, type OCPP16SampledValue, + type OCPP16SignedMeterValue, } from './ocpp/1.6/MeterValues.js' export { type ChangeConfigurationRequest, @@ -337,6 +340,7 @@ export { type ConfigurationKeyType, ConnectorPhaseRotation, type OCPPConfigurationKey, + PublicKeyWithSignedMeterValueEnumType, StandardParametersKey, SupportedFeatureProfiles, VendorParametersKey, diff --git a/src/types/ocpp/1.6/Configuration.ts b/src/types/ocpp/1.6/Configuration.ts index 12074b23..29e50af0 100644 --- a/src/types/ocpp/1.6/Configuration.ts +++ b/src/types/ocpp/1.6/Configuration.ts @@ -55,5 +55,13 @@ export enum OCPP16SupportedFeatureProfiles { } export enum OCPP16VendorParametersKey { + AlignedDataSignReadings = 'AlignedDataSignReadings', + AlignedDataSignUpdatedReadings = 'AlignedDataSignUpdatedReadings', ConnectionUrl = 'ConnectionUrl', + MeterPublicKey = 'MeterPublicKey', + PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue', + SampledDataSignReadings = 'SampledDataSignReadings', + SampledDataSignStartedReadings = 'SampledDataSignStartedReadings', + SampledDataSignUpdatedReadings = 'SampledDataSignUpdatedReadings', + StartTxnSampledData = 'StartTxnSampledData', } diff --git a/src/types/ocpp/1.6/MeterValues.ts b/src/types/ocpp/1.6/MeterValues.ts index 9630f062..de4461f3 100644 --- a/src/types/ocpp/1.6/MeterValues.ts +++ b/src/types/ocpp/1.6/MeterValues.ts @@ -12,6 +12,11 @@ export enum OCPP16MeterValueContext { TRIGGER = 'Trigger', } +export enum OCPP16MeterValueFormat { + RAW = 'Raw', + SIGNED_DATA = 'SignedData', +} + export enum OCPP16MeterValueLocation { BODY = 'Body', CABLE = 'Cable', @@ -77,11 +82,6 @@ export enum OCPP16MeterValueUnit { WATT_HOUR = 'Wh', } -enum OCPP16MeterValueFormat { - RAW = 'Raw', - SIGNED_DATA = 'SignedData', -} - export interface OCPP16MeterValue extends JsonObject { sampledValue: OCPP16SampledValue[] timestamp: Date @@ -104,3 +104,10 @@ export interface OCPP16SampledValue extends JsonObject { unit?: OCPP16MeterValueUnit value: string } + +export interface OCPP16SignedMeterValue extends JsonObject { + encodingMethod: string + publicKey: string + signedMeterData: string + signingMethod: string +} diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index d2a01c89..0f12c9e9 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -42,10 +42,12 @@ export enum OCPP20OptionalVariableName { MaxCertificateChainSize = 'MaxCertificateChainSize', MaxEnergyOnInvalidId = 'MaxEnergyOnInvalidId', NonEvseSpecific = 'NonEvseSpecific', + PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue', ReportingValueSize = 'ReportingValueSize', RetryBackOffRandomRange = 'RetryBackOffRandomRange', RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes', RetryBackOffWaitMinimum = 'RetryBackOffWaitMinimum', + SignReadings = 'SignReadings', ValueSize = 'ValueSize', WebSocketPingInterval = 'WebSocketPingInterval', } @@ -88,6 +90,10 @@ export enum OCPP20RequiredVariableName { export enum OCPP20VendorVariableName { CertificatePrivateKey = 'CertificatePrivateKey', ConnectionUrl = 'ConnectionUrl', + PublicKey = 'PublicKey', + SigningMethod = 'SigningMethod', + SignStartedReadings = 'SignStartedReadings', + SignUpdatedReadings = 'SignUpdatedReadings', SimulateSignatureVerificationFailure = 'SimulateSignatureVerificationFailure', } diff --git a/src/types/ocpp/Configuration.ts b/src/types/ocpp/Configuration.ts index f0760742..90a3bab3 100644 --- a/src/types/ocpp/Configuration.ts +++ b/src/types/ocpp/Configuration.ts @@ -22,6 +22,12 @@ export enum ConnectorPhaseRotation { Unknown = 'Unknown', } +export enum PublicKeyWithSignedMeterValueEnumType { + EveryMeterValue = 'EveryMeterValue', + Never = 'Never', + OncePerTransaction = 'OncePerTransaction', +} + export type ConfigurationKeyType = StandardParametersKey | string | VendorParametersKey export interface OCPPConfigurationKey extends JsonObject { diff --git a/tests/charging-station/ocpp/1.6/OCPP16SignedMeterValues.test.ts b/tests/charging-station/ocpp/1.6/OCPP16SignedMeterValues.test.ts new file mode 100644 index 00000000..958758b4 --- /dev/null +++ b/tests/charging-station/ocpp/1.6/OCPP16SignedMeterValues.test.ts @@ -0,0 +1,494 @@ +/** + * @file Tests for OCPP 1.6 signed meter value support + * @module OCPP 1.6 — Signed MeterValues (OCA Application Note v1.0) + * @description Verifies signed meter value integration in transaction begin/end functions, + * buildSignedOCPP16SampledValue, and periodic meter values. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { buildSignedOCPP16SampledValue } from '../../../../src/charging-station/ocpp/1.6/OCPP16RequestBuilders.js' +import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js' +import { + type OCPP16MeterValue, + OCPP16MeterValueContext, + OCPP16MeterValueFormat, + OCPP16MeterValueLocation, + OCPP16MeterValueMeasurand, + OCPP16MeterValueUnit, + type OCPP16SampledValue, + type OCPP16SignedMeterValue, + OCPP16VendorParametersKey, + OCPPVersion, +} from '../../../../src/types/index.js' +import { standardCleanup, withMockTimers } from '../../../helpers/TestLifecycleHelpers.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' +import { createMeterValuesTemplate, upsertConfigurationKey } from './OCPP16TestUtils.js' + +await describe('OCPP 1.6 — Signed MeterValues', async () => { + afterEach(() => { + standardCleanup() + }) + + // ─── buildSignedOCPP16SampledValue ────────────────────────────────────── + + await describe('buildSignedOCPP16SampledValue', async () => { + await it('should return SampledValue with format=SignedData', () => { + const signedData: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: '', + signedMeterData: 'dGVzdA==', + signingMethod: '', + } + + const result = buildSignedOCPP16SampledValue( + OCPP16MeterValueContext.TRANSACTION_BEGIN, + signedData + ) + + assert.strictEqual(result.format, OCPP16MeterValueFormat.SIGNED_DATA) + }) + + await it('should set measurand to Energy.Active.Import.Register', () => { + const signedData: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: '', + signedMeterData: 'dGVzdA==', + signingMethod: '', + } + + const result = buildSignedOCPP16SampledValue( + OCPP16MeterValueContext.TRANSACTION_END, + signedData + ) + + assert.strictEqual(result.measurand, OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) + }) + + await it('should set location to Outlet', () => { + const signedData: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: '', + signedMeterData: 'dGVzdA==', + signingMethod: '', + } + + const result = buildSignedOCPP16SampledValue( + OCPP16MeterValueContext.SAMPLE_PERIODIC, + signedData + ) + + assert.strictEqual(result.location, OCPP16MeterValueLocation.OUTLET) + }) + + await it('should set value to JSON-serialized SignedMeterValue', () => { + const signedData: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: 'abc123', + signedMeterData: 'dGVzdA==', + signingMethod: '', + } + + const result = buildSignedOCPP16SampledValue( + OCPP16MeterValueContext.TRANSACTION_BEGIN, + signedData + ) + + const parsed = JSON.parse(result.value) as OCPP16SignedMeterValue + assert.strictEqual(parsed.encodingMethod, 'OCMF') + assert.strictEqual(parsed.signingMethod, '') + assert.strictEqual(parsed.signedMeterData, 'dGVzdA==') + assert.strictEqual(parsed.publicKey, 'abc123') + }) + + await it('should use the provided context', () => { + const signedData: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: '', + signedMeterData: 'dGVzdA==', + signingMethod: '', + } + + const result = buildSignedOCPP16SampledValue( + OCPP16MeterValueContext.TRANSACTION_END, + signedData + ) + + assert.strictEqual(result.context, OCPP16MeterValueContext.TRANSACTION_END) + }) + }) + + // ─── buildTransactionBeginMeterValue with signing ─────────────────────── + + await describe('buildTransactionBeginMeterValue — signing', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + meterSerialNumber: 'SIM-001', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = createMeterValuesTemplate([ + { + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + value: '0', + }, + ]) + } + }) + + await it('should not include signed SampledValue when signing is disabled', () => { + const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000) + + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 0) + }) + + await it('should include signed SampledValue when SampledDataSignReadings and SampledDataSignStartedReadings are true', () => { + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = 42 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.SampledDataSignStartedReadings, + 'true' + ) + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue, + 'EveryMeterValue' + ) + + const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000) + + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 1) + assert.strictEqual( + signedSamples[0].measurand, + OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_BEGIN) + }) + + await it('should not include signed SampledValue when SampledDataSignReadings=true but SampledDataSignStartedReadings=false', () => { + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + + const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000) + + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 0) + }) + + await it('should set publicKeySentInTransaction=true after signing begin value', () => { + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = 42 + connectorStatus.publicKeySentInTransaction = false + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.SampledDataSignStartedReadings, + 'true' + ) + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue, + 'OncePerTransaction' + ) + upsertConfigurationKey(station, `${OCPP16VendorParametersKey.MeterPublicKey}1`, 'abcd1234') + + OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 0) + + assert.strictEqual(connectorStatus?.publicKeySentInTransaction, true) + }) + }) + + // ─── buildTransactionEndMeterValue with signing ───────────────────────── + + await describe('buildTransactionEndMeterValue — signing', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + meterSerialNumber: 'SIM-001', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = createMeterValuesTemplate([ + { + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + value: '0', + }, + ]) + } + }) + + await it('should not include signed SampledValue when signing is disabled', () => { + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000) + + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 0) + }) + + await it('should include signed SampledValue when SampledDataSignReadings=true', () => { + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = 42 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 50000) + + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 1) + assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_END) + }) + + await it('should produce signed SampledValue with valid JSON containing all 4 fields', () => { + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.transactionId = 99 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 25000) + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + + assert.strictEqual(signedSamples.length, 1) + const parsed = JSON.parse(signedSamples[0].value) as OCPP16SignedMeterValue + assert.strictEqual(typeof parsed.encodingMethod, 'string') + assert.strictEqual(typeof parsed.signingMethod, 'string') + assert.strictEqual(typeof parsed.signedMeterData, 'string') + assert.strictEqual(typeof parsed.publicKey, 'string') + assert.strictEqual(parsed.encodingMethod, 'OCMF') + assert.strictEqual(parsed.signingMethod, '') + }) + }) + + await describe('signing — spec edge cases', async () => { + await describe('with standard energy config', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + meterSerialNumber: 'SIM-001', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = createMeterValuesTemplate([ + { + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + value: '0', + }, + ]) + connectorStatus.transactionId = 42 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + }) + + await it('should not sign non-energy measurands even when signing is enabled', () => { + const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000) + const signedSamples = meterValue.sampledValue.filter( + sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + + assert.strictEqual(signedSamples.length, 1) + for (const sv of signedSamples) { + assert.strictEqual(sv.measurand, OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER) + } + }) + }) + + await describe('with transactionDataMeterValues disabled', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + meterSerialNumber: 'SIM-001', + ocppVersion: OCPPVersion.VERSION_16, + transactionDataMeterValues: false, + }, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = createMeterValuesTemplate([ + { + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + value: '0', + }, + ]) + connectorStatus.transactionId = 42 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + }) + + await it('should force transactionData when signing enabled even without energy template', () => { + assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), true) + assert.strictEqual(station.stationInfo?.transactionDataMeterValues, false) + }) + }) + }) + + await describe('startUpdatedMeterValues — periodic signing', async () => { + let station: ChargingStation + let capturedMeterValue: OCPP16MeterValue | undefined + + beforeEach(() => { + capturedMeterValue = undefined + const { station: s } = createMockChargingStation({ + ocppRequestService: { + requestHandler: (...args: unknown[]): Promise => { + const payload = args[2] as undefined | { meterValue?: OCPP16MeterValue[] } + if (payload?.meterValue?.[0] != null) { + capturedMeterValue = payload.meterValue[0] + } + return Promise.resolve() + }, + }, + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { + meterSerialNumber: 'SIM-001', + ocppVersion: OCPPVersion.VERSION_16, + }, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = createMeterValuesTemplate([ + { + measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: OCPP16MeterValueUnit.WATT_HOUR, + value: '0', + }, + ]) + connectorStatus.transactionStarted = true + connectorStatus.transactionId = 42 + } + + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + }) + + await it('should include signed SampledValue in periodic meter values when SampledDataSignUpdatedReadings=true', async t => { + await withMockTimers(t, ['setInterval'], () => { + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.SampledDataSignUpdatedReadings, + 'true' + ) + + // Act + OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60) + t.mock.timers.tick(60000) + + // Assert + assert.ok(capturedMeterValue != null) + const signedSamples = capturedMeterValue.sampledValue.filter( + (sv: OCPP16SampledValue) => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.ok(signedSamples.length > 0) + assert.strictEqual( + signedSamples[0].measurand, + OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) + }) + }) + + await it('should not include signed SampledValue when SampledDataSignUpdatedReadings=false', async t => { + await withMockTimers(t, ['setInterval'], () => { + upsertConfigurationKey( + station, + OCPP16VendorParametersKey.SampledDataSignUpdatedReadings, + 'false' + ) + + // Act + OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60) + t.mock.timers.tick(60000) + + // Assert + assert.ok(capturedMeterValue != null) + const signedSamples = capturedMeterValue.sampledValue.filter( + (sv: OCPP16SampledValue) => sv.format === OCPP16MeterValueFormat.SIGNED_DATA + ) + assert.strictEqual(signedSamples.length, 0) + }) + }) + }) + + // ─── isSigningEnabled ─────────────────────────────────────────────────── + + await describe('isSigningEnabled', async () => { + let station: ChargingStation + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + ocppVersion: OCPPVersion.VERSION_16, + stationInfo: { ocppVersion: OCPPVersion.VERSION_16 }, + }) + station = s + }) + + await it('should return false when SampledDataSignReadings is not configured', () => { + assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), false) + }) + + await it('should return true when SampledDataSignReadings=true', () => { + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true') + + assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), true) + }) + + await it('should return false when SampledDataSignReadings=false', () => { + upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'false') + + assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), false) + }) + }) +}) diff --git a/tests/charging-station/ocpp/2.0/OCPP20SignedMeterValues.test.ts b/tests/charging-station/ocpp/2.0/OCPP20SignedMeterValues.test.ts new file mode 100644 index 00000000..aa1a4ca3 --- /dev/null +++ b/tests/charging-station/ocpp/2.0/OCPP20SignedMeterValues.test.ts @@ -0,0 +1,526 @@ +/** + * @file Tests for OCPP 2.0 signed meter value support + * @description Verifies signedMeterValue population in sampled value building, + * context-dependent sub-switch logic, and public key inclusion. + */ + +import assert from 'node:assert/strict' +import { afterEach, beforeEach, describe, it } from 'node:test' + +import type { ChargingStation } from '../../../../src/charging-station/index.js' + +import { addConfigurationKey, buildConfigKey } from '../../../../src/charging-station/index.js' +import { buildOCPP20SampledValue } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestBuilders.js' +import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js' +import { type SampledValueSigningConfig } from '../../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js' +import { + MeterValueMeasurand, + OCPP20ComponentName, + OCPP20ReadingContextEnumType, + type OCPP20SampledValue, + OCPPVersion, + PublicKeyWithSignedMeterValueEnumType, + type SampledValueTemplate, +} from '../../../../src/types/index.js' +import { Constants } from '../../../../src/utils/index.js' +import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { + TEST_CHARGING_STATION_BASE_NAME, + TEST_TRANSACTION_ID_STRING, +} from '../../ChargingStationTestConstants.js' +import { createMockChargingStation } from '../../ChargingStationTestUtils.js' + +const energyTemplate: SampledValueTemplate = { + measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER, + unit: 'Wh', + value: '0', +} as unknown as SampledValueTemplate + +const voltageTemplate: SampledValueTemplate = { + measurand: MeterValueMeasurand.VOLTAGE, + unit: 'V', + value: '230', +} as unknown as SampledValueTemplate + +await describe('OCPP 2.0 Signed Meter Values', async () => { + await describe('buildOCPP20SampledValue with signing config', async () => { + await it('should add signedMeterValue when signing is enabled for energy measurand', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeyHex: 'abcdef1234567890', + publicKeySentInTransaction: false, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, + transactionId: 'tx-1', + } + + const { sampledValue } = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + + assert.ok(sampledValue.signedMeterValue != null) + assert.strictEqual(sampledValue.signedMeterValue.signingMethod, '') + assert.strictEqual(sampledValue.signedMeterValue.encodingMethod, 'OCMF') + }) + + await it('should not add signedMeterValue when signing is disabled', () => { + const { sampledValue } = buildOCPP20SampledValue(energyTemplate, 1500) + + assert.strictEqual(sampledValue.signedMeterValue, undefined) + }) + + await it('should not add signedMeterValue for non-energy measurands', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeySentInTransaction: false, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, + transactionId: 'tx-1', + } + + const { sampledValue } = buildOCPP20SampledValue( + voltageTemplate, + 230, + undefined, + undefined, + signingConfig + ) + + assert.strictEqual(sampledValue.signedMeterValue, undefined) + }) + + await it('should include publicKey when configured as EveryMeterValue', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeyHex: 'abcdef1234567890', + publicKeySentInTransaction: false, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, + transactionId: 'tx-1', + } + + const { sampledValue } = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + + assert.notStrictEqual(sampledValue.signedMeterValue?.publicKey, '') + }) + + await it('should set publicKey to empty string when configured as Never', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeyHex: 'abcdef1234567890', + publicKeySentInTransaction: false, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.Never, + transactionId: 'tx-1', + } + + const { sampledValue } = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + + assert.strictEqual(sampledValue.signedMeterValue?.publicKey, '') + }) + + await it('should set publicKeySentInTransaction after including publicKey with OncePerTransaction', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeyHex: 'abcdef1234567890', + publicKeySentInTransaction: false, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, + transactionId: 'tx-1', + } + + const firstResult = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + assert.strictEqual(firstResult.publicKeyIncluded, true) + + signingConfig.publicKeySentInTransaction = true + const secondResult = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + assert.strictEqual(secondResult.publicKeyIncluded, false) + }) + + await it('should not include publicKey on second call with OncePerTransaction', () => { + const signingConfig: SampledValueSigningConfig = { + enabled: true, + meterSerialNumber: 'SIM-METER-001', + publicKeyHex: 'abcdef1234567890', + publicKeySentInTransaction: true, + publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, + transactionId: 'tx-1', + } + + const { sampledValue } = buildOCPP20SampledValue( + energyTemplate, + 1500, + undefined, + undefined, + signingConfig + ) + + assert.strictEqual(sampledValue.signedMeterValue?.publicKey, '') + }) + }) + + await describe('buildMeterValue with OCPP 2.0 signing integration', async () => { + let station: ChargingStation + + afterEach(() => { + standardCleanup() + }) + + beforeEach(() => { + const { station: s } = createMockChargingStation({ + baseName: TEST_CHARGING_STATION_BASE_NAME, + connectorsCount: 1, + evseConfiguration: { evsesCount: 1 }, + stationInfo: { + meterSerialNumber: 'SIM-METER-001', + ocppVersion: OCPPVersion.VERSION_201, + }, + websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS, + }) + station = s + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [energyTemplate] + connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING + } + }) + + await it('should add signedMeterValue on energy sampled value when SignReadings is true', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.ok(meterValue.sampledValue.length > 0) + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.notStrictEqual(energySampledValue, undefined) + assert.ok(energySampledValue?.signedMeterValue != null) + assert.strictEqual(energySampledValue.signedMeterValue.signingMethod, '') + assert.strictEqual(energySampledValue.signedMeterValue.encodingMethod, 'OCMF') + }) + + await it('should not add signedMeterValue when SignReadings is not configured', () => { + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.ok(meterValue.sampledValue.length > 0) + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.notStrictEqual(energySampledValue, undefined) + assert.strictEqual(energySampledValue?.signedMeterValue, undefined) + }) + + await it('should not add signedMeterValue when SignReadings is false', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'false' + ) + + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.strictEqual(energySampledValue?.signedMeterValue, undefined) + }) + + await it('should not sign non-energy measurands even when signing is enabled', () => { + const connectorStatus = station.getConnectorStatus(1) + if (connectorStatus != null) { + connectorStatus.MeterValues = [voltageTemplate, energyTemplate] + } + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'TxUpdatedMeasurands'), + 'Voltage,Energy.Active.Import.Register' + ) + + const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + const voltageSampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.VOLTAGE + ) as OCPP20SampledValue | undefined + assert.strictEqual( + voltageSampledValue?.signedMeterValue, + undefined, + 'Voltage measurand should not have signedMeterValue' + ) + }) + + await it('should set publicKeySentInTransaction on connector status with OncePerTransaction', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.OncePerTransaction + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'), + 'abcdef1234567890' + ) + + const connectorStatus = station.getConnectorStatus(1) + assert.strictEqual(connectorStatus?.publicKeySentInTransaction ?? false, false) + + buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0) + + assert.strictEqual(connectorStatus?.publicKeySentInTransaction, true) + }) + + await it('should not sign when SignReadings=true but SignStartedReadings=false with TRANSACTION_BEGIN context', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'), + 'false' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID_STRING, + 0, + undefined, + OCPP20ReadingContextEnumType.TRANSACTION_BEGIN + ) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.strictEqual( + energySampledValue?.signedMeterValue, + undefined, + 'Should not sign when SignStartedReadings is false' + ) + }) + + await it('should sign when SignReadings=true and SignStartedReadings=true with TRANSACTION_BEGIN context', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'), + 'abcdef1234567890' + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID_STRING, + 0, + undefined, + OCPP20ReadingContextEnumType.TRANSACTION_BEGIN + ) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.ok( + energySampledValue?.signedMeterValue != null, + 'Should sign when SignStartedReadings is true' + ) + }) + + await it('should not sign when SignReadings=true but SignUpdatedReadings=false with periodic context', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'), + 'false' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID_STRING, + 0, + undefined, + OCPP20ReadingContextEnumType.SAMPLE_PERIODIC + ) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.strictEqual( + energySampledValue?.signedMeterValue, + undefined, + 'Should not sign when SignUpdatedReadings is false' + ) + }) + + await it('should sign when SignReadings=true and SignUpdatedReadings=true with periodic context', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'), + 'abcdef1234567890' + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID_STRING, + 0, + undefined, + OCPP20ReadingContextEnumType.SAMPLE_PERIODIC + ) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.ok( + energySampledValue?.signedMeterValue != null, + 'Should sign when SignUpdatedReadings is true' + ) + }) + + await it('should sign when SignReadings=true with TRANSACTION_END context regardless of sub-switches', () => { + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'), + 'true' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'), + 'false' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'), + 'false' + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'), + PublicKeyWithSignedMeterValueEnumType.Never + ) + addConfigurationKey( + station, + buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'), + 'abcdef1234567890' + ) + + const meterValue = buildMeterValue( + station, + TEST_TRANSACTION_ID_STRING, + 0, + undefined, + OCPP20ReadingContextEnumType.TRANSACTION_END + ) + + const energySampledValue = meterValue.sampledValue.find( + sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER + ) as OCPP20SampledValue | undefined + assert.ok( + energySampledValue?.signedMeterValue != null, + 'Should always sign TRANSACTION_END regardless of sub-switches' + ) + }) + }) +}) diff --git a/tests/charging-station/ocpp/OCPPSignedMeterDataGenerator.test.ts b/tests/charging-station/ocpp/OCPPSignedMeterDataGenerator.test.ts new file mode 100644 index 00000000..bd3537b9 --- /dev/null +++ b/tests/charging-station/ocpp/OCPPSignedMeterDataGenerator.test.ts @@ -0,0 +1,154 @@ +/** + * @file Tests for SignedMeterDataGenerator + * @description Verifies OCMF-like signed meter data generation for simulation purposes. + * + * Covers: + * - generateSignedMeterData — output structure and field values + * - generateSignedMeterData — signedMeterData is valid Base64 containing OCMF payload + * - generateSignedMeterData — publicKey handling with and without publicKeyHex + * - generateSignedMeterData — TX codes for different contexts + * - generateSignedMeterData — different meterValues produce different output + * - buildPublicKeyValue — produces valid Base64 containing oca:base16:asn1: prefix + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { + buildPublicKeyValue, + generateSignedMeterData, + type SignedMeterDataParams, +} from '../../../src/charging-station/ocpp/OCPPSignedMeterDataGenerator.js' +import { MeterValueContext, MeterValueUnit } from '../../../src/types/index.js' + +const DEFAULT_PARAMS: SignedMeterDataParams = { + context: MeterValueContext.SAMPLE_PERIODIC, + meterSerialNumber: 'SIM-METER-001', + meterValue: 12345000, + timestamp: new Date('2025-01-15T10:30:00.000Z'), + transactionId: 42, +} + +const TEST_PUBLIC_KEY_HEX = '3059301306072a8648ce3d020106082a8648ce3d03010703420004abc123' + +await describe('SignedMeterDataGenerator', async () => { + await it('should return an object with all required fields', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + + assert.ok('encodingMethod' in result) + assert.ok('publicKey' in result) + assert.ok('signedMeterData' in result) + assert.ok('signingMethod' in result) + }) + + await it('should produce valid Base64 in signedMeterData', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + const reEncoded = Buffer.from(Buffer.from(result.signedMeterData, 'base64')).toString('base64') + + assert.strictEqual(result.signedMeterData, reEncoded) + }) + + await it('should produce signedMeterData that decodes to an OCMF string', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8') + + assert.ok(decoded.startsWith('OCMF|')) + }) + + await it('should set signingMethod to empty string when included in signedMeterData', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + + assert.strictEqual(result.signingMethod, '') + }) + + await it('should set encodingMethod to OCMF', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + + assert.strictEqual(result.encodingMethod, 'OCMF') + }) + + await it('should return empty publicKey when no publicKeyHex provided', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + + assert.strictEqual(result.publicKey, '') + }) + + await it('should return non-empty publicKey when publicKeyHex provided', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS, TEST_PUBLIC_KEY_HEX) + + assert.ok(result.publicKey.length > 0) + }) + + await it('should produce valid Base64 containing oca:base16:asn1: in buildPublicKeyValue', () => { + const result = buildPublicKeyValue(TEST_PUBLIC_KEY_HEX) + const reEncoded = Buffer.from(Buffer.from(result, 'base64')).toString('base64') + + assert.strictEqual(result, reEncoded) + + const decoded = Buffer.from(result, 'base64').toString('utf8') + assert.ok(decoded.includes('oca:base16:asn1:')) + }) + + await it('should produce TX=B for Transaction.Begin', () => { + const params: SignedMeterDataParams = { + ...DEFAULT_PARAMS, + context: MeterValueContext.TRANSACTION_BEGIN, + } + const result = generateSignedMeterData(params) + const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8') + + assert.ok(decoded.includes('"TX":"B"')) + }) + + await it('should produce TX=E for Transaction.End', () => { + const params: SignedMeterDataParams = { + ...DEFAULT_PARAMS, + context: MeterValueContext.TRANSACTION_END, + } + const result = generateSignedMeterData(params) + const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8') + + assert.ok(decoded.includes('"TX":"E"')) + }) + + await it('should produce TX=P for Sample.Periodic', () => { + const result = generateSignedMeterData(DEFAULT_PARAMS) + const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8') + + assert.ok(decoded.includes('"TX":"P"')) + }) + + await it('should produce TX=P for Sample.Clock', () => { + const params: SignedMeterDataParams = { + ...DEFAULT_PARAMS, + context: MeterValueContext.SAMPLE_CLOCK, + } + const result = generateSignedMeterData(params) + const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8') + + assert.ok(decoded.includes('"TX":"P"')) + }) + + await it('should produce different signedMeterData for different meterValues', () => { + const result1 = generateSignedMeterData(DEFAULT_PARAMS) + const result2 = generateSignedMeterData({ ...DEFAULT_PARAMS, meterValue: 99999000 }) + + assert.notStrictEqual(result1.signedMeterData, result2.signedMeterData) + }) + + await it('should handle kWh input without double-dividing', () => { + const whParams: SignedMeterDataParams = { ...DEFAULT_PARAMS, meterValue: 12345 } + const kwhParams: SignedMeterDataParams = { + ...DEFAULT_PARAMS, + meterValue: 12.345, + meterValueUnit: MeterValueUnit.KILO_WATT_HOUR, + } + const whResult = generateSignedMeterData(whParams) + const kwhResult = generateSignedMeterData(kwhParams) + const whDecoded = Buffer.from(whResult.signedMeterData, 'base64').toString('utf8') + const kwhDecoded = Buffer.from(kwhResult.signedMeterData, 'base64').toString('utf8') + + assert.ok(whDecoded.includes('"RV":12.345')) + assert.ok(kwhDecoded.includes('"RV":12.345')) + }) +}) diff --git a/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts b/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts new file mode 100644 index 00000000..5ebb0a7b --- /dev/null +++ b/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts @@ -0,0 +1,77 @@ +/** + * @file Tests for SignedMeterValueUtils + * @description Unit tests for PublicKeyWithSignedMeterValueEnumType and shouldIncludePublicKey helper + */ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { shouldIncludePublicKey } from '../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js' +import { PublicKeyWithSignedMeterValueEnumType } from '../../../src/types/index.js' + +await describe('SignedMeterValueUtils', async () => { + await describe('PublicKeyWithSignedMeterValueEnumType', async () => { + await it('should have EveryMeterValue value', () => { + assert.strictEqual(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, 'EveryMeterValue') + }) + + await it('should have Never value', () => { + assert.strictEqual(PublicKeyWithSignedMeterValueEnumType.Never, 'Never') + }) + + await it('should have OncePerTransaction value', () => { + assert.strictEqual( + PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, + 'OncePerTransaction' + ) + }) + + await it('should have exactly 3 values', () => { + const values = Object.values(PublicKeyWithSignedMeterValueEnumType) + assert.strictEqual(values.length, 3) + }) + }) + + await describe('shouldIncludePublicKey', async () => { + await it('should return false when config is Never and key not sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.Never, false), + false + ) + }) + + await it('should return false when config is Never and key already sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.Never, true), + false + ) + }) + + await it('should return true when config is EveryMeterValue and key not sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, false), + true + ) + }) + + await it('should return true when config is EveryMeterValue and key already sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, true), + true + ) + }) + + await it('should return true when config is OncePerTransaction and key not sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, false), + true + ) + }) + + await it('should return false when config is OncePerTransaction and key already sent', () => { + assert.strictEqual( + shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, true), + false + ) + }) + }) +}) diff --git a/tests/ocpp-server/server.py b/tests/ocpp-server/server.py index 225526e1..4d54b736 100644 --- a/tests/ocpp-server/server.py +++ b/tests/ocpp-server/server.py @@ -81,6 +81,19 @@ SUBPROTOCOLS: list[websockets.Subprotocol] = [ ] +def _log_signed_meter_values(meter_value: list) -> None: + """Log signed meter value details from a list of meter value dicts.""" + for mv in meter_value: + for sv in mv.get("sampled_value", []): + signed_mv = sv.get("signed_meter_value") + if signed_mv is not None: + logger.info( + "Received signed meter value: encoding=%s, signing=%s", + signed_mv.get("encoding_method"), + signed_mv.get("signing_method"), + ) + + def _random_request_id() -> int: """Generate a random OCPP request ID within the valid range.""" return randint(1, MAX_REQUEST_ID) # noqa: S311 @@ -294,6 +307,7 @@ class ChargePoint(ocpp.v201.ChargePoint): transaction_info, **kwargs, ): + meter_value = kwargs.get("meter_value") match event_type: case TransactionEventEnumType.started: logger.info("Received %s Started", Action.transaction_event) @@ -314,11 +328,15 @@ class ChargePoint(ocpp.v201.ChargePoint): token_id, id_token_info["status"], ) + if meter_value is not None: + _log_signed_meter_values(meter_value) return ocpp.v201.call_result.TransactionEvent( id_token_info=id_token_info ) case TransactionEventEnumType.updated: logger.info("Received %s Updated", Action.transaction_event) + if meter_value is not None: + _log_signed_meter_values(meter_value) return ocpp.v201.call_result.TransactionEvent( total_cost=self._total_cost ) @@ -326,6 +344,8 @@ class ChargePoint(ocpp.v201.ChargePoint): logger.info("Received %s Ended", Action.transaction_event) transaction_id = transaction_info.get("transaction_id", "") self._active_transactions.pop(transaction_id, None) + if meter_value is not None: + _log_signed_meter_values(meter_value) return ocpp.v201.call_result.TransactionEvent() case _: logger.warning("Unknown transaction event type: %s", event_type) @@ -334,6 +354,7 @@ class ChargePoint(ocpp.v201.ChargePoint): @on(Action.meter_values) async def on_meter_values(self, evse_id: int, meter_value, **kwargs): logger.info("Received %s", Action.meter_values) + _log_signed_meter_values(meter_value) return ocpp.v201.call_result.MeterValues() @on(Action.notify_report) diff --git a/tests/ocpp-server/test_server.py b/tests/ocpp-server/test_server.py index 42f75448..38c06103 100644 --- a/tests/ocpp-server/test_server.py +++ b/tests/ocpp-server/test_server.py @@ -724,6 +724,102 @@ class TestTransactionEventHandler: assert response.total_cost is None assert response.id_token_info is None + async def test_started_with_signed_meter_value(self, charge_point): + signed_mv = { + "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl", + "signing_method": "ECDSA-secp256r1-SHA256", + "encoding_method": "OCMF", + "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=", + } + mv = { + "timestamp": TEST_TIMESTAMP, + "sampled_value": [ + { + "value": 0.0, + "measurand": "Energy.Active.Import.Register", + "signed_meter_value": signed_mv, + } + ], + } + response = await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.started, + timestamp=TEST_TIMESTAMP, + trigger_reason="Authorized", + seq_no=0, + transaction_info={"transaction_id": TEST_TRANSACTION_ID}, + id_token={"id_token": TEST_TOKEN, "type": "ISO14443"}, + meter_value=[mv], + ) + assert isinstance(response, ocpp.v201.call_result.TransactionEvent) + assert response.id_token_info["status"] == AuthorizationStatusEnumType.accepted + + async def test_updated_with_signed_meter_value(self, charge_point): + signed_mv = { + "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl", + "signing_method": "ECDSA-secp256r1-SHA256", + "encoding_method": "OCMF", + "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=", + } + mv = { + "timestamp": TEST_TIMESTAMP, + "sampled_value": [ + { + "value": 1500.5, + "measurand": "Energy.Active.Import.Register", + "signed_meter_value": signed_mv, + } + ], + } + response = await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.updated, + timestamp=TEST_TIMESTAMP, + trigger_reason="MeterValuePeriodic", + seq_no=1, + transaction_info={"transaction_id": TEST_TRANSACTION_ID}, + meter_value=[mv], + ) + assert isinstance(response, ocpp.v201.call_result.TransactionEvent) + assert response.total_cost == DEFAULT_TOTAL_COST + + async def test_ended_with_signed_meter_value(self, charge_point): + signed_mv = { + "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl", + "signing_method": "ECDSA-secp256r1-SHA256", + "encoding_method": "OCMF", + "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=", + } + begin_mv = { + "timestamp": TEST_TIMESTAMP, + "sampled_value": [ + { + "value": 0.0, + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.Begin", + "signed_meter_value": signed_mv, + } + ], + } + end_mv = { + "timestamp": TEST_TIMESTAMP, + "sampled_value": [ + { + "value": 15000.5, + "measurand": "Energy.Active.Import.Register", + "context": "Transaction.End", + "signed_meter_value": signed_mv, + } + ], + } + response = await charge_point.on_transaction_event( + event_type=TransactionEventEnumType.ended, + timestamp=TEST_TIMESTAMP, + trigger_reason="EVDeparture", + seq_no=2, + transaction_info={"transaction_id": TEST_TRANSACTION_ID}, + meter_value=[begin_mv, end_mv], + ) + assert isinstance(response, ocpp.v201.call_result.TransactionEvent) + class TestDataTransferHandler: """Tests for the DataTransfer incoming handler.""" @@ -847,6 +943,28 @@ class TestNotificationHandlers: ) assert isinstance(response, ocpp.v201.call_result.MeterValues) + async def test_meter_values_with_signed_meter_value(self, charge_point): + signed_mv = { + "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl", + "signing_method": "ECDSA-secp256r1-SHA256", + "encoding_method": "OCMF", + "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=", + } + mv = { + "timestamp": TEST_TIMESTAMP, + "sampled_value": [ + { + "value": 1500.5, + "measurand": "Energy.Active.Import.Register", + "signed_meter_value": signed_mv, + } + ], + } + response = await charge_point.on_meter_values( + evse_id=TEST_EVSE_ID, meter_value=[mv] + ) + assert isinstance(response, ocpp.v201.call_result.MeterValues) + async def test_notify_report(self, charge_point): response = await charge_point.on_notify_report( request_id=1, diff --git a/tests/types/ocpp/1.6/MeterValues.test.ts b/tests/types/ocpp/1.6/MeterValues.test.ts new file mode 100644 index 00000000..068817f5 --- /dev/null +++ b/tests/types/ocpp/1.6/MeterValues.test.ts @@ -0,0 +1,36 @@ +/** + * @file OCPP 1.6 MeterValues types test suite + * @description Tests for OCPP16MeterValueFormat enum and OCPP16SignedMeterValue interface + */ + +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { OCPP16MeterValueFormat, type OCPP16SignedMeterValue } from '../../../../src/types/index.js' + +await describe('OCPP 1.6 meter value types', async () => { + await describe('OCPP16MeterValueFormat', async () => { + await it('should have RAW enum value equal to "Raw"', () => { + assert.strictEqual(OCPP16MeterValueFormat.RAW, 'Raw') + }) + + await it('should have SIGNED_DATA enum value equal to "SignedData"', () => { + assert.strictEqual(OCPP16MeterValueFormat.SIGNED_DATA, 'SignedData') + }) + }) + + await describe('OCPP16SignedMeterValue', async () => { + await it('should compile as an interface with correct field names', () => { + const signedMeterValue: OCPP16SignedMeterValue = { + encodingMethod: 'OCMF', + publicKey: 'b2NhOmJhc2UxNjphc24xOmZha2VrZXk=', // cspell:disable-line + signedMeterData: 'T0NNRnx7fXxmYWtlc2lnbmF0dXJl', // cspell:disable-line + signingMethod: 'ECDSA-secp256r1-SHA256', + } + assert.strictEqual(signedMeterValue.encodingMethod, 'OCMF') + assert.strictEqual(signedMeterValue.signingMethod, 'ECDSA-secp256r1-SHA256') + assert.ok(signedMeterValue.publicKey.length > 0) + assert.ok(signedMeterValue.signedMeterData.length > 0) + }) + }) +}) diff --git a/tests/types/ocpp/1.6/OCPP16VendorParametersKey.test.ts b/tests/types/ocpp/1.6/OCPP16VendorParametersKey.test.ts new file mode 100644 index 00000000..186a74ae --- /dev/null +++ b/tests/types/ocpp/1.6/OCPP16VendorParametersKey.test.ts @@ -0,0 +1,58 @@ +/** + * @file Tests for OCPP16VendorParametersKey enum + * @description Unit tests for signed meter values vendor configuration keys + */ +import assert from 'node:assert/strict' +import { describe, it } from 'node:test' + +import { OCPP16VendorParametersKey } from '../../../../src/types/index.js' + +await describe('OCPP16VendorParametersKey', async () => { + await it('should have AlignedDataSignReadings key', () => { + assert.strictEqual(OCPP16VendorParametersKey.AlignedDataSignReadings, 'AlignedDataSignReadings') + }) + + await it('should have AlignedDataSignUpdatedReadings key', () => { + assert.strictEqual( + OCPP16VendorParametersKey.AlignedDataSignUpdatedReadings, + 'AlignedDataSignUpdatedReadings' + ) + }) + + await it('should have ConnectionUrl key', () => { + assert.strictEqual(OCPP16VendorParametersKey.ConnectionUrl, 'ConnectionUrl') + }) + + await it('should have MeterPublicKey key', () => { + assert.strictEqual(OCPP16VendorParametersKey.MeterPublicKey, 'MeterPublicKey') + }) + + await it('should have PublicKeyWithSignedMeterValue key', () => { + assert.strictEqual( + OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue, + 'PublicKeyWithSignedMeterValue' + ) + }) + + await it('should have SampledDataSignReadings key', () => { + assert.strictEqual(OCPP16VendorParametersKey.SampledDataSignReadings, 'SampledDataSignReadings') + }) + + await it('should have SampledDataSignStartedReadings key', () => { + assert.strictEqual( + OCPP16VendorParametersKey.SampledDataSignStartedReadings, + 'SampledDataSignStartedReadings' + ) + }) + + await it('should have SampledDataSignUpdatedReadings key', () => { + assert.strictEqual( + OCPP16VendorParametersKey.SampledDataSignUpdatedReadings, + 'SampledDataSignUpdatedReadings' + ) + }) + + await it('should have StartTxnSampledData key', () => { + assert.strictEqual(OCPP16VendorParametersKey.StartTxnSampledData, 'StartTxnSampledData') + }) +}) -- 2.43.0