From 4b421f2e44ce695ca51e3cfea5691230be1b2bee Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Fri, 3 Jul 2026 23:26:23 +0200 Subject: [PATCH] feat(ocpp16): wire signed meter values into trigger + broadcast paths (issue #1936 d) (#1945) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * 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). --- .../ChargingStationWorkerBroadcastChannel.ts | 27 ++++- .../ocpp/1.6/OCPP16IncomingRequestService.ts | 62 +++++++---- .../ocpp/1.6/OCPP16ServiceUtils.ts | 102 ++++++++++++------ 3 files changed, 139 insertions(+), 52 deletions(-) diff --git a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts index 42df35cd..bf86c3f3 100644 --- a/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts +++ b/src/charging-station/broadcast-channel/ChargingStationWorkerBroadcastChannel.ts @@ -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 diff --git a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts index 3ecadfdc..a164db11 100644 --- a/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts +++ b/src/charging-station/ocpp/1.6/OCPP16IncomingRequestService.ts @@ -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( 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( 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, diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index d34ff82c..303b9daf 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -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( 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 -- 2.53.0