]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp): add signed meter values support for OCPP 1.6 and 2.0.x (#1775)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Tue, 7 Apr 2026 17:05:43 +0000 (19:05 +0200)
committerGitHub <noreply@github.com>
Tue, 7 Apr 2026 17:05:43 +0000 (19:05 +0200)
* 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<T> 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>
31 files changed:
README.md
eslint.config.js
src/assets/station-templates/keba-ocpp2-signed.station-template.json [new file with mode: 0644]
src/assets/station-templates/virtual-simple-signed.station-template.json [new file with mode: 0644]
src/charging-station/ChargingStation.ts
src/charging-station/ConfigurationKeyUtils.ts
src/charging-station/Helpers.ts
src/charging-station/ocpp/1.6/OCPP16RequestBuilders.ts
src/charging-station/ocpp/1.6/OCPP16RequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20RequestBuilders.ts
src/charging-station/ocpp/2.0/OCPP20ServiceUtils.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts [new file with mode: 0644]
src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts [new file with mode: 0644]
src/charging-station/ocpp/index.ts
src/types/ConnectorStatus.ts
src/types/index.ts
src/types/ocpp/1.6/Configuration.ts
src/types/ocpp/1.6/MeterValues.ts
src/types/ocpp/2.0/Variables.ts
src/types/ocpp/Configuration.ts
tests/charging-station/ocpp/1.6/OCPP16SignedMeterValues.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/2.0/OCPP20SignedMeterValues.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/OCPPSignedMeterDataGenerator.test.ts [new file with mode: 0644]
tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts [new file with mode: 0644]
tests/ocpp-server/server.py
tests/ocpp-server/test_server.py
tests/types/ocpp/1.6/MeterValues.test.ts [new file with mode: 0644]
tests/types/ocpp/1.6/OCPP16VendorParametersKey.test.ts [new file with mode: 0644]

index 9729a47d40e03139cba82cbd1f862567a2afac67..e7cdadc9267e041bedb9bfcb2f5b9a272e64948b 100644 (file)
--- 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: -)
index 43fcfd7a853e6e67d44fa92849016f55db60d424..02c7b77d57de75481da8aff18654f5a39612a491 100644 (file)
@@ -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 (file)
index 0000000..7d078a5
--- /dev/null
@@ -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 (file)
index 0000000..f1bd28a
--- /dev/null
@@ -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"
+        }
+      ]
+    }
+  }
+}
index 716b8e42f96958d7cdefcd756a99101b3088ca9c..b36e4ba828e5e8ed08e5f47fea68fb4a50fcf089 100644 (file)
@@ -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
index 7fe1e3192595ddf89432d2026df8cc7ed8203a8c..f040cf5f46744d51f86e508f4e72b4705586ba24 100644 (file)
@@ -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<ConfigurationKeyType, ConfigurationKeyTy
     StandardParametersKey.WebSocketPingInterval,
     buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, StandardParametersKey.WebSocketPingInterval),
   ],
+  [
+    VendorParametersKey.AlignedDataSignReadings,
+    buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, StandardParametersKey.SignReadings),
+  ],
+  [
+    VendorParametersKey.AlignedDataSignUpdatedReadings,
+    buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, VendorParametersKey.SignUpdatedReadings),
+  ],
+  [
+    VendorParametersKey.PublicKeyWithSignedMeterValue,
+    buildConfigKey(
+      OCPP20ComponentName.OCPPCommCtrlr,
+      StandardParametersKey.PublicKeyWithSignedMeterValue
+    ),
+  ],
+  [
+    VendorParametersKey.SampledDataSignReadings,
+    buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.SignReadings),
+  ],
+  [
+    VendorParametersKey.SampledDataSignStartedReadings,
+    buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, VendorParametersKey.SignStartedReadings),
+  ],
+  [
+    VendorParametersKey.SampledDataSignUpdatedReadings,
+    buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, VendorParametersKey.SignUpdatedReadings),
+  ],
+  [
+    VendorParametersKey.StartTxnSampledData,
+    buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.TxStartedMeasurands),
+  ],
 ])
 
 const resolveKey = (
index a51b9c70dda03990b4cd987829cb17f841e46680..6b33ebfb6b60d81fc33961662509bc8b63fdfb34 100644 (file)
@@ -527,6 +527,7 @@ export const resetConnectorStatus = (connectorStatus: ConnectorStatus | undefine
     delete connectorStatus.transactionEndedMeterValuesSetInterval
   }
   delete connectorStatus.transactionSeqNo
+  delete connectorStatus.publicKeySentInTransaction
   delete connectorStatus.transactionEvseSent
   delete connectorStatus.transactionIdTokenSent
   delete connectorStatus.transactionDeauthorized
index f0f63d31ed165a7b3fd7f6cdaed31963dc630e7c..37b3ef520706a9e3abd426372e3384fc88101764 100644 (file)
@@ -3,7 +3,12 @@ import {
   type MeterValueContext,
   type MeterValuePhase,
   type OCPP16BootNotificationRequest,
+  type OCPP16MeterValueContext,
+  OCPP16MeterValueFormat,
+  OCPP16MeterValueLocation,
+  OCPP16MeterValueMeasurand,
   type OCPP16SampledValue,
+  type OCPP16SignedMeterValue,
   type SampledValueTemplate,
 } from '../../../types/index.js'
 import { resolveSampledValueFields } from '../OCPPServiceUtils.js'
@@ -56,3 +61,22 @@ export function buildOCPP16SampledValue (
     ...(fields.phase != null && { phase: fields.phase }),
   } as OCPP16SampledValue
 }
+
+/**
+ * Builds a signed OCPP 1.6 sampled value from a SignedMeterValue payload.
+ * Per OCA Application Note v1.0 section 3.2.1, the value field contains
+ * the JSON-serialized SignedMeterValueType.
+ * @param context - The reading context for the signed value
+ * @param signedData - The signed meter value data
+ * @returns Signed OCPP 1.6 sampled value with format=SignedData
+ */
+export const buildSignedOCPP16SampledValue = (
+  context: OCPP16MeterValueContext,
+  signedData: OCPP16SignedMeterValue
+): OCPP16SampledValue => ({
+  context,
+  format: OCPP16MeterValueFormat.SIGNED_DATA,
+  location: OCPP16MeterValueLocation.OUTLET,
+  measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+  value: JSON.stringify(signedData),
+})
index eeb4d938376775f4bcc16b8d3707475b33a1426f..f8bd81dda415470be5059c51e8f35f0089e79acd 100644 (file)
@@ -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`
index f5f21524fe8c0d8b559a9ddd0d25e9c71687804b..e5c2d33f346e6ac2c58a31fa9a7d544ad11099f6 100644 (file)
@@ -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<MeterValuesRequest, MeterValuesResponse>(
           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<OCPP16SampledValue> {
+    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
+      ),
+    }
+  }
 }
index 4033bff504539fde6e47fa9db89c151b9d58ad57..80e1a4fb9204fc5dff4a86619db511bdf1762f9f 100644 (file)
@@ -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<OCPP20SampledValue> {
   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 = (
index 3598d17640b5b897d574fc496890d6322f0ede89..210eb7c340fb88d3dc4a80dec5cca88cb9bf2a6a 100644 (file)
@@ -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 (
index efafaab8fb0fb4b82c3d06e2cc0d423daababb77..bc39b2a5a2d7af2b455b541141739f53cb6ff287 100644 (file)
@@ -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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     supportedAttributes: [AttributeEnumType.Actual],
     variable: 'DisableRemoteAuthorization',
   },
-
   [buildRegistryKey(OCPP20ComponentName.AuthCtrlr, 'OfflineTxForUnknownIdEnabled')]: {
     component: OCPP20ComponentName.AuthCtrlr,
     dataType: DataEnumType.boolean,
@@ -720,6 +736,7 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     unit: OCPP20UnitEnumType.CHARS,
     variable: OCPP20OptionalVariableName.ValueSize,
   },
-
   [buildRegistryKey(
     OCPP20ComponentName.DeviceDataCtrlr,
     OCPP20RequiredVariableName.BytesPerMessage
@@ -1056,6 +1071,32 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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:<key> 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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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<string, VariableMetadata> = {
     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')]: {
index b022c19acedea8d657d4807c8d50dc8e60ac00ef..da9c0c9191d739347e78909096c7971ab0a2c99b 100644 (file)
@@ -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 (file)
index 0000000..c9f2dc8
--- /dev/null
@@ -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 (file)
index 0000000..48d4867
--- /dev/null
@@ -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<T extends SampledValue = SampledValue> {
+  publicKeyIncluded: boolean
+  sampledValue: T
+}
+
+export interface SigningConfig {
+  meterSerialNumber: string
+  publicKeyHex?: string
+  publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType
+}
+
+const PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES = new Set<string>(
+  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)}`
+      )
+  }
+}
index 7b1458d86f5258198a2c34bbab657bd46a8ecd3e..9a5865c4fcbae53a05964521c527890c6207fa41 100644 (file)
@@ -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'
index ec846b3685d91fdbfc1e33d5460eadd5d11e57aa..fab168e8f6bdfa29573288cd6e60d49d0c096117 100644 (file)
@@ -24,6 +24,7 @@ export interface ConnectorStatus {
   localAuthorizeIdTag?: string
   locked?: boolean
   MeterValues: SampledValueTemplate[]
+  publicKeySentInTransaction?: boolean
   remoteStartId?: number
   reservation?: Reservation
   status?: ConnectorStatusEnum
index 1d41b5a4fd3f33821e30318443850b8f0c5537ea..bd253b7cff999f051d9d011d5e5a5be02e44b63b 100644 (file)
@@ -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,
index 12074b23fffcfb2031fe84f766a32cf7b731267b..29e50af0fb99e4ed93cdab9fc9367e9211fd1592 100644 (file)
@@ -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',
 }
index 9630f0629ff0b8080dcf2569ed1f23666821f8b7..de4461f3f68998c3167226e615149e2097497b15 100644 (file)
@@ -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
+}
index d2a01c8943aeea057708a53a694bf67b7ba6e4a5..0f12c9e9f0c680072f62a4e8d6138bb9ca9cb4af 100644 (file)
@@ -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',
 }
 
index f076074238b8c6aabbce1efa9127a8ce5f334e69..90a3bab37f4bb10a3c8a32711bbcd6bbd10e73b4 100644 (file)
@@ -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 (file)
index 0000000..958758b
--- /dev/null
@@ -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<unknown> => {
+            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 (file)
index 0000000..aa1a4ca
--- /dev/null
@@ -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 (file)
index 0000000..bd3537b
--- /dev/null
@@ -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 (file)
index 0000000..5ebb0a7
--- /dev/null
@@ -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
+      )
+    })
+  })
+})
index 225526e140b0a821ac7a2c0514996362c697e3f8..4d54b736b2a404d3a85fffc91f4edd04bf13f234 100644 (file)
@@ -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)
index 42f754488cb8b6c7ed8f3582e489bf9d00bf17c9..38c061035daf0a7556c24682f6a3318f3b750431 100644 (file)
@@ -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 (file)
index 0000000..068817f
--- /dev/null
@@ -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 (file)
index 0000000..186a74a
--- /dev/null
@@ -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')
+  })
+})