type OCPP20InstallCertificateResponse,
type OCPP20LogStatusNotificationRequest,
type OCPP20LogStatusNotificationResponse,
+ OCPP20MeasurandEnumType,
type OCPP20MeterValue,
type OCPP20MeterValuesRequest,
type OCPP20MeterValuesResponse,
convertToIntOrNaN,
ensureError,
generateUUID,
+ getErrorMessage,
handleIncomingRequestError,
isEmpty,
isNotEmptyArray,
)
.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
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
}
}
+ /**
+ * 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'],
import { afterEach, beforeEach, describe, it, mock } from 'node:test'
import type {
+ OCPP20MeterValuesRequest,
OCPP20StatusNotificationRequest,
OCPP20TriggerMessageRequest,
OCPP20TriggerMessageResponse,
import {
MessageTriggerEnumType,
OCPP20IncomingRequestCommand,
+ OCPP20MeasurandEnumType,
+ OCPP20ReadingContextEnumType,
OCPP20RequestCommand,
OCPPVersion,
ReasonCodeEnumType,
name: 'LogStatusNotification',
trigger: MessageTriggerEnumType.LogStatusNotification,
},
- {
- name: 'MeterValues',
- trigger: MessageTriggerEnumType.MeterValues,
- },
]
for (const { name, trigger } of triggerCases) {
})
}
+ 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,