]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Fri, 3 Jul 2026 21:26:23 +0000 (23:26 +0200)
committerGitHub <noreply@github.com>
Fri, 3 Jul 2026 21:26:23 +0000 (23:26 +0200)
* feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d)

OCPP 1.6 Signed Meter Values whitepaper v1.0 requires that when
SampledDataSignReadings + SampledDataSignUpdatedReadings are enabled
and a signing key is configured, a paired SignedData SampledValue
accompanies the Raw SampledValue in every Sample.Periodic-context
MeterValue. The periodic loop (OCPP16ServiceUtils.startUpdatedMeterValues)
already applies POST-HOC signing after buildMeterValue returns, so
periodic MeterValues are signed today.

Two callsites were missing the signing wrapper:
1. OCPP 1.6 TriggerMessage(MeterValues) handler in
   OCPP16IncomingRequestService.ts (both the specific-connectorId
   branch and the broadcast-to-all-connectors branch).
2. Worker broadcast channel in
   ChargingStationWorkerBroadcastChannel.handleMeterValues
   (cross-version handler; signing is 1.6-only per the whitepaper).

Both paths silently emitted unsigned Raw values instead of the
whitepaper-mandated paired SignedData SampledValue when signing was
enabled.

Fix: consolidate the signing block from startUpdatedMeterValues into
a new public static helper OCPP16ServiceUtils.appendSignedUpdatedReadings
that mutates the MeterValue in place. The helper is a no-op when
signing is disabled or the connector's signing config is missing.
Apply the helper at the three missing callsites and refactor
startUpdatedMeterValues to call it too.

Test invariant fail: 0, skipped: 6 preserved (2895 pass / 2901 total).

Spec citation: OCA Application Note 'Signed Meter Values for OCPP 1.6'
v1.0 §3.2.1 (paired SignedData SampledValue).

Closes issue #1936 item (d).

* docs(ocpp16): apply round-1 review fixes on signed MV wire-up (issue #1936 d)

Address cross-validated R1 findings:

- Naming coherence in TriggerMessage broadcast-to-all branch: rename
  loop-local variables from abbreviated 'id'/'cs'/'txId'/'mv' to
  full 'connectorId'/'connectorStatus'/'transactionId'/'meterValue'
  to match the specific-connector sibling branch style. Per AGENTS.md
  naming coherence: two adjacent branches doing the same thing with
  different name styles is the 'synonym creates ambiguity' pattern.

- Rewrite the broadcast channel guard comment to correctly attribute
  the '!isOcpp2' skip. Previous wording claimed 'the whitepaper is OCPP
  1.6 specific', but the whitepaper §4 explicitly covers OCPP 2.x. The
  actual reason for the skip is that OCPP 2.0.x signing is applied
  inline inside buildMeterValue via the versioned dispatcher's signing
  hook, so post-hoc wrapping is the 1.6 pattern only.

- Tighten the appendSignedUpdatedReadings @description to drop
  refactor-history narrative ('Consolidates the signing block used by
  every trigger/broadcast path...'). Replace with operational spec
  citing whitepaper §3.3.6 SampledDataSignUpdatedReadings and the
  mutation contract on connectorStatus.publicKeySentInTransaction
  (per PublicKeyWithSignedMeterValue = OncePerTransaction).

* fix(ocpp16): harden signed MV helper and thread reading context (issue #1936 d)

Enforce the transactionStarted invariant inside `appendSignedUpdatedReadings` so callsites
with looser guards cannot leak signed emissions past `resetConnectorTransactionStatus`.

Accept an optional reading `context` parameter (default `Sample.Periodic`);
`TriggerMessage(MeterValues)` callsites pass `Trigger` per OCPP 1.6 Core Table 30 so the
signed payload's context field matches its emission source.

Downgrade the `readSigningConfigForConnector` disabled-state log from `warn` to `debug`;
the helper is a hot-path probe whose `undefined` return already conveys the disabled state
to callers.

Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total).

src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts
src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts
src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts

index 42df35cde7f1392ef21684fedd8d9b210a64acdf..bf86c3f343c20df6a0a60c4ec6bfd54f73070079 100644 (file)
@@ -22,6 +22,7 @@ import {
   type MeterValuesRequest,
   type MeterValuesResponse,
   type OCPP16AuthorizeResponse,
+  type OCPP16MeterValue,
   OCPP20AuthorizationStatusEnumType,
   type OCPP20AuthorizeResponse,
   type OCPP20Get15118EVCertificateResponse,
@@ -48,7 +49,12 @@ import {
   logger,
 } from '../../utils/index.js'
 import { getConfigurationKey } from '../ConfigurationKeyUtils.js'
-import { buildMeterValue, OCPP20ServiceUtils, sendAndSetConnectorStatus } from '../ocpp/index.js'
+import {
+  buildMeterValue,
+  OCPP16ServiceUtils,
+  OCPP20ServiceUtils,
+  sendAndSetConnectorStatus,
+} from '../ocpp/index.js'
 import { WorkerBroadcastChannel } from './WorkerBroadcastChannel.js'
 
 const moduleName = 'ChargingStationWorkerBroadcastChannel'
@@ -421,6 +427,23 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
             ? secondsToMilliseconds(convertToInt(key.value))
             : Constants.DEFAULT_METER_VALUES_INTERVAL_MS
         })()
+    const meterValue = buildMeterValue(this.chargingStation, transactionId, interval)
+    // OCPP 1.6 Signed Meter Values whitepaper §3.3.6: mirror the periodic
+    // loop (`OCPP16ServiceUtils.startUpdatedMeterValues`) and append a paired
+    // SignedData SampledValue when signing is enabled for the connector.
+    // Guarded on `!isOcpp2` because OCPP 2.0.x signing is applied inline
+    // inside `buildMeterValue` (via the versioned dispatcher's signing hook);
+    // post-hoc wrapping is the 1.6 pattern only. Guarded on `transactionId
+    // != null` because signing a MeterValue outside an active transaction
+    // has no defined semantics in the whitepaper.
+    if (!isOcpp2 && transactionId != null) {
+      OCPP16ServiceUtils.appendSignedUpdatedReadings(
+        this.chargingStation,
+        connectorId,
+        convertToInt(transactionId),
+        meterValue as OCPP16MeterValue
+      )
+    }
     return await this.chargingStation.ocppRequestService.requestHandler<
       MeterValuesRequest,
       MeterValuesResponse
@@ -433,7 +456,7 @@ export class ChargingStationWorkerBroadcastChannel extends WorkerBroadcastChanne
               evseId: payloadEvseId ?? this.chargingStation.getEvseIdByConnectorId(connectorId),
             }
           : { connectorId }),
-        meterValue: [buildMeterValue(this.chargingStation, transactionId, interval)],
+        meterValue: [meterValue],
         ...requestPayload,
       } as MeterValuesRequest,
       this.requestParams
index 3ecadfdcf2ca7d7be84688a3c14f9601555e2e7f..a164db1180db1703491db903dfe160b8aad85164 100644 (file)
@@ -74,6 +74,7 @@ import {
   OCPP16IncomingRequestCommand,
   OCPP16MessageTrigger,
   type OCPP16MeterValue,
+  OCPP16MeterValueContext,
   type OCPP16MeterValuesRequest,
   type OCPP16MeterValuesResponse,
   OCPP16RequestCommand,
@@ -461,20 +462,27 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
                 connectorStatus?.transactionStarted === true &&
                 connectorStatus.transactionId != null
               ) {
+                const transactionId = convertToInt(connectorStatus.transactionId)
+                const meterValue = buildMeterValue(
+                  chargingStation,
+                  transactionId,
+                  0
+                ) as OCPP16MeterValue
+                OCPP16ServiceUtils.appendSignedUpdatedReadings(
+                  chargingStation,
+                  connectorId,
+                  transactionId,
+                  meterValue,
+                  OCPP16MeterValueContext.TRIGGER
+                )
                 chargingStation.ocppRequestService
                   .requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
                     chargingStation,
                     OCPP16RequestCommand.METER_VALUES,
                     {
                       connectorId,
-                      meterValue: [
-                        buildMeterValue(
-                          chargingStation,
-                          convertToInt(connectorStatus.transactionId),
-                          0
-                        ) as OCPP16MeterValue,
-                      ],
-                      transactionId: convertToInt(connectorStatus.transactionId),
+                      meterValue: [meterValue],
+                      transactionId,
                     },
                     {
                       triggerMessage: true,
@@ -483,23 +491,37 @@ export class OCPP16IncomingRequestService extends OCPPIncomingRequestService {
                   .catch(errorHandler)
               }
             } else {
-              for (let id = 1; id <= chargingStation.getNumberOfConnectors(); id++) {
-                const cs = chargingStation.getConnectorStatus(id)
-                if (cs?.transactionStarted === true && cs.transactionId != null) {
+              for (
+                let connectorId = 1;
+                connectorId <= chargingStation.getNumberOfConnectors();
+                connectorId++
+              ) {
+                const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+                if (
+                  connectorStatus?.transactionStarted === true &&
+                  connectorStatus.transactionId != null
+                ) {
+                  const transactionId = convertToInt(connectorStatus.transactionId)
+                  const meterValue = buildMeterValue(
+                    chargingStation,
+                    transactionId,
+                    0
+                  ) as OCPP16MeterValue
+                  OCPP16ServiceUtils.appendSignedUpdatedReadings(
+                    chargingStation,
+                    connectorId,
+                    transactionId,
+                    meterValue,
+                    OCPP16MeterValueContext.TRIGGER
+                  )
                   chargingStation.ocppRequestService
                     .requestHandler<OCPP16MeterValuesRequest, OCPP16MeterValuesResponse>(
                       chargingStation,
                       OCPP16RequestCommand.METER_VALUES,
                       {
-                        connectorId: id,
-                        meterValue: [
-                          buildMeterValue(
-                            chargingStation,
-                            convertToInt(cs.transactionId),
-                            0
-                          ) as OCPP16MeterValue,
-                        ],
-                        transactionId: convertToInt(cs.transactionId),
+                        connectorId,
+                        meterValue: [meterValue],
+                        transactionId,
                       },
                       {
                         triggerMessage: true,
index d34ff82ce775a45749fab7791383baa1997737c2..303b9daf69a1cbe313adca18b90e4418cfbe5afb 100644 (file)
@@ -123,6 +123,65 @@ export class OCPP16ServiceUtils {
     [OCPP16RequestCommand.STOP_TRANSACTION, 'StopTransaction'],
   ]
 
+  /**
+   * Post-hoc signing wrapper for OCPP 1.6 Signed Meter Values whitepaper
+   * §3.3.6 (`SampledDataSignUpdatedReadings`). When
+   * `SampledDataSignReadings` and `SampledDataSignUpdatedReadings` are
+   * both enabled and a signing key is configured for the connector,
+   * appends a paired `SignedData` `SampledValue` to the supplied
+   * `MeterValue`. Idempotent no-op when signing is disabled or the
+   * signing prerequisites are absent.
+   *
+   * Mutates `meterValue.sampledValue` in place and updates
+   * `connectorStatus.publicKeySentInTransaction` so the public-key
+   * payload is emitted at most once per transaction (per
+   * `PublicKeyWithSignedMeterValue = OncePerTransaction`).
+   * @param chargingStation - Target charging station.
+   * @param connectorId - Connector identifier owning the transaction.
+   * @param transactionId - Active transaction identifier.
+   * @param meterValue - MeterValue to mutate (in place).
+   * @param context - Reading context for the emitted `SignedData` sampled value (defaults to `Sample.Periodic`); pass `Trigger` for TriggerMessage-originated emissions per OCPP 1.6 Core Table 30.
+   */
+  public static appendSignedUpdatedReadings (
+    chargingStation: ChargingStation,
+    connectorId: number,
+    transactionId: number,
+    meterValue: OCPP16MeterValue,
+    context: OCPP16MeterValueContext = OCPP16MeterValueContext.SAMPLE_PERIODIC
+  ): void {
+    if (
+      !OCPP16ServiceUtils.isSigningEnabled(chargingStation) ||
+      !OCPP16ServiceUtils.isSigningUpdatedReadingsEnabled(chargingStation)
+    ) {
+      return
+    }
+    const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+    if (connectorStatus?.transactionStarted !== true) {
+      return
+    }
+    const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
+      chargingStation,
+      connectorId
+    )
+    if (signingCfg == null) {
+      return
+    }
+    const energyWh = chargingStation.getEnergyActiveImportRegisterByTransactionId(transactionId)
+    const publicKeySentInTransaction = connectorStatus.publicKeySentInTransaction ?? false
+    const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+      signingCfg,
+      energyWh,
+      context,
+      transactionId,
+      publicKeySentInTransaction,
+      meterValue.timestamp
+    )
+    meterValue.sampledValue.push(signedResult.sampledValue)
+    if (signedResult.publicKeyIncluded) {
+      connectorStatus.publicKeySentInTransaction = true
+    }
+  }
+
   /**
    * @param commandParams - Status notification parameters
    * @returns Formatted OCPP 1.6 StatusNotification request payload
@@ -842,34 +901,17 @@ export class OCPP16ServiceUtils {
     }
     connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(() => {
       const transactionId = convertToInt(connectorStatus.transactionId)
-      const meterValue = buildMeterValue(chargingStation, transactionId, interval)
-      if (
-        OCPP16ServiceUtils.isSigningEnabled(chargingStation) &&
-        OCPP16ServiceUtils.isSigningUpdatedReadingsEnabled(chargingStation)
-      ) {
-        const energyWh = chargingStation.getEnergyActiveImportRegisterByTransactionId(
-          connectorStatus.transactionId
-        )
-        const publicKeySentInTransaction = connectorStatus.publicKeySentInTransaction ?? false
-        const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
-          chargingStation,
-          connectorId
-        )
-        if (signingCfg != null) {
-          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
-          }
-        }
-      }
+      const meterValue = buildMeterValue(
+        chargingStation,
+        transactionId,
+        interval
+      ) as OCPP16MeterValue
+      OCPP16ServiceUtils.appendSignedUpdatedReadings(
+        chargingStation,
+        connectorId,
+        transactionId,
+        meterValue
+      )
       chargingStation.ocppRequestService
         .requestHandler<MeterValuesRequest, MeterValuesResponse>(
           chargingStation,
@@ -878,7 +920,7 @@ export class OCPP16ServiceUtils {
             connectorId,
             meterValue: [meterValue],
             transactionId,
-          } as MeterValuesRequest
+          }
         )
         .catch((error: unknown) => {
           logger.error(
@@ -1113,7 +1155,7 @@ export class OCPP16ServiceUtils {
 
     const prerequisiteResult = validateSigningPrerequisites(publicKeyHex, configuredSigningMethod)
     if (!prerequisiteResult.enabled) {
-      logger.warn(
+      logger.debug(
         `${chargingStation.logPrefix()} OCPP16ServiceUtils.readSigningConfigForConnector: Signed meter values disabled for connector ${connectorId.toString()}: ${prerequisiteResult.reason}`
       )
       return undefined