]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp): support TriggerMessage(TransactionEvent) per F06.FR.07 (#2013)
authorJérôme Benoit <jerome.benoit@piment-noir.org>
Thu, 16 Jul 2026 11:23:56 +0000 (13:23 +0200)
committerGitHub <noreply@github.com>
Thu, 16 Jul 2026 11:23:56 +0000 (13:23 +0200)
TriggerMessage(requestedMessage=TransactionEvent) returned NotImplemented
although TransactionEvent is a fully implemented outgoing message, violating
OCPP 2.0.1 F06.FR.07/08.

Wire the trigger to the existing TransactionEvent send path across the two
touchpoints:
- handleRequestTriggerMessage: add a TransactionEvent case that validates the
  evse, returns Accepted when an ongoing transaction exists in scope, else
  Rejected (F06.FR.05); it no longer falls through to NotImplemented.
- TRIGGER_MESSAGE dispatch listener: add a TransactionEvent case that sends a
  TransactionEventRequest(eventType=Updated, triggerReason=Trigger) with the
  TxUpdatedMeasurands meterValue for each active transaction in scope; when the
  evse field is absent it fans out to all EVSEs (F06.FR.11).

Reuses sendTransactionEvent and the TxUpdatedMeasurands meter-value builder;
chargingState is populated by buildTransactionEvent for Updated events. The 6
existing trigger cases are unchanged.

Closes #2012

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

index 55f1100ae8ac5346ac77afd4660a5b644b4fac6d..1e90cbb429695e365e3cbc00f73ee9000ddf2d81 100644 (file)
@@ -690,6 +690,9 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           case MessageTriggerEnumType.StatusNotification:
             this.triggerStatusNotification(chargingStation, evse, errorHandler)
             break
+          case MessageTriggerEnumType.TransactionEvent:
+            this.triggerTransactionEvent(chargingStation, evse, errorHandler)
+            break
         }
       }
     )
@@ -3099,6 +3102,23 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
           return { status: TriggerMessageStatusEnumType.Accepted }
         }
 
+        case MessageTriggerEnumType.TransactionEvent: {
+          const evseValidation = this.validateTriggerMessageEvse(chargingStation, evse)
+          if (evseValidation != null) {
+            return evseValidation
+          }
+          if (isEmpty(this.resolveActiveTransactionConnectors(chargingStation, evse))) {
+            return {
+              status: TriggerMessageStatusEnumType.Rejected,
+              statusInfo: {
+                additionalInfo: 'No active transaction to trigger a TransactionEvent for',
+                reasonCode: ReasonCodeEnumType.TxNotFound,
+              },
+            }
+          }
+          return { status: TriggerMessageStatusEnumType.Accepted }
+        }
+
         default:
           logger.warn(
             `${chargingStation.logPrefix()} ${moduleName}.handleRequestTriggerMessage: Unsupported message trigger '${requestedMessage}'`
@@ -3460,6 +3480,46 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     await restoreConnectorStatus(chargingStation, connectorId, connectorStatus)
   }
 
+  /**
+   * Resolve the connectors carrying an active transaction within the trigger
+   * scope. When an EVSE is specified, only that EVSE's connectors are
+   * considered; when the EVSE field is absent, all EVSEs are considered
+   * (F06.FR.11 — "for all allowed evse values").
+   *
+   * The predicate requires `transactionStarted === true` and deliberately
+   * excludes `transactionPending` connectors (unlike the looser
+   * {@link OCPP20ServiceUtils.resolveActiveTransaction}): only a started
+   * transaction yields `chargingState = Charging`, which F06.FR.07 and the
+   * OCPP 2.0.1 conformance test cases TC_F_13/TC_F_14 mandate for the triggered
+   * TransactionEventRequest.
+   * @param chargingStation - Target charging station.
+   * @param evse - Optional EVSE scope from the TriggerMessageRequest.
+   * @returns The `{ connectorId, transactionId }` pair of each active transaction within the trigger scope.
+   * @see {@link hasEvseActiveTransactions} for the looser presence-only predicate used elsewhere.
+   */
+  private resolveActiveTransactionConnectors (
+    chargingStation: ChargingStation,
+    evse: OCPP20TriggerMessageRequest['evse']
+  ): { connectorId: number; transactionId: string }[] {
+    const targetEvseId = evse?.id != null && evse.id > 0 ? evse.id : undefined
+    const activeConnectors: { connectorId: number; transactionId: string }[] = []
+    for (const { connectorId, connectorStatus, evseId } of chargingStation.iterateConnectors(
+      true
+    )) {
+      if (
+        (targetEvseId == null || evseId === targetEvseId) &&
+        connectorStatus.transactionStarted === true &&
+        connectorStatus.transactionId != null
+      ) {
+        activeConnectors.push({
+          connectorId,
+          transactionId: connectorStatus.transactionId.toString(),
+        })
+      }
+    }
+    return activeConnectors
+  }
+
   /**
    * Saves current connector statuses before Inoperative is applied, for later restoration.
    * @param chargingStation - The charging station instance
@@ -4344,6 +4404,67 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService<OCP
     }
   }
 
+  /**
+   * Sends a triggered TransactionEventRequest for each in-scope transaction
+   * (F06.FR.07). Each event carries `eventType = Updated`, `triggerReason =
+   * Trigger`, the transaction's `chargingState` (derived by
+   * `buildTransactionEvent`), and a `meterValue` built from the
+   * SampledDataCtrlr.TxUpdatedMeasurands allow-list. When `evse` is absent the
+   * send is broadcast to every EVSE with an active transaction (F06.FR.11).
+   * @param chargingStation - Target charging station.
+   * @param evse - Optional target EVSE from the TriggerMessage payload.
+   * @param errorHandler - Handler for downstream request-emission errors.
+   */
+  private triggerTransactionEvent (
+    chargingStation: ChargingStation,
+    evse: OCPP20TriggerMessageRequest['evse'],
+    errorHandler: (error: unknown) => void
+  ): void {
+    const txUpdatedInterval = OCPP20ServiceUtils.getTxUpdatedInterval(chargingStation)
+    const txUpdatedMeasurandsKey = buildConfigKey(
+      OCPP20ComponentName.SampledDataCtrlr,
+      OCPP20RequiredVariableName.TxUpdatedMeasurands
+    )
+    for (const { connectorId, transactionId } of this.resolveActiveTransactionConnectors(
+      chargingStation,
+      evse
+    )) {
+      // F06.FR.10: a message marked Accepted SHALL be sent. A meterValue build
+      // failure must not suppress the event: `meterValue` is `0..*` while the
+      // `chargingState` (populated by buildTransactionEvent for Updated events)
+      // is the F06.FR.07-mandatory core, so omit the meterValue field on failure.
+      let eventPayload: { meterValue?: [OCPP20MeterValue] } = {}
+      try {
+        const meterValue = buildMeterValue(
+          chargingStation,
+          transactionId,
+          txUpdatedInterval,
+          txUpdatedMeasurandsKey,
+          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.
+        if (isNotEmptyArray(meterValue.sampledValue)) {
+          eventPayload = { meterValue: [meterValue] }
+        }
+      } catch (error) {
+        logger.warn(
+          `${chargingStation.logPrefix()} ${moduleName}.triggerTransactionEvent: ${getErrorMessage(error)}`
+        )
+      }
+      OCPP20ServiceUtils.sendTransactionEvent(
+        chargingStation,
+        OCPP20TransactionEventEnumType.Updated,
+        OCPP20TriggerReasonEnumType.Trigger,
+        connectorId,
+        transactionId,
+        eventPayload
+      ).catch(errorHandler)
+    }
+  }
+
   private validateChargingProfile (
     chargingStation: ChargingStation,
     chargingProfile: OCPP20ChargingProfileType,
index d86d45e0edbd77f1f895ec0323a133adf89cd117..f4ebc3a596d9819de1fdb3f797dd051aa850e489 100644 (file)
@@ -7,31 +7,44 @@ import assert from 'node:assert/strict'
 import { afterEach, beforeEach, describe, it, mock } from 'node:test'
 
 import type {
+  EvseStatus,
   OCPP20FirmwareStatusNotificationRequest,
   OCPP20MeterValuesRequest,
   OCPP20StatusNotificationRequest,
+  OCPP20TransactionEventOptions,
   OCPP20TriggerMessageRequest,
   OCPP20TriggerMessageResponse,
   RequestParams,
 } from '../../../../src/types/index.js'
 import type { MockChargingStation } from '../../helpers/StationHelpers.js'
 
+import { addConfigurationKey, buildConfigKey } from '../../../../src/charging-station/index.js'
 import { createTestableIncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/__testable__/index.js'
 import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js'
+import { buildTransactionEvent } from '../../../../src/charging-station/ocpp/2.0/OCPP20ServiceUtils.js'
 import {
   MessageTriggerEnumType,
+  OCPP20ChargingStateEnumType,
+  OCPP20ComponentName,
   OCPP20FirmwareStatusEnumType,
   OCPP20IncomingRequestCommand,
   OCPP20MeasurandEnumType,
   OCPP20ReadingContextEnumType,
   OCPP20RequestCommand,
+  OCPP20RequiredVariableName,
+  OCPP20TransactionEventEnumType,
+  OCPP20TriggerReasonEnumType,
   OCPPVersion,
   ReasonCodeEnumType,
   RegistrationStatusEnumType,
   TriggerMessageStatusEnumType,
 } from '../../../../src/types/index.js'
 import { Constants } from '../../../../src/utils/index.js'
-import { flushMicrotasks, standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+  flushMicrotasks,
+  setupConnectorWithTransaction,
+  standardCleanup,
+} from '../../../helpers/TestLifecycleHelpers.js'
 import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js'
 import { createMockChargingStation } from '../../helpers/StationHelpers.js'
 
@@ -222,21 +235,6 @@ await describe('F06 - TriggerMessage', async () => {
       ;({ mockStation } = createTriggerMessageStation())
     })
 
-    await it('should return NotImplemented for TransactionEvent trigger', () => {
-      const request: OCPP20TriggerMessageRequest = {
-        requestedMessage: MessageTriggerEnumType.TransactionEvent,
-      }
-
-      const response: OCPP20TriggerMessageResponse = testableService.handleRequestTriggerMessage(
-        mockStation,
-        request
-      )
-
-      assert.strictEqual(response.status, TriggerMessageStatusEnumType.NotImplemented)
-      assert.notStrictEqual(response.statusInfo, undefined)
-      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnsupportedRequest)
-    })
-
     await it('should return NotImplemented for PublishFirmwareStatusNotification trigger', () => {
       const request: OCPP20TriggerMessageRequest = {
         requestedMessage: MessageTriggerEnumType.PublishFirmwareStatusNotification,
@@ -791,4 +789,284 @@ await describe('F06 - TriggerMessage', async () => {
       })
     }
   })
+
+  await describe('F06 - TransactionEvent trigger (F06.FR.05/07/08/11)', async () => {
+    let listenerService: OCPP20IncomingRequestService
+    let mockStation: MockChargingStation
+    let requestHandlerMock: ReturnType<typeof mock.fn>
+    let testableService: ReturnType<typeof createTestableIncomingRequestService>
+
+    beforeEach(() => {
+      ;({ mockStation, requestHandlerMock } = createTriggerMessageStation())
+      listenerService = new OCPP20IncomingRequestService()
+      testableService = createTestableIncomingRequestService(listenerService)
+    })
+
+    /**
+     * Seed an active transaction on the connector of the given EVSE and
+     * configure TxUpdatedMeasurands so a triggered TransactionEvent carries a
+     * non-empty meterValue. In the 3-EVSE fixture, EVSE id equals connector id.
+     * @param evseId - EVSE (and connector) id to seed.
+     * @param transactionId - Transaction id to assign.
+     */
+    function seedActiveTransaction (evseId: number, transactionId: string): void {
+      const evseStatus = mockStation.getEvseStatus(evseId)
+      if (evseStatus != null) {
+        evseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as EvseStatus['MeterValues']
+      }
+      setupConnectorWithTransaction(mockStation, evseId, {
+        energyImport: 1234,
+        transactionId,
+      })
+      addConfigurationKey(
+        mockStation,
+        buildConfigKey(
+          OCPP20ComponentName.SampledDataCtrlr,
+          OCPP20RequiredVariableName.TxUpdatedMeasurands
+        ),
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER,
+        undefined,
+        { save: false }
+      )
+    }
+
+    /**
+     * Emit an Accepted TRIGGER_MESSAGE(TransactionEvent) and return the captured
+     * TransactionEvent payloads sent to the request handler.
+     * @param evse - Optional EVSE scope for the trigger request.
+     * @returns The captured TransactionEvent request payloads.
+     */
+    function emitTransactionEventTrigger (
+      evse?: OCPP20TriggerMessageRequest['evse']
+    ): OCPP20TransactionEventOptions[] {
+      const request: OCPP20TriggerMessageRequest = {
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+        ...(evse != null && { evse }),
+      }
+      const response: OCPP20TriggerMessageResponse = {
+        status: TriggerMessageStatusEnumType.Accepted,
+      }
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+        mockStation,
+        request,
+        response
+      )
+      return requestHandlerMock.mock.calls
+        .map(
+          call =>
+            call.arguments as [
+              unknown,
+              OCPP20RequestCommand,
+              OCPP20TransactionEventOptions,
+              RequestParams
+            ]
+        )
+        .filter(([, command]) => command === OCPP20RequestCommand.TRANSACTION_EVENT)
+        .map(([, , payload]) => payload)
+    }
+
+    await it('should register a single TRIGGER_MESSAGE event listener in the constructor', () => {
+      assert.strictEqual(
+        listenerService.listenerCount(OCPP20IncomingRequestCommand.TRIGGER_MESSAGE),
+        1
+      )
+    })
+
+    await it('should return Accepted when a specified EVSE has an active transaction (F06.FR.05)', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        evse: { id: 1 },
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+      assert.strictEqual(response.statusInfo, undefined)
+    })
+
+    await it('should treat evse.id === 0 as broadcast scope and Accept when a transaction is active (F06.FR.11)', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        evse: { id: 0 },
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+    })
+
+    await it('should return Accepted when EVSE is omitted and a transaction is active (F06.FR.11)', () => {
+      seedActiveTransaction(2, 'txn-evse-2')
+
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Accepted)
+    })
+
+    await it('should return Rejected (not NotImplemented) when no transaction is active (F06.FR.08)', () => {
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+      if (response.statusInfo == null) {
+        assert.fail('Expected statusInfo to be defined')
+      }
+      assert.strictEqual(response.statusInfo.reasonCode, ReasonCodeEnumType.TxNotFound)
+      if (response.statusInfo.additionalInfo == null) {
+        assert.fail('Expected additionalInfo to be defined')
+      }
+      assert.strictEqual(
+        response.statusInfo.additionalInfo,
+        'No active transaction to trigger a TransactionEvent for'
+      )
+    })
+
+    await it('should return Rejected when the specified EVSE has no active transaction', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        evse: { id: 2 },
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+    })
+
+    await it('should return Rejected with UnknownEvse for a non-existent EVSE', () => {
+      const response = testableService.handleRequestTriggerMessage(mockStation, {
+        evse: { id: 999 },
+        requestedMessage: MessageTriggerEnumType.TransactionEvent,
+      })
+
+      assert.strictEqual(response.status, TriggerMessageStatusEnumType.Rejected)
+      assert.strictEqual(response.statusInfo?.reasonCode, ReasonCodeEnumType.UnknownEvse)
+    })
+
+    await it('should emit one TransactionEvent(Updated, Trigger) for a specified EVSE (F06.FR.07)', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+
+      const payloads = emitTransactionEventTrigger({ id: 1 })
+
+      assert.strictEqual(payloads.length, 1)
+      const payload = payloads[0]
+      assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
+      assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
+      assert.strictEqual(payload.transactionId, 'txn-evse-1')
+      assert.strictEqual(payload.connectorId, 1)
+      const meterValue = payload.meterValue
+      if (meterValue == null) {
+        assert.fail('Expected meterValue with TxUpdatedMeasurands to be defined')
+      }
+      assert.strictEqual(meterValue.length, 1)
+      assert.strictEqual(
+        meterValue[0].sampledValue[0].measurand,
+        OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+      )
+      assert.strictEqual(
+        meterValue[0].sampledValue[0].context,
+        OCPP20ReadingContextEnumType.TRIGGER
+      )
+      // F06.FR.07: building the emitted params yields transactionInfo.chargingState.
+      const built = buildTransactionEvent(mockStation, payload)
+      assert.strictEqual(built.transactionInfo.chargingState, OCPP20ChargingStateEnumType.Charging)
+    })
+
+    await it('should still emit a TransactionEvent carrying chargingState when no TxUpdated sample is produced (F06.FR.10)', () => {
+      // Active transaction but no TxUpdatedMeasurands configured: buildMeterValue
+      // yields no sampledValue, yet an Accepted trigger MUST still send the event
+      // with the mandatory chargingState (F06.FR.07/FR.10), meterValue omitted.
+      setupConnectorWithTransaction(mockStation, 1, {
+        energyImport: 1234,
+        transactionId: 'txn-evse-1',
+      })
+
+      const payloads = emitTransactionEventTrigger({ id: 1 })
+
+      assert.strictEqual(payloads.length, 1)
+      const payload = payloads[0]
+      assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
+      assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
+      assert.strictEqual(payload.meterValue, undefined)
+      const built = buildTransactionEvent(mockStation, payload)
+      assert.strictEqual(built.transactionInfo.chargingState, OCPP20ChargingStateEnumType.Charging)
+    })
+
+    await it('should still emit a TransactionEvent when the meterValue build throws (F06.FR.10)', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+      // Force buildMeterValue to throw: it resolves the connector via
+      // getConnectorIdByTransactionId (which sendTransactionEvent does not use),
+      // so the build fails while the event send stays reachable.
+      mock.method(mockStation, 'getConnectorIdByTransactionId', () => {
+        throw new Error('meterValue build failure')
+      })
+
+      const payloads = emitTransactionEventTrigger({ id: 1 })
+
+      assert.strictEqual(payloads.length, 1)
+      const payload = payloads[0]
+      assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
+      assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
+      assert.strictEqual(payload.meterValue, undefined)
+    })
+
+    await it('should emit a TransactionEvent for every EVSE with an active transaction when EVSE is omitted (F06.FR.11)', () => {
+      seedActiveTransaction(1, 'txn-evse-1')
+      seedActiveTransaction(3, 'txn-evse-3')
+
+      const payloads = emitTransactionEventTrigger()
+
+      assert.strictEqual(payloads.length, 2)
+      const observedTransactions = new Set(payloads.map(payload => payload.transactionId))
+      assert.deepStrictEqual([...observedTransactions].sort(), ['txn-evse-1', 'txn-evse-3'])
+      for (const payload of payloads) {
+        assert.strictEqual(payload.eventType, OCPP20TransactionEventEnumType.Updated)
+        assert.strictEqual(payload.triggerReason, OCPP20TriggerReasonEnumType.Trigger)
+      }
+    })
+
+    await it('should emit no TransactionEvent when no transaction is active', () => {
+      const payloads = emitTransactionEventTrigger()
+
+      assert.strictEqual(payloads.length, 0)
+    })
+
+    await it('should handle a request handler rejection gracefully via errorHandler', async () => {
+      const rejectingMock = mock.fn(async () => Promise.reject(new Error('test error')))
+      const { station: rejectStation } = createMockChargingStation({
+        baseName: TEST_CHARGING_STATION_BASE_NAME,
+        connectorsCount: 3,
+        evseConfiguration: { evsesCount: 3 },
+        ocppRequestService: {
+          requestHandler: rejectingMock,
+        },
+        stationInfo: {
+          ocppVersion: OCPPVersion.VERSION_201,
+        },
+        websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+      })
+      const rejectEvseStatus = rejectStation.getEvseStatus(1)
+      if (rejectEvseStatus != null) {
+        rejectEvseStatus.MeterValues = [{ unit: 'Wh' }] as unknown as EvseStatus['MeterValues']
+      }
+      setupConnectorWithTransaction(rejectStation, 1, {
+        energyImport: 1234,
+        transactionId: 'txn-evse-1',
+      })
+
+      listenerService.emit(
+        OCPP20IncomingRequestCommand.TRIGGER_MESSAGE,
+        rejectStation,
+        { evse: { id: 1 }, requestedMessage: MessageTriggerEnumType.TransactionEvent },
+        { status: TriggerMessageStatusEnumType.Accepted }
+      )
+
+      await flushMicrotasks()
+
+      assert.strictEqual(rejectingMock.mock.callCount(), 1)
+    })
+  })
 })