]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp20): trigger meterValues path emits MeterValuesRequest per F06.FR.06 (issue...
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Sat, 4 Jul 2026 16:36:19 +0000 (18:36 +0200)
committerGitHub <noreply@github.com>
Sat, 4 Jul 2026 16:36:19 +0000 (18:36 +0200)
* 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).

src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-TriggerMessage.test.ts

index de66867a64607a9e282b47febfcad0ee986dfe71..3298f9c457d188462b1dfd697559177c66ae49ab 100644 (file)
@@ -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<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
-                  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<OCPP20MeterValuesRequest, OCPP20MeterValuesResponse>(
+        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'],
index 281e0d58333e35e0ffd43e5e5647ac2f4d99f56e..12728a467115896f6b0f8e6209ad092d6b815886 100644 (file)
@@ -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<number>()
+      for (const call of requestHandlerMock.mock.calls) {
+        const args = call.arguments as [
+          unknown,
+          string,
+          Partial<OCPP20MeterValuesRequest>,
+          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,