From 66890b06db7bf33b2c63520b6c7d0122a0b50b83 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 4 Jul 2026 18:36:19 +0200 Subject: [PATCH] fix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue #1936 j) (#1943) MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit * fix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue #1936 j) OCPP 2.0.1 F06.FR.06: when a CSMS sends a TriggerMessage with MessageTrigger.MeterValues, the Charging Station SHALL respond with a MeterValuesRequest carrying the requested Measurand values. The current OCPP20IncomingRequestService.handleRequestTriggerMessage handler for MessageTrigger.MeterValues iterated all connectors with active transactions and sent TransactionEventRequest(Updated) — a spec violation introduced by PR #1726. It only fell back to sending a MeterValuesRequest when no active transactions existed. Fix: always emit MeterValuesRequest for MessageTrigger.MeterValues. - Iterate target EVSEs (specific from evse.id, or all if unspecified). - Per EVSE, build one MeterValue per connector with an active transaction using the OCPP 2.0.1 AlignedDataCtrlr.Measurands allow-list and TRIGGER ReadingContext (matches F06 semantics for aligned/triggered data). Sub-schema MeterValues with empty sampledValue arrays are skipped per MeterValueType.sampledValue's 1..* cardinality. - Emit one MeterValuesRequest per target EVSE aggregating those MeterValues. When an EVSE has no active transactions, emit one request with a schema-conforming placeholder so the CSMS observes the response. Test updates: - Remove the MeterValues entry from the shared parameterized 'fires requestHandler once' loop (that assertion baked in the pre-fix 1-call fallback behavior). - Add a dedicated F06.FR.06 broadcast test asserting one MeterValuesRequest per EVSE (3 EVSEs → 3 requests) — symmetric with the existing StatusNotification broadcast test. Test invariant fail: 0, skipped: 6 preserved. Closes issue #1936 item (j). * fix(ocpp20): tag trigger MV placeholder with Trigger context and measurand (issue #1936 j) The placeholder SampledValue emitted when an EVSE has no active transaction previously omitted both `context` and `measurand`, silently defaulting to `Sample.Periodic` and `Energy.Active.Import.Register` per the OCPP 2.0.1 `MeterValuesRequest` schema. TC_F_12_CS (F06.FR.06 certification) asserts `meterValue[0].sampledValue[0].context = 'Trigger'` on trigger-elicited MeterValuesRequests, and reporting `Energy.Active.Import.Register = 0` misleads the CSMS into recording a spurious energy register reset. Sets `context = OCPP20ReadingContextEnumType.TRIGGER` and `measurand = OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT` on the placeholder so the wire payload is truthful (`0 W` when no charging is in progress) and consistent with the surrounding trigger emission's declared context. Extends the broadcast test to assert per-EVSE payload shape (command, `evseId > 0`, `meterValue` non-empty, `sampledValue[0].context = Trigger`, request options) mirroring the sibling StatusNotification broadcast test. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). * test(ocpp20): use numeric comparator on trigger meterValues broadcast assertion (issue #1936 j) `Array.prototype.sort()` sorts as strings by default; the coincidence with numeric ordering only holds for the current fixture's single-digit EVSE ids `{1, 2, 3}`. If a future fixture bumps `evsesCount` beyond 9, the assertion would silently reorder (`['1','10','2','3']`) and mask a real bug. Uses an explicit `(a, b) => a - b` numeric comparator so the sort behavior is fixture-count-agnostic. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). * test(ocpp20): lock trigger meterValues placeholder measurand value (issue #1936 j) Extends the F06.FR.06 broadcast test to assert `sampledValue[0].measurand = Power.Active.Import` on the placeholder path, locking the design tradeoff: the placeholder emits `Power.Active.Import = 0 W` (truthful when idle) rather than reading the first entry from `AlignedDataCtrlr.Measurands` (whose default value `Energy.Active.Import.Register = 0 Wh` would imply a cumulative register reset). F06.FR.09 requires "current information only"; a 0-value energy register is a stronger lie than a 0-value power flow when no transaction is running. The measurand assertion prevents a future regression that swaps the placeholder for the schema-default measurand. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). * refactor(ocpp20): extract triggerMeterValues + emitEvseMeterValues helpers (issue #1936 j) Harmonises the OCPP 2.0.1 TriggerMessage(MeterValues) case with the sibling `triggerStatusNotification` / `triggerAllEvseStatusNotifications` pattern already established in the same class. The 66-line inline case is replaced by a single-line delegation, matching the visual rhythm of every other switch case. Design refinements enabled by the extraction: - `emitEvseMeterValues` now consumes the `evseStatus` yielded directly by `iterateEvses(true)`, removing a redundant Map lookup on every broadcast target (matches the sibling `triggerAllEvseStatusNotifications` idiom). - The unreachable `evseStatus == null` guard in the broadcast path is eliminated; the type is narrowed once at the top of the specific-EVSE branch. - The F06.FR.06 spec-citation comment block is lifted to the helper's JSDoc, composing F06.FR.10 (Accepted messages SHALL be sent) and F06.FR.11 (evse absent -> broadcast to all EVSEs) references. Test invariant `fail: 0, skipped: 6` preserved (2916 pass / 2922 total). * fix(ocpp20): guard buildMeterValue against synchronous throw in trigger path (issue #1936 j) `buildMeterValue` can throw synchronously - the sibling `OCPP20ServiceUtils.buildTransactionStartedMeterValues` (lines 165-183) wraps it in try/catch and logs on failure. `emitEvseMeterValues` was calling the same builder unguarded; a synchronous throw would have unwound through the EventEmitter listener without ever reaching the downstream `.catch(errorHandler)` (which only observes Promise rejection). Mirrors the sibling defensive pattern: wraps the per-connector `buildMeterValue` call in try/catch, logs the error with the module scope name and forwarded reason, and continues the outer loop so that one bad connector does not prevent the remaining connectors' meter values from being aggregated into the outgoing MeterValuesRequest. Test invariant `fail: 0, skipped: 6` preserved (2925 pass / 2931 total). --- .../ocpp/2.0/OCPP20IncomingRequestService.ts | 184 ++++++++++++------ ...omingRequestService-TriggerMessage.test.ts | 67 ++++++- 2 files changed, 185 insertions(+), 66 deletions(-) diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index de66867a..3298f9c4 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -78,6 +78,7 @@ import { type OCPP20InstallCertificateResponse, type OCPP20LogStatusNotificationRequest, type OCPP20LogStatusNotificationResponse, + OCPP20MeasurandEnumType, type OCPP20MeterValue, type OCPP20MeterValuesRequest, type OCPP20MeterValuesResponse, @@ -140,6 +141,7 @@ import { convertToIntOrNaN, ensureError, generateUUID, + getErrorMessage, handleIncomingRequestError, isEmpty, isNotEmptyArray, @@ -592,69 +594,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { ) .catch(errorHandler) break - case MessageTriggerEnumType.MeterValues: { - const targetEvseIds: number[] = [] - if (evse?.id != null && evse.id > 0) { - targetEvseIds.push(evse.id) - } else { - for (const { evseId } of chargingStation.iterateEvses(true)) { - targetEvseIds.push(evseId) - } - } - let hasSentTransactionEvent = false - for (const targetEvseId of targetEvseIds) { - const evseStatus = chargingStation.getEvseStatus(targetEvseId) - if (evseStatus == null) continue - for (const [cId, connector] of evseStatus.connectors) { - if (connector.transactionId == null) continue - hasSentTransactionEvent = true - const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation) - const meterValue = buildMeterValue( - chargingStation, - connector.transactionId, - txUpdatedInterval, - buildConfigKey( - OCPP20ComponentName.SampledDataCtrlr, - OCPP20RequiredVariableName.TxUpdatedMeasurands - ), - OCPP20ReadingContextEnumType.TRIGGER - ) as OCPP20MeterValue - // OCPP 2.0.1 MeterValueType.sampledValue cardinality is 1..*, while - // TransactionEventRequest.meterValue is 0..*: when TxUpdatedMeasurands - // yields no sampled values, omit the meterValue field entirely rather - // than send an empty-wrapper schema violation. - const eventPayload = isNotEmptyArray(meterValue.sampledValue) - ? { meterValue: [meterValue] } - : {} - OCPP20ServiceUtils.sendTransactionEvent( - chargingStation, - OCPP20TransactionEventEnumType.Updated, - OCPP20TriggerReasonEnumType.Trigger, - cId, - connector.transactionId as string, - eventPayload - ).catch(errorHandler) - } - } - if (!hasSentTransactionEvent) { - const meterValue: OCPP20MeterValue = { - sampledValue: [{ value: 0 }], - timestamp: new Date(), - } - chargingStation.ocppRequestService - .requestHandler( - chargingStation, - OCPP20RequestCommand.METER_VALUES, - { - evseId: evse?.id ?? 1, - meterValue: [meterValue], - }, - { skipBufferingOnError: true, triggerMessage: true } - ) - .catch(errorHandler) - } + case MessageTriggerEnumType.MeterValues: + this.triggerMeterValues(chargingStation, evse, errorHandler) break - } case MessageTriggerEnumType.StatusNotification: this.triggerStatusNotification(chargingStation, evse, errorHandler) break @@ -1354,6 +1296,75 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { return queue.some(({ request }) => request.transactionInfo.transactionId === transactionId) } + /** + * Emits one `MeterValuesRequest` aggregating the given EVSE's + * active-transaction connectors, or a schema-conforming placeholder + * when the EVSE has no active transactions. + * @param chargingStation - Target charging station. + * @param evseId - Target EVSE identifier. + * @param evseStatus - Target EVSE status (yields the connectors). + * @param alignedInterval - Aligned-data emission interval in milliseconds. + * @param alignedMeasurandsKey - Configuration key for the AlignedDataCtrlr.Measurands allow-list. + * @param errorHandler - Handler for downstream request-emission errors. + */ + private emitEvseMeterValues ( + chargingStation: ChargingStation, + evseId: number, + evseStatus: EvseStatus, + alignedInterval: number, + alignedMeasurandsKey: string, + errorHandler: (error: unknown) => void + ): void { + const meterValues: OCPP20MeterValue[] = [] + for (const connector of evseStatus.connectors.values()) { + if (connector.transactionId == null) continue + let meterValue: OCPP20MeterValue + try { + meterValue = buildMeterValue( + chargingStation, + connector.transactionId, + alignedInterval, + alignedMeasurandsKey, + OCPP20ReadingContextEnumType.TRIGGER + ) as OCPP20MeterValue + } catch (error) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.emitEvseMeterValues: ${getErrorMessage(error)}` + ) + continue + } + // OCPP 2.0.1 MeterValueType.sampledValue cardinality is 1..*: + // skip a MeterValue whose emit set collapsed to empty so the + // outgoing MeterValuesRequest stays schema-conforming. + if (isNotEmptyArray(meterValue.sampledValue)) { + meterValues.push(meterValue) + } + } + if (meterValues.length === 0) { + meterValues.push({ + sampledValue: [ + { + context: OCPP20ReadingContextEnumType.TRIGGER, + measurand: OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, + value: 0, + }, + ], + timestamp: new Date(), + }) + } + chargingStation.ocppRequestService + .requestHandler( + chargingStation, + OCPP20RequestCommand.METER_VALUES, + { + evseId, + meterValue: meterValues, + }, + { skipBufferingOnError: true, triggerMessage: true } + ) + .catch(errorHandler) + } + private getRestoredConnectorStatus ( chargingStation: ChargingStation, connectorId: number @@ -3944,6 +3955,55 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } + /** + * OCPP 2.0.1 F06.FR.06: TriggerMessage with `MessageTrigger.MeterValues` + * SHALL respond with `MeterValuesRequest` (not `TransactionEventRequest`), + * using the `AlignedDataCtrlr.Measurands` allow-list and `TRIGGER` + * ReadingContext. One `MeterValuesRequest` per target EVSE aggregating + * MeterValues from every connector with an active transaction; when an + * EVSE has no active transactions, still emit one request with a + * schema-conforming placeholder so the CSMS observes the response + * (F06.FR.10). F06.FR.11 broadcast semantics apply when `evse` is absent. + * @param chargingStation - Target charging station. + * @param evse - Optional target EVSE from the TriggerMessage payload. + * @param errorHandler - Handler for downstream request-emission errors. + */ + private triggerMeterValues ( + chargingStation: ChargingStation, + evse: OCPP20TriggerMessageRequest['evse'], + errorHandler: (error: unknown) => void + ): void { + const alignedInterval = OCPP20ServiceUtils.getAlignedDataInterval(chargingStation) + const alignedMeasurandsKey = buildConfigKey( + OCPP20ComponentName.AlignedDataCtrlr, + OCPP20RequiredVariableName.Measurands + ) + if (evse?.id != null && evse.id > 0) { + const evseStatus = chargingStation.getEvseStatus(evse.id) + if (evseStatus != null) { + this.emitEvseMeterValues( + chargingStation, + evse.id, + evseStatus, + alignedInterval, + alignedMeasurandsKey, + errorHandler + ) + } + } else { + for (const { evseId, evseStatus } of chargingStation.iterateEvses(true)) { + this.emitEvseMeterValues( + chargingStation, + evseId, + evseStatus, + alignedInterval, + alignedMeasurandsKey, + errorHandler + ) + } + } + } + private triggerStatusNotification ( chargingStation: ChargingStation, evse: OCPP20TriggerMessageRequest['evse'], diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts index 281e0d58..12728a46 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts @@ -7,6 +7,7 @@ import assert from 'node:assert/strict' import { afterEach, beforeEach, describe, it, mock } from 'node:test' import type { + OCPP20MeterValuesRequest, OCPP20StatusNotificationRequest, OCPP20TriggerMessageRequest, OCPP20TriggerMessageResponse, @@ -19,6 +20,8 @@ import { OCPP20IncomingRequestService } from '../../../../src/charging-station/o import { MessageTriggerEnumType, OCPP20IncomingRequestCommand, + OCPP20MeasurandEnumType, + OCPP20ReadingContextEnumType, OCPP20RequestCommand, OCPPVersion, ReasonCodeEnumType, @@ -459,10 +462,6 @@ await describe('F06 - TriggerMessage', async () => { name: 'LogStatusNotification', trigger: MessageTriggerEnumType.LogStatusNotification, }, - { - name: 'MeterValues', - trigger: MessageTriggerEnumType.MeterValues, - }, ] for (const { name, trigger } of triggerCases) { @@ -485,6 +484,66 @@ await describe('F06 - TriggerMessage', async () => { }) } + await it('should broadcast MeterValuesRequest for all EVSEs on Accepted (F06.FR.06)', () => { + const request: OCPP20TriggerMessageRequest = { + requestedMessage: MessageTriggerEnumType.MeterValues, + } + const response: OCPP20TriggerMessageResponse = { + status: TriggerMessageStatusEnumType.Accepted, + } + + incomingRequestServiceForListener.emit( + OCPP20IncomingRequestCommand.TRIGGER_MESSAGE, + mockStation, + request, + response + ) + + // F06.FR.06: TriggerMessage(MessageTrigger.MeterValues) without a + // specific EVSE MUST emit one MeterValuesRequest per EVSE. Fixture has + // 3 EVSEs -> 3 requests. + const callCount = requestHandlerMock.mock.callCount() + assert.strictEqual(callCount, 3) + const observedEvseIds = new Set() + for (const call of requestHandlerMock.mock.calls) { + const args = call.arguments as [ + unknown, + string, + Partial, + RequestParams + ] + const [, command, payload, options] = args + assert.strictEqual(command, OCPP20RequestCommand.METER_VALUES) + assert.notStrictEqual(payload, undefined) + assert.ok('evseId' in payload, 'Expected payload to include evseId') + assert.ok('meterValue' in payload, 'Expected payload to include meterValue') + assert.ok( + payload.evseId != null && payload.evseId > 0, + 'Expected evseId > 0 (EVSE 0 excluded)' + ) + observedEvseIds.add(payload.evseId) + assert.ok(Array.isArray(payload.meterValue), 'Expected meterValue to be an array') + assert.ok(payload.meterValue.length >= 1, 'Expected meterValue.length >= 1') + const firstSample = payload.meterValue[0].sampledValue[0] + assert.strictEqual( + firstSample.context, + OCPP20ReadingContextEnumType.TRIGGER, + 'Expected sampledValue[0].context = Trigger per TC_F_12_CS' + ) + assert.strictEqual( + firstSample.measurand, + OCPP20MeasurandEnumType.POWER_ACTIVE_IMPORT, + 'Expected sampledValue[0].measurand = Power.Active.Import (placeholder emits Power.Active.Import = 0 W, truthful when idle, regardless of AlignedDataCtrlr.Measurands default which is Energy.Active.Import.Register)' + ) + assert.strictEqual(options.skipBufferingOnError, true) + assert.strictEqual(options.triggerMessage, true) + } + assert.deepStrictEqual( + [...observedEvseIds].sort((a, b) => a - b), + [1, 2, 3] + ) + }) + await it('should broadcast StatusNotification for all EVSEs on Accepted without specific EVSE', () => { const request: OCPP20TriggerMessageRequest = { requestedMessage: MessageTriggerEnumType.StatusNotification, -- 2.53.0