- _none_
+#### Vendor-specific Configuration Keys
+
+- :white_check_mark: AlignedDataSignReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: AlignedDataSignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: MeterPublicKey[ConnectorID] (type: string) (units: -) **(vendor-specific)**
+- :white_check_mark: PublicKeyWithSignedMeterValue (type: optionlist) (units: -) **(vendor-specific)**
+- :white_check_mark: SampledDataSignReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: SampledDataSignStartedReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: SampledDataSignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: StartTxnSampledData (type: memberlist) (units: -) **(vendor-specific)**
+
### Version 2.0.x
#### AlignedDataCtrlr
- :white_check_mark: Measurands (type: memberlist) (units: -)
- :white_check_mark: SendDuringIdle (type: boolean) (units: -)
- :white_check_mark: SignReadings (type: boolean) (units: -)
+- :white_check_mark: SignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)**
- :white_check_mark: TxEndedInterval (type: integer) (units: seconds)
- :white_check_mark: TxEndedMeasurands (type: memberlist) (units: -)
- :white_check_mark: SimulateSignatureVerificationFailure (type: boolean) (units: -) **(vendor-specific)**
+#### FiscalMetering
+
+- :white_check_mark: PublicKey (type: string) (units: -) **(vendor-specific)**
+- :white_check_mark: SigningMethod (type: string) (units: -) **(vendor-specific)**
+
#### ISO15118Ctrlr
- :white_check_mark: CentralContractValidationAllowed (type: boolean) (units: -)
- :white_check_mark: Enabled (type: boolean) (units: -)
- :white_check_mark: RegisterValuesWithoutPhases (type: boolean) (units: -)
- :white_check_mark: SignReadings (type: boolean) (units: -)
+- :white_check_mark: SignStartedReadings (type: boolean) (units: -) **(vendor-specific)**
+- :white_check_mark: SignUpdatedReadings (type: boolean) (units: -) **(vendor-specific)**
- :white_check_mark: TxEndedInterval (type: integer) (units: seconds)
- :white_check_mark: TxEndedMeasurands (type: memberlist) (units: -)
- :white_check_mark: TxStartedMeasurands (type: memberlist) (units: -)
'UIMCP',
'Streamable',
'modelcontextprotocol',
+ // Signed meter values
+ 'OCMF',
+ 'ocmf',
+ 'secp',
+ 'brainpool',
+ 'Eichrecht',
+ 'eichrecht',
+ 'signingmethod',
+ 'SIGNINGMETHOD',
+ 'encodingmethod',
+ 'ENCODINGMETHOD',
+ 'signedmeterdata',
+ 'SIGNEDMETERDATA',
+ 'fiscalmetering',
+ 'FISCALMETERING',
+ 'publickeywithsignedmetervalue',
+ 'PUBLICKEYWITHSIGNEDMETERVALUE',
+ 'sampleddatasignreadings',
+ 'SAMPLEDDATASIGNREADINGS',
],
},
},
--- /dev/null
+{
+ "supervisionUrls": ["ws://localhost:9000"],
+ "supervisionUrlOcppConfiguration": true,
+ "supervisionUrlOcppKey": "CentralSystemAddress",
+ "idTagsFile": "idtags.json",
+ "baseName": "CS-KEBA-OCPP2-SIGNED",
+ "chargePointModel": "KC-P30-ESS400C2-E0R",
+ "chargePointVendor": "Keba AG",
+ "firmwareVersion": "1.10.1",
+ "power": 22080,
+ "powerUnit": "W",
+ "numberOfConnectors": 1,
+ "randomConnectors": false,
+ "amperageLimitationOcppKey": "MaxAvailableCurrent",
+ "amperageLimitationUnit": "mA",
+ "ocppVersion": "2.0.1",
+ "Configuration": {
+ "configurationKey": [
+ {
+ "key": "AuthCtrlr.AuthorizeRemoteStart",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "AuthCtrlr.LocalAuthorizationOffline",
+ "readonly": false,
+ "value": "true"
+ },
+ {
+ "key": "AuthCtrlr.LocalPreAuthorization",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "AuthCtrlr.OfflineTxForUnknownIdEnabled",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "OCPPCommCtrlr.WebSocketPingInterval",
+ "readonly": false,
+ "value": "60"
+ },
+ {
+ "key": "LocalAuthListCtrlr.Enabled",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "OCPPCommCtrlr.MessageAttemptInterval.TransactionEvent",
+ "readonly": false,
+ "value": "20"
+ },
+ {
+ "key": "OCPPCommCtrlr.MessageAttempts.TransactionEvent",
+ "readonly": false,
+ "value": "3"
+ },
+ {
+ "key": "ReservationCtrlr.Enabled",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "ReservationCtrlr.NonEvseSpecific",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "SampledDataCtrlr.TxUpdatedInterval",
+ "readonly": false,
+ "value": "30"
+ },
+ {
+ "key": "SampledDataCtrlr.TxUpdatedMeasurands",
+ "readonly": false,
+ "value": "Energy.Active.Import.Register,Power.Active.Import,Current.Import,Voltage"
+ },
+ {
+ "key": "TxCtrlr.EVConnectionTimeOut",
+ "readonly": false,
+ "value": "180"
+ },
+ {
+ "key": "SampledDataCtrlr.SignReadings",
+ "readonly": false,
+ "value": "true"
+ },
+ {
+ "key": "SampledDataCtrlr.SignStartedReadings",
+ "readonly": false,
+ "value": "true"
+ },
+ {
+ "key": "SampledDataCtrlr.SignUpdatedReadings",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "OCPPCommCtrlr.PublicKeyWithSignedMeterValue",
+ "readonly": false,
+ "value": "EveryMeterValue"
+ },
+ {
+ "key": "FiscalMetering.PublicKey",
+ "readonly": true,
+ "value": "3056301006072a8648ce3d020106052b8104000a03420004460a02ba2766d9c44f023ecc0e4e58644a87add1aadd6317e5fe4dccdb29b163a01d8a6297c84bc530f86431e92f8d46ab37830247c05cbd92fac252929e7f61"
+ }
+ ]
+ },
+ "AutomaticTransactionGenerator": {
+ "enable": false,
+ "minDuration": 30,
+ "maxDuration": 60,
+ "minDelayBetweenTwoTransactions": 15,
+ "maxDelayBetweenTwoTransactions": 30,
+ "probabilityOfStart": 1,
+ "stopAfterHours": 0.3,
+ "requireAuthorize": true
+ },
+ "Evses": {
+ "0": {
+ "Connectors": {
+ "0": {}
+ }
+ },
+ "1": {
+ "Connectors": {
+ "1": {
+ "MeterValues": [
+ {
+ "unit": "V",
+ "measurand": "Voltage"
+ },
+ {
+ "unit": "W",
+ "measurand": "Power.Active.Import"
+ },
+ {
+ "unit": "A",
+ "measurand": "Current.Import"
+ },
+ {
+ "unit": "Wh"
+ }
+ ]
+ }
+ }
+ }
+ }
+}
--- /dev/null
+{
+ "idTagsFile": "idtags.json",
+ "baseName": "CS-BASIC-SIGNED",
+ "chargePointModel": "Simulator simple",
+ "chargePointVendor": "Ovomaltin",
+ "power": 50000,
+ "powerUnit": "W",
+ "powerSharedByConnectors": true,
+ "currentOutType": "DC",
+ "numberOfConnectors": 3,
+ "randomConnectors": false,
+ "Configuration": {
+ "configurationKey": [
+ {
+ "key": "MeterValuesSampledData",
+ "readonly": false,
+ "value": "SoC,Energy.Active.Import.Register,Voltage"
+ },
+ {
+ "key": "MeterValueSampleInterval",
+ "readonly": false,
+ "value": "30"
+ },
+ {
+ "key": "SupportedFeatureProfiles",
+ "readonly": true,
+ "value": "Core,FirmwareManagement,LocalAuthListManagement,SmartCharging,RemoteTrigger,Reservation"
+ },
+ {
+ "key": "LocalAuthListEnabled",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "AuthorizeRemoteTxRequests",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "WebSocketPingInterval",
+ "readonly": false,
+ "value": "60"
+ },
+ {
+ "key": "ReserveConnectorZeroSupported",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "SampledDataSignReadings",
+ "readonly": false,
+ "value": "true"
+ },
+ {
+ "key": "SampledDataSignStartedReadings",
+ "readonly": false,
+ "value": "true"
+ },
+ {
+ "key": "SampledDataSignUpdatedReadings",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "AlignedDataSignReadings",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "AlignedDataSignUpdatedReadings",
+ "readonly": false,
+ "value": "false"
+ },
+ {
+ "key": "PublicKeyWithSignedMeterValue",
+ "readonly": false,
+ "value": "EveryMeterValue"
+ },
+ {
+ "key": "MeterPublicKey1",
+ "readonly": true,
+ "value": "3056301006072a8648ce3d020106052b8104000a03420004460a02ba2766d9c44f023ecc0e4e58644a87add1aadd6317e5fe4dccdb29b163a01d8a6297c84bc530f86431e92f8d46ab37830247c05cbd92fac252929e7f61"
+ },
+ {
+ "key": "StartTxnSampledData",
+ "readonly": false,
+ "value": "Energy.Active.Import.Register"
+ }
+ ]
+ },
+ "AutomaticTransactionGenerator": {
+ "enable": false,
+ "minDuration": 60,
+ "maxDuration": 80,
+ "minDelayBetweenTwoTransactions": 15,
+ "maxDelayBetweenTwoTransactions": 30,
+ "probabilityOfStart": 1,
+ "stopAfterHours": 0.3,
+ "requireAuthorize": true
+ },
+ "Connectors": {
+ "0": {},
+ "1": {
+ "bootStatus": "Available",
+ "MeterValues": [
+ {
+ "unit": "Percent",
+ "context": "Sample.Periodic",
+ "measurand": "SoC",
+ "location": "EV"
+ },
+ {
+ "unit": "Wh",
+ "context": "Sample.Periodic"
+ }
+ ]
+ },
+ "2": {
+ "bootStatus": "Preparing",
+ "MeterValues": [
+ {
+ "unit": "Percent",
+ "context": "Sample.Periodic",
+ "measurand": "SoC",
+ "location": "EV"
+ },
+ {
+ "unit": "Wh",
+ "context": "Sample.Periodic"
+ }
+ ]
+ },
+ "3": {
+ "bootStatus": "Faulted",
+ "MeterValues": [
+ {
+ "unit": "Percent",
+ "context": "Sample.Periodic",
+ "measurand": "SoC",
+ "location": "EV"
+ },
+ {
+ "unit": "Wh",
+ "context": "Sample.Periodic"
+ }
+ ]
+ }
+ }
+}
OCPPVersion,
type OutgoingRequest,
PowerUnits,
+ PublicKeyWithSignedMeterValueEnumType,
RegistrationStatusEnumType,
RequestCommand,
type Reservation,
type StopTransactionReason,
SupervisionUrlDistribution,
SupportedFeatureProfiles,
+ VendorParametersKey,
type Voltage,
WebSocketCloseEventStatusCode,
type WSError,
save: false,
})
}
+ if (getConfigurationKey(this, VendorParametersKey.SampledDataSignReadings) == null) {
+ addConfigurationKey(this, VendorParametersKey.SampledDataSignReadings, 'false', {
+ readonly: false,
+ })
+ }
+ if (getConfigurationKey(this, VendorParametersKey.AlignedDataSignReadings) == null) {
+ addConfigurationKey(this, VendorParametersKey.AlignedDataSignReadings, 'false', {
+ readonly: false,
+ })
+ }
+ if (getConfigurationKey(this, VendorParametersKey.SampledDataSignStartedReadings) == null) {
+ addConfigurationKey(this, VendorParametersKey.SampledDataSignStartedReadings, 'false', {
+ readonly: false,
+ })
+ }
+ if (getConfigurationKey(this, VendorParametersKey.SampledDataSignUpdatedReadings) == null) {
+ addConfigurationKey(this, VendorParametersKey.SampledDataSignUpdatedReadings, 'false', {
+ readonly: false,
+ })
+ }
+ if (getConfigurationKey(this, VendorParametersKey.AlignedDataSignUpdatedReadings) == null) {
+ addConfigurationKey(this, VendorParametersKey.AlignedDataSignUpdatedReadings, 'false', {
+ readonly: false,
+ })
+ }
+ if (getConfigurationKey(this, VendorParametersKey.PublicKeyWithSignedMeterValue) == null) {
+ addConfigurationKey(
+ this,
+ VendorParametersKey.PublicKeyWithSignedMeterValue,
+ PublicKeyWithSignedMeterValueEnumType.Never,
+ {
+ readonly: false,
+ }
+ )
+ }
+ if (getConfigurationKey(this, VendorParametersKey.StartTxnSampledData) == null) {
+ addConfigurationKey(this, VendorParametersKey.StartTxnSampledData, '', {
+ readonly: false,
+ })
+ }
if (
isNotEmptyString(this.stationInfo?.amperageLimitationOcppKey) &&
getConfigurationKey(this, this.stationInfo.amperageLimitationOcppKey) == null
OCPP20ComponentName,
OCPPVersion,
StandardParametersKey,
+ VendorParametersKey,
} from '../types/index.js'
import { logger } from '../utils/index.js'
StandardParametersKey.WebSocketPingInterval,
buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, StandardParametersKey.WebSocketPingInterval),
],
+ [
+ VendorParametersKey.AlignedDataSignReadings,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, StandardParametersKey.SignReadings),
+ ],
+ [
+ VendorParametersKey.AlignedDataSignUpdatedReadings,
+ buildConfigKey(OCPP20ComponentName.AlignedDataCtrlr, VendorParametersKey.SignUpdatedReadings),
+ ],
+ [
+ VendorParametersKey.PublicKeyWithSignedMeterValue,
+ buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ StandardParametersKey.PublicKeyWithSignedMeterValue
+ ),
+ ],
+ [
+ VendorParametersKey.SampledDataSignReadings,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.SignReadings),
+ ],
+ [
+ VendorParametersKey.SampledDataSignStartedReadings,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, VendorParametersKey.SignStartedReadings),
+ ],
+ [
+ VendorParametersKey.SampledDataSignUpdatedReadings,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, VendorParametersKey.SignUpdatedReadings),
+ ],
+ [
+ VendorParametersKey.StartTxnSampledData,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.TxStartedMeasurands),
+ ],
])
const resolveKey = (
delete connectorStatus.transactionEndedMeterValuesSetInterval
}
delete connectorStatus.transactionSeqNo
+ delete connectorStatus.publicKeySentInTransaction
delete connectorStatus.transactionEvseSent
delete connectorStatus.transactionIdTokenSent
delete connectorStatus.transactionDeauthorized
type MeterValueContext,
type MeterValuePhase,
type OCPP16BootNotificationRequest,
+ type OCPP16MeterValueContext,
+ OCPP16MeterValueFormat,
+ OCPP16MeterValueLocation,
+ OCPP16MeterValueMeasurand,
type OCPP16SampledValue,
+ type OCPP16SignedMeterValue,
type SampledValueTemplate,
} from '../../../types/index.js'
import { resolveSampledValueFields } from '../OCPPServiceUtils.js'
...(fields.phase != null && { phase: fields.phase }),
} as OCPP16SampledValue
}
+
+/**
+ * Builds a signed OCPP 1.6 sampled value from a SignedMeterValue payload.
+ * Per OCA Application Note v1.0 section 3.2.1, the value field contains
+ * the JSON-serialized SignedMeterValueType.
+ * @param context - The reading context for the signed value
+ * @param signedData - The signed meter value data
+ * @returns Signed OCPP 1.6 sampled value with format=SignedData
+ */
+export const buildSignedOCPP16SampledValue = (
+ context: OCPP16MeterValueContext,
+ signedData: OCPP16SignedMeterValue
+): OCPP16SampledValue => ({
+ context,
+ format: OCPP16MeterValueFormat.SIGNED_DATA,
+ location: OCPP16MeterValueLocation.OUTLET,
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ value: JSON.stringify(signedData),
+})
commandParams as unknown as OCPP16StatusNotificationRequest
) as unknown as Request
case OCPP16RequestCommand.STOP_TRANSACTION:
- chargingStation.stationInfo?.transactionDataMeterValues === true &&
+ ;(chargingStation.stationInfo?.transactionDataMeterValues === true ||
+ OCPP16ServiceUtils.isSigningEnabled(chargingStation)) &&
(connectorId = chargingStation.getConnectorIdByTransactionId(
commandParams.transactionId as number
))
commandParams.transactionId as number,
true
)
- return {
- idTag: chargingStation.getTransactionIdTag(commandParams.transactionId as number),
- meterStop: energyActiveImportRegister,
- timestamp: new Date(),
- ...(chargingStation.stationInfo?.transactionDataMeterValues === true &&
- connectorId != null && {
- transactionData: OCPP16ServiceUtils.buildTransactionDataMeterValues(
- chargingStation.getConnectorStatus(connectorId)
- ?.transactionBeginMeterValue as OCPP16MeterValue,
- OCPP16ServiceUtils.buildTransactionEndMeterValue(
- chargingStation,
- connectorId,
- energyActiveImportRegister
+ {
+ let transactionData: OCPP16MeterValue[] | undefined
+ const transactionDataExplicit =
+ chargingStation.stationInfo?.transactionDataMeterValues === true
+ const signingForcesTransactionData = OCPP16ServiceUtils.isSigningEnabled(chargingStation)
+ if ((transactionDataExplicit || signingForcesTransactionData) && connectorId != null) {
+ if (transactionDataExplicit) {
+ transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+ chargingStation.getConnectorStatus(connectorId)
+ ?.transactionBeginMeterValue as OCPP16MeterValue,
+ OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ connectorId,
+ energyActiveImportRegister
+ )
)
- ),
- }),
- ...commandParams,
- } as unknown as Request
+ } else {
+ try {
+ transactionData = OCPP16ServiceUtils.buildTransactionDataMeterValues(
+ chargingStation.getConnectorStatus(connectorId)
+ ?.transactionBeginMeterValue as OCPP16MeterValue,
+ OCPP16ServiceUtils.buildTransactionEndMeterValue(
+ chargingStation,
+ connectorId,
+ energyActiveImportRegister
+ )
+ )
+ } catch (error) {
+ logger.warn(
+ `${chargingStation.logPrefix()} ${moduleName}.buildRequestPayload: Failed to build signed transaction data meter values for StopTransaction:`,
+ error
+ )
+ }
+ }
+ }
+ return {
+ idTag: chargingStation.getTransactionIdTag(commandParams.transactionId as number),
+ meterStop: energyActiveImportRegister,
+ timestamp: new Date(),
+ ...(transactionData != null && { transactionData }),
+ ...commandParams,
+ } as unknown as Request
+ }
default: {
// OCPPError usage here is debatable: it's an error in the OCPP stack but not targeted to sendError().
const errorMsg = `Unsupported OCPP command ${commandName as string} for payload building`
import {
type ChargingStation,
+ getConfigurationKey,
hasFeatureProfile,
hasReservationExpired,
} from '../../../charging-station/index.js'
OCPP16MeterValueMeasurand,
OCPP16MeterValueUnit,
OCPP16RequestCommand,
+ type OCPP16SampledValue,
OCPP16StandardParametersKey,
type OCPP16StatusNotificationRequest,
OCPP16StopTransactionReason,
type OCPP16SupportedFeatureProfiles,
+ OCPP16VendorParametersKey,
OCPPVersion,
RequestCommand,
type StartTransactionRequest,
getSampledValueTemplate,
PayloadValidatorOptions,
} from '../OCPPServiceUtils.js'
+import { generateSignedMeterData } from '../OCPPSignedMeterDataGenerator.js'
+import {
+ parsePublicKeyWithSignedMeterValue,
+ shouldIncludePublicKey,
+ type SignedSampledValueResult,
+ type SigningConfig,
+} from '../OCPPSignedMeterValueUtils.js'
import { OCPP16Constants } from './OCPP16Constants.js'
-import { buildOCPP16SampledValue } from './OCPP16RequestBuilders.js'
+import { buildOCPP16SampledValue, buildSignedOCPP16SampledValue } from './OCPP16RequestBuilders.js'
const moduleName = 'OCPP16ServiceUtils'
)
)
}
+ if (
+ OCPP16ServiceUtils.isSigningEnabled(chargingStation) &&
+ getConfigurationKey(chargingStation, OCPP16VendorParametersKey.SampledDataSignStartedReadings)
+ ?.value === 'true'
+ ) {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const transactionId = connectorStatus?.transactionId ?? 0
+ const publicKeySentInTransaction = connectorStatus?.publicKeySentInTransaction ?? false
+ const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
+ chargingStation,
+ connectorId
+ )
+ const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+ signingCfg,
+ meterStart ?? 0,
+ OCPP16MeterValueContext.TRANSACTION_BEGIN,
+ transactionId,
+ publicKeySentInTransaction,
+ meterValue.timestamp
+ )
+ meterValue.sampledValue.push(signedResult.sampledValue)
+ if (signedResult.publicKeyIncluded && connectorStatus != null) {
+ connectorStatus.publicKeySentInTransaction = true
+ }
+ }
return meterValue
}
OCPP16MeterValueContext.TRANSACTION_END
)
)
+ if (OCPP16ServiceUtils.isSigningEnabled(chargingStation)) {
+ const connectorStatus = chargingStation.getConnectorStatus(connectorId)
+ const transactionId = connectorStatus?.transactionId ?? 0
+ const publicKeySentInTransaction = connectorStatus?.publicKeySentInTransaction ?? false
+ const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
+ chargingStation,
+ connectorId
+ )
+ const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+ signingCfg,
+ meterStop ?? 0,
+ OCPP16MeterValueContext.TRANSACTION_END,
+ transactionId,
+ publicKeySentInTransaction,
+ meterValue.timestamp
+ )
+ meterValue.sampledValue.push(signedResult.sampledValue)
+ if (signedResult.publicKeyIncluded && connectorStatus != null) {
+ connectorStatus.publicKeySentInTransaction = true
+ }
+ }
return meterValue
}
return key.visible
}
+ /**
+ * @param chargingStation - Target charging station
+ * @returns Whether signed meter value generation is enabled (SampledDataSignReadings=true)
+ */
+ public static isSigningEnabled (chargingStation: ChargingStation): boolean {
+ return (
+ getConfigurationKey(chargingStation, OCPP16VendorParametersKey.SampledDataSignReadings)
+ ?.value === 'true'
+ )
+ }
+
/**
* Stops a transaction remotely on the given connector.
* @param chargingStation - Target charging station
connectorStatus.transactionUpdatedMeterValuesSetInterval = setInterval(() => {
const transactionId = convertToInt(connectorStatus.transactionId)
const meterValue = buildMeterValue(chargingStation, transactionId, interval)
+ if (
+ OCPP16ServiceUtils.isSigningEnabled(chargingStation) &&
+ getConfigurationKey(
+ chargingStation,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings
+ )?.value === 'true'
+ ) {
+ const energyWh = chargingStation.getEnergyActiveImportRegisterByTransactionId(
+ connectorStatus.transactionId
+ )
+ const publicKeySentInTransaction = connectorStatus.publicKeySentInTransaction ?? false
+ const signingCfg = OCPP16ServiceUtils.readSigningConfigForConnector(
+ chargingStation,
+ connectorId
+ )
+ 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
+ }
+ }
chargingStation.ocppRequestService
.requestHandler<MeterValuesRequest, MeterValuesResponse>(
chargingStation,
}
}
+ private static buildSignedSampledValue (
+ signingConfig: SigningConfig,
+ meterValue: number,
+ context: OCPP16MeterValueContext,
+ transactionId: number | string,
+ publicKeySentInTransaction: boolean,
+ timestamp: Date
+ ): SignedSampledValueResult<OCPP16SampledValue> {
+ const includePublicKey = shouldIncludePublicKey(
+ signingConfig.publicKeyWithSignedMeterValue,
+ publicKeySentInTransaction
+ )
+
+ const signedData = generateSignedMeterData(
+ {
+ context,
+ meterSerialNumber: signingConfig.meterSerialNumber,
+ meterValue,
+ timestamp,
+ transactionId,
+ },
+ includePublicKey ? signingConfig.publicKeyHex : undefined
+ )
+ return {
+ publicKeyIncluded: includePublicKey && signingConfig.publicKeyHex != null,
+ sampledValue: buildSignedOCPP16SampledValue(context, signedData),
+ }
+ }
+
private static readonly composeChargingSchedule = (
chargingSchedule: OCPP16ChargingSchedule,
compositeInterval: Interval
return chargingSchedule
}
}
+
+ private static readSigningConfigForConnector (
+ chargingStation: ChargingStation,
+ connectorId: number
+ ): SigningConfig {
+ return {
+ meterSerialNumber: chargingStation.stationInfo?.meterSerialNumber ?? 'SIMULATOR',
+ publicKeyHex: getConfigurationKey(
+ chargingStation,
+ `${OCPP16VendorParametersKey.MeterPublicKey}${connectorId.toString()}`
+ )?.value,
+ publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
+ getConfigurationKey(
+ chargingStation,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue
+ )?.value
+ ),
+ }
+ }
}
type MeterValuePhase,
OCPP16StopTransactionReason,
type OCPP20BootNotificationRequest,
+ OCPP20MeasurandEnumType,
OCPP20ReasonEnumType,
type OCPP20SampledValue,
OCPP20TriggerReasonEnumType,
type SampledValueTemplate,
} from '../../../types/index.js'
import { resolveSampledValueFields } from '../OCPPServiceUtils.js'
+import {
+ generateSignedMeterData,
+ type SignedMeterDataParams,
+} from '../OCPPSignedMeterDataGenerator.js'
+import {
+ type SampledValueSigningConfig,
+ shouldIncludePublicKey,
+ type SignedSampledValueResult,
+} from '../OCPPSignedMeterValueUtils.js'
export const buildOCPP20BootNotificationRequest = (
stationInfo: ChargingStationInfo,
* @param value - The measured value.
* @param context - The reading context.
* @param phase - The phase of the measurement.
- * @returns The built OCPP 2.0 sampled value.
+ * @param signingConfig - Optional signing configuration for generating signedMeterValue.
+ * @returns The built OCPP 2.0 sampled value with signing metadata.
*/
export function buildOCPP20SampledValue (
sampledValueTemplate: SampledValueTemplate,
value: number,
context?: MeterValueContext,
- phase?: MeterValuePhase
-): OCPP20SampledValue {
+ phase?: MeterValuePhase,
+ signingConfig?: SampledValueSigningConfig
+): SignedSampledValueResult<OCPP20SampledValue> {
const fields = resolveSampledValueFields(sampledValueTemplate, value, context, phase)
- return {
+ const sampledValue: OCPP20SampledValue = {
context: fields.context,
location: fields.location,
measurand: fields.measurand,
value: fields.value,
...(fields.phase != null && { phase: fields.phase }),
} as OCPP20SampledValue
+
+ let publicKeyIncluded = false
+
+ if (
+ signingConfig?.enabled === true &&
+ fields.measurand === OCPP20MeasurandEnumType.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) {
+ const includePublicKey = shouldIncludePublicKey(
+ signingConfig.publicKeyWithSignedMeterValue,
+ signingConfig.publicKeySentInTransaction
+ )
+ const signedMeterDataParams: SignedMeterDataParams = {
+ context: fields.context,
+ meterSerialNumber: signingConfig.meterSerialNumber,
+ meterValue: fields.value,
+ meterValueUnit: fields.unit,
+ timestamp: signingConfig.timestamp ?? new Date(),
+ transactionId: signingConfig.transactionId,
+ }
+ sampledValue.signedMeterValue = {
+ ...generateSignedMeterData(
+ signedMeterDataParams,
+ includePublicKey ? signingConfig.publicKeyHex : undefined
+ ),
+ }
+ publicKeyIncluded = includePublicKey && signingConfig.publicKeyHex != null
+ }
+
+ return { publicKeyIncluded, sampledValue }
}
export const mapStopReasonToOCPP20 = (
type ConnectorStatus,
ConnectorStatusEnum,
ErrorType,
+ type MeterValue,
OCPP20AuthorizationStatusEnumType,
OCPP20ChargingStateEnumType,
OCPP20ComponentName,
chargingStation,
transactionId
)
+ if (isNotEmptyArray(startedMeterValues) && connectorStatus != null) {
+ connectorStatus.transactionBeginMeterValue = startedMeterValues[0] as MeterValue
+ }
const response = await OCPP20ServiceUtils.sendTransactionEvent(
chargingStation,
OCPP20TransactionEventEnumType.Started,
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
const endedMeterValues = (connectorStatus?.transactionEndedMeterValues ??
[]) as OCPP20MeterValue[]
+ const beginMeterValue = connectorStatus?.transactionBeginMeterValue as
+ | OCPP20MeterValue
+ | undefined
try {
const measurandsKey = buildConfigKey(
OCPP20ReadingContextEnumType.TRANSACTION_END
) as OCPP20MeterValue
if (isNotEmptyArray(finalMeterValue.sampledValue)) {
- return [...endedMeterValues, finalMeterValue]
+ return [
+ ...(beginMeterValue != null ? [beginMeterValue] : []),
+ ...endedMeterValues,
+ finalMeterValue,
+ ]
}
} catch (error) {
logger.warn(
`${chargingStation.logPrefix()} ${moduleName}.buildTransactionEndedMeterValues: ${(error as Error).message}`
)
}
- return isNotEmptyArray(endedMeterValues) ? endedMeterValues : []
+ const meterValues: OCPP20MeterValue[] = [
+ ...(beginMeterValue != null ? [beginMeterValue] : []),
+ ...endedMeterValues,
+ ]
+ return isNotEmptyArray(meterValues) ? meterValues : []
}
private static readVariableAsIntervalMs (
OCPP20UnitEnumType,
OCPP20VendorVariableName,
PersistenceEnumType,
+ PublicKeyWithSignedMeterValueEnumType,
ReasonCodeEnumType,
type VariableName,
} from '../../../types/index.js'
supportedAttributes: [AttributeEnumType.Actual],
variable: 'SendDuringIdle',
},
- [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, 'SignReadings')]: {
- component: OCPP20ComponentName.AlignedDataCtrlr,
- dataType: DataEnumType.boolean,
- defaultValue: 'false',
- description:
- 'If set to true, the Charging Station SHALL include signed meter values in the SampledValueType in the MeterValuesRequest to the CSMS.',
- mutability: MutabilityEnumType.ReadWrite,
- persistence: PersistenceEnumType.Persistent,
- supportedAttributes: [AttributeEnumType.Actual],
- variable: 'SignReadings',
- },
+ [buildRegistryKey(OCPP20ComponentName.AlignedDataCtrlr, OCPP20OptionalVariableName.SignReadings)]:
+ {
+ component: OCPP20ComponentName.AlignedDataCtrlr,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description:
+ 'If set to true, the Charging Station SHALL include signed meter values in the SampledValueType in the MeterValuesRequest to the CSMS.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20OptionalVariableName.SignReadings,
+ },
[buildRegistryKey(
OCPP20ComponentName.AlignedDataCtrlr,
OCPP20RequiredVariableName.AlignedDataInterval
supportedAttributes: [AttributeEnumType.Actual],
variable: OCPP20RequiredVariableName.TxEndedMeasurands,
},
+ [buildRegistryKey(
+ OCPP20ComponentName.AlignedDataCtrlr,
+ OCPP20VendorVariableName.SignUpdatedReadings
+ )]: {
+ component: OCPP20ComponentName.AlignedDataCtrlr,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description:
+ 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Updated) for those measurands configured in AlignedDataTxUpdatedMeasurands. Only has effect if AlignedDataCtrlr.SignReadings is true.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.SignUpdatedReadings,
+ vendorSpecific: true,
+ },
// AuthCacheCtrlr Component
[buildRegistryKey(OCPP20ComponentName.AuthCacheCtrlr, 'Available')]: {
supportedAttributes: [AttributeEnumType.Actual],
variable: 'DisableRemoteAuthorization',
},
-
[buildRegistryKey(OCPP20ComponentName.AuthCtrlr, 'OfflineTxForUnknownIdEnabled')]: {
component: OCPP20ComponentName.AuthCtrlr,
dataType: DataEnumType.boolean,
variable: OCPP20RequiredVariableName.TimeSource,
},
+ // DeviceDataCtrlr Component
// Value size family: ValueSize (broadest), ConfigurationValueSize (affects setting), ReportingValueSize (affects reporting). Simulator sets same absolute cap; truncate occurs at reporting step.
[buildRegistryKey(
OCPP20ComponentName.DeviceDataCtrlr,
unit: OCPP20UnitEnumType.CHARS,
variable: OCPP20OptionalVariableName.ReportingValueSize,
},
-
[buildRegistryKey(OCPP20ComponentName.DeviceDataCtrlr, OCPP20OptionalVariableName.ValueSize)]: {
component: OCPP20ComponentName.DeviceDataCtrlr,
dataType: DataEnumType.integer,
unit: OCPP20UnitEnumType.CHARS,
variable: OCPP20OptionalVariableName.ValueSize,
},
-
[buildRegistryKey(
OCPP20ComponentName.DeviceDataCtrlr,
OCPP20RequiredVariableName.BytesPerMessage
vendorSpecific: true,
},
+ // FiscalMetering Component
+ [buildRegistryKey(OCPP20ComponentName.FiscalMetering, OCPP20VendorVariableName.PublicKey)]: {
+ component: OCPP20ComponentName.FiscalMetering,
+ dataType: DataEnumType.string,
+ defaultValue: '',
+ description:
+ 'Public key for the fiscal meter connected to the EVSE. The raw hex key value; the OCA oca:base16:asn1:<key> encoding and Base64 are applied by the signing implementation.',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.PublicKey,
+ vendorSpecific: true,
+ },
+ [buildRegistryKey(OCPP20ComponentName.FiscalMetering, OCPP20VendorVariableName.SigningMethod)]: {
+ component: OCPP20ComponentName.FiscalMetering,
+ dataType: DataEnumType.string,
+ defaultValue: 'ECDSA-secp256r1-SHA256',
+ description:
+ 'Method used to create the digital signature for signed meter values. See OCA Application Note v1.0 Table 12 for valid values.',
+ mutability: MutabilityEnumType.ReadOnly,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.SigningMethod,
+ vendorSpecific: true,
+ },
+
// ISO15118Ctrlr Component
[buildRegistryKey(OCPP20ComponentName.ISO15118Ctrlr, 'CentralContractValidationAllowed')]: {
component: OCPP20ComponentName.ISO15118Ctrlr,
supportedAttributes: [AttributeEnumType.Actual],
variable: 'FieldLength',
},
- [buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue')]: {
- component: OCPP20ComponentName.OCPPCommCtrlr,
- dataType: DataEnumType.OptionList,
- description:
- 'This Configuration Variable can be used to configure whether a public key needs to be sent with a signed meter value.',
- mutability: MutabilityEnumType.ReadWrite,
- persistence: PersistenceEnumType.Persistent,
- supportedAttributes: [AttributeEnumType.Actual],
- variable: 'PublicKeyWithSignedMeterValue',
- },
[buildRegistryKey(OCPP20ComponentName.OCPPCommCtrlr, 'QueueAllMessages')]: {
component: OCPP20ComponentName.OCPPCommCtrlr,
dataType: DataEnumType.boolean,
unit: OCPP20UnitEnumType.SECONDS,
variable: OCPP20OptionalVariableName.HeartbeatInterval,
},
+ [buildRegistryKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue
+ )]: {
+ component: OCPP20ComponentName.OCPPCommCtrlr,
+ dataType: DataEnumType.OptionList,
+ defaultValue: PublicKeyWithSignedMeterValueEnumType.Never,
+ description:
+ 'This Configuration Variable can be used to configure whether a public key needs to be sent with a signed meter value.',
+ enumeration: Object.values(PublicKeyWithSignedMeterValueEnumType),
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20OptionalVariableName.PublicKeyWithSignedMeterValue,
+ },
[buildRegistryKey(
OCPP20ComponentName.OCPPCommCtrlr,
OCPP20OptionalVariableName.RetryBackOffRandomRange
supportedAttributes: [AttributeEnumType.Actual],
variable: 'RegisterValuesWithoutPhases',
},
- [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings')]: {
- component: OCPP20ComponentName.SampledDataCtrlr,
- dataType: DataEnumType.boolean,
- defaultValue: 'false',
- description:
- 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest to the CSMS',
- mutability: MutabilityEnumType.ReadWrite,
- persistence: PersistenceEnumType.Persistent,
- supportedAttributes: [AttributeEnumType.Actual],
- variable: 'SignReadings',
- },
[buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20MeasurandEnumType.CURRENT_IMPORT)]:
{
component: OCPP20ComponentName.SampledDataCtrlr,
variable: OCPP20MeasurandEnumType.VOLTAGE,
vendorSpecific: true,
},
+ [buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20OptionalVariableName.SignReadings)]:
+ {
+ component: OCPP20ComponentName.SampledDataCtrlr,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description:
+ 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest to the CSMS',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20OptionalVariableName.SignReadings,
+ },
[buildRegistryKey(OCPP20ComponentName.SampledDataCtrlr, OCPP20RequiredVariableName.Enabled)]: {
component: OCPP20ComponentName.SampledDataCtrlr,
dataType: DataEnumType.boolean,
supportedAttributes: [AttributeEnumType.Actual],
variable: OCPP20RequiredVariableName.TxUpdatedMeasurands,
},
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20VendorVariableName.SignStartedReadings
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description:
+ 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Started or Updated) to the CSMS for those measurands configured in SampledDataTxStartedMeasurands.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.SignStartedReadings,
+ vendorSpecific: true,
+ },
+ [buildRegistryKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ OCPP20VendorVariableName.SignUpdatedReadings
+ )]: {
+ component: OCPP20ComponentName.SampledDataCtrlr,
+ dataType: DataEnumType.boolean,
+ defaultValue: 'false',
+ description:
+ 'If set to true, the Charging Station SHALL include signed meter values in the TransactionEventRequest (Updated) to the CSMS for those measurands configured in SampledDataTxUpdatedMeasurands. This setting only has an effect if SampledDataCtrlr.SignReadings is set to true.',
+ mutability: MutabilityEnumType.ReadWrite,
+ persistence: PersistenceEnumType.Persistent,
+ supportedAttributes: [AttributeEnumType.Actual],
+ variable: OCPP20VendorVariableName.SignUpdatedReadings,
+ vendorSpecific: true,
+ },
// SecurityCtrlr Component
[buildRegistryKey(OCPP20ComponentName.SecurityCtrlr, 'AdditionalRootCertificateCheck')]: {
import type { BootReasonEnumType } from '../../types/index.js'
+import { buildConfigKey } from '../../charging-station/ConfigurationKeyUtils.js'
import { type ChargingStation, getConfigurationKey } from '../../charging-station/index.js'
import { BaseError, OCPPError } from '../../exception/index.js'
import {
MeterValueMeasurand,
MeterValuePhase,
MeterValueUnit,
+ OCPP20ComponentName,
+ OCPP20ReadingContextEnumType,
OCPPVersion,
RequestCommand,
type SampledValue,
type SampledValueTemplate,
StandardParametersKey,
+ VendorParametersKey,
} from '../../types/index.js'
import {
ACElectricUtils,
buildOCPP20SampledValue,
} from './2.0/OCPP20RequestBuilders.js'
import { OCPPConstants } from './OCPPConstants.js'
+import {
+ parsePublicKeyWithSignedMeterValue,
+ type SampledValueSigningConfig,
+} from './OCPPSignedMeterValueUtils.js'
const moduleName = 'OCPPServiceUtils'
context?: MeterValueContext,
phase?: MeterValuePhase
) => SampledValue
+ let signingConfig: SampledValueSigningConfig | undefined
+ const signingState = { publicKeyIncluded: false }
switch (chargingStation.stationInfo?.ocppVersion) {
case OCPPVersion.VERSION_16:
if (connectorId == null) {
RequestCommand.METER_VALUES
)
}
- buildVersionedSampledValue = buildOCPP20SampledValue
+ {
+ const signReadings =
+ getConfigurationKey(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, StandardParametersKey.SignReadings)
+ )?.value === 'true'
+
+ if (signReadings) {
+ let signingEnabledForContext = true
+ if (context === OCPP20ReadingContextEnumType.TRANSACTION_BEGIN) {
+ signingEnabledForContext =
+ getConfigurationKey(
+ chargingStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ VendorParametersKey.SignStartedReadings
+ )
+ )?.value === 'true'
+ } else if (
+ context == null ||
+ context === OCPP20ReadingContextEnumType.SAMPLE_PERIODIC ||
+ context === OCPP20ReadingContextEnumType.SAMPLE_CLOCK
+ ) {
+ signingEnabledForContext =
+ getConfigurationKey(
+ chargingStation,
+ buildConfigKey(
+ OCPP20ComponentName.SampledDataCtrlr,
+ VendorParametersKey.SignUpdatedReadings
+ )
+ )?.value === 'true'
+ }
+
+ if (signingEnabledForContext) {
+ const publicKeyWithSignedMeterValueStr = getConfigurationKey(
+ chargingStation,
+ buildConfigKey(
+ OCPP20ComponentName.OCPPCommCtrlr,
+ StandardParametersKey.PublicKeyWithSignedMeterValue
+ )
+ )?.value
+ const publicKeyHex = getConfigurationKey(
+ chargingStation,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, VendorParametersKey.PublicKey)
+ )?.value
+ signingConfig = {
+ enabled: true,
+ meterSerialNumber: chargingStation.stationInfo.meterSerialNumber ?? 'UNKNOWN',
+ publicKeyHex,
+ publicKeySentInTransaction:
+ chargingStation.getConnectorStatus(connectorId)?.publicKeySentInTransaction ??
+ false,
+ publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
+ publicKeyWithSignedMeterValueStr
+ ),
+ transactionId,
+ }
+ }
+ }
+
+ buildVersionedSampledValue = (
+ sampledValueTemplate: SampledValueTemplate,
+ value: number,
+ ctx?: MeterValueContext,
+ phase?: MeterValuePhase
+ ) => {
+ const result = buildOCPP20SampledValue(
+ sampledValueTemplate,
+ value,
+ ctx,
+ phase,
+ signingConfig
+ )
+ if (result.publicKeyIncluded) {
+ signingState.publicKeyIncluded = true
+ }
+ return result.sampledValue
+ }
+ }
break
default:
throw new OCPPError(
}
const connectorStatus = chargingStation.getConnectorStatus(connectorId)
const meterValue: { sampledValue: SampledValue[]; timestamp: Date } = buildEmptyMeterValue()
+ if (signingConfig != null) {
+ signingConfig.timestamp = meterValue.timestamp
+ }
// SoC measurand
const socMeasurand = buildSocMeasurandValue(chargingStation, connectorId, evseId, measurandsKey)
if (socMeasurand != null) {
{ interval }
)
}
+ if (signingState.publicKeyIncluded && connectorStatus != null) {
+ connectorStatus.publicKeySentInTransaction = true
+ }
return meterValue as MeterValue
}
--- /dev/null
+import { createHash } from 'node:crypto'
+
+import { type JsonObject, MeterValueContext, MeterValueUnit } from '../../types/index.js'
+import { roundTo } from '../../utils/index.js'
+
+export interface SignedMeterData extends JsonObject {
+ encodingMethod: string
+ publicKey: string
+ signedMeterData: string
+ signingMethod: string
+}
+
+export interface SignedMeterDataParams {
+ context: MeterValueContext
+ meterSerialNumber: string
+ meterValue: number
+ meterValueUnit?: MeterValueUnit
+ timestamp: Date
+ transactionId: number | string
+}
+
+const SIGNING_METHOD = 'ECDSA-secp256r1-SHA256'
+const ENCODING_METHOD = 'OCMF'
+
+const contextToTxCode = (context: MeterValueContext): string => {
+ switch (context) {
+ case MeterValueContext.TRANSACTION_BEGIN:
+ return 'B'
+ case MeterValueContext.TRANSACTION_END:
+ return 'E'
+ default:
+ return 'P'
+ }
+}
+
+export const buildPublicKeyValue = (hexKey: string): string => {
+ return Buffer.from(`oca:base16:asn1:${hexKey}`).toString('base64')
+}
+
+export const generateSignedMeterData = (
+ params: SignedMeterDataParams,
+ publicKeyHex?: string
+): SignedMeterData => {
+ const txCode = contextToTxCode(params.context)
+ const meterValueKwh =
+ params.meterValueUnit === MeterValueUnit.KILO_WATT_HOUR
+ ? roundTo(params.meterValue, 3)
+ : roundTo(params.meterValue / 1000, 3)
+
+ const ocmfPayload = {
+ FV: '1.0',
+ GI: 'SIMULATOR',
+ GS: params.meterSerialNumber,
+ GV: '1.0',
+ PG: `T${String(params.transactionId)}`,
+ RD: [
+ {
+ RI: '1-0:1.8.0',
+ RT: 'AC',
+ RU: 'kWh',
+ RV: meterValueKwh,
+ ST: 'G',
+ TM: params.timestamp.toISOString(),
+ TX: txCode,
+ },
+ ],
+ }
+
+ const simulatedSignature = createHash('sha256').update(JSON.stringify(ocmfPayload)).digest('hex')
+
+ const ocmfString = `OCMF|${JSON.stringify(ocmfPayload)}|{"SA":"${SIGNING_METHOD}","SD":"${simulatedSignature}"}`
+
+ return {
+ encodingMethod: ENCODING_METHOD,
+ publicKey: publicKeyHex != null ? buildPublicKeyValue(publicKeyHex) : '',
+ signedMeterData: Buffer.from(ocmfString).toString('base64'),
+ signingMethod: '',
+ }
+}
--- /dev/null
+import { BaseError } from '../../exception/index.js'
+import { PublicKeyWithSignedMeterValueEnumType, type SampledValue } from '../../types/index.js'
+
+export interface SampledValueSigningConfig extends SigningConfig {
+ enabled: boolean
+ publicKeySentInTransaction: boolean
+ timestamp?: Date
+ transactionId: number | string
+}
+
+export interface SignedSampledValueResult<T extends SampledValue = SampledValue> {
+ publicKeyIncluded: boolean
+ sampledValue: T
+}
+
+export interface SigningConfig {
+ meterSerialNumber: string
+ publicKeyHex?: string
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType
+}
+
+const PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES = new Set<string>(
+ Object.values(PublicKeyWithSignedMeterValueEnumType)
+)
+
+export const parsePublicKeyWithSignedMeterValue = (
+ value: string | undefined
+): PublicKeyWithSignedMeterValueEnumType =>
+ value != null && PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES.has(value)
+ ? (value as PublicKeyWithSignedMeterValueEnumType)
+ : PublicKeyWithSignedMeterValueEnumType.Never
+
+export const shouldIncludePublicKey = (
+ config: PublicKeyWithSignedMeterValueEnumType,
+ publicKeySentInTransaction: boolean
+): boolean => {
+ switch (config) {
+ case PublicKeyWithSignedMeterValueEnumType.EveryMeterValue:
+ return true
+ case PublicKeyWithSignedMeterValueEnumType.Never:
+ return false
+ case PublicKeyWithSignedMeterValueEnumType.OncePerTransaction:
+ return !publicKeySentInTransaction
+ default:
+ throw new BaseError(
+ `Unsupported PublicKeyWithSignedMeterValueEnumType value: ${String(config)}`
+ )
+ }
+}
stopTransactionOnConnector,
} from './OCPPServiceOperations.js'
export { buildBootNotificationRequest, buildMeterValue } from './OCPPServiceUtils.js'
+export {
+ buildPublicKeyValue,
+ generateSignedMeterData,
+ type SignedMeterData,
+ type SignedMeterDataParams,
+} from './OCPPSignedMeterDataGenerator.js'
+export {
+ parsePublicKeyWithSignedMeterValue,
+ type SampledValueSigningConfig,
+ shouldIncludePublicKey,
+ type SignedSampledValueResult,
+ type SigningConfig,
+} from './OCPPSignedMeterValueUtils.js'
localAuthorizeIdTag?: string
locked?: boolean
MeterValues: SampledValueTemplate[]
+ publicKeySentInTransaction?: boolean
remoteStartId?: number
reservation?: Reservation
status?: ConnectorStatusEnum
export {
OCPP16StandardParametersKey,
OCPP16SupportedFeatureProfiles,
+ OCPP16VendorParametersKey,
} from './ocpp/1.6/Configuration.js'
export { OCPP16DiagnosticsStatus } from './ocpp/1.6/DiagnosticsStatus.js'
export {
type OCPP16MeterValue,
OCPP16MeterValueContext,
+ OCPP16MeterValueFormat,
OCPP16MeterValueLocation,
OCPP16MeterValueMeasurand,
OCPP16MeterValuePhase,
type OCPP16MeterValuesResponse,
OCPP16MeterValueUnit,
type OCPP16SampledValue,
+ type OCPP16SignedMeterValue,
} from './ocpp/1.6/MeterValues.js'
export {
type ChangeConfigurationRequest,
type ConfigurationKeyType,
ConnectorPhaseRotation,
type OCPPConfigurationKey,
+ PublicKeyWithSignedMeterValueEnumType,
StandardParametersKey,
SupportedFeatureProfiles,
VendorParametersKey,
}
export enum OCPP16VendorParametersKey {
+ AlignedDataSignReadings = 'AlignedDataSignReadings',
+ AlignedDataSignUpdatedReadings = 'AlignedDataSignUpdatedReadings',
ConnectionUrl = 'ConnectionUrl',
+ MeterPublicKey = 'MeterPublicKey',
+ PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue',
+ SampledDataSignReadings = 'SampledDataSignReadings',
+ SampledDataSignStartedReadings = 'SampledDataSignStartedReadings',
+ SampledDataSignUpdatedReadings = 'SampledDataSignUpdatedReadings',
+ StartTxnSampledData = 'StartTxnSampledData',
}
TRIGGER = 'Trigger',
}
+export enum OCPP16MeterValueFormat {
+ RAW = 'Raw',
+ SIGNED_DATA = 'SignedData',
+}
+
export enum OCPP16MeterValueLocation {
BODY = 'Body',
CABLE = 'Cable',
WATT_HOUR = 'Wh',
}
-enum OCPP16MeterValueFormat {
- RAW = 'Raw',
- SIGNED_DATA = 'SignedData',
-}
-
export interface OCPP16MeterValue extends JsonObject {
sampledValue: OCPP16SampledValue[]
timestamp: Date
unit?: OCPP16MeterValueUnit
value: string
}
+
+export interface OCPP16SignedMeterValue extends JsonObject {
+ encodingMethod: string
+ publicKey: string
+ signedMeterData: string
+ signingMethod: string
+}
MaxCertificateChainSize = 'MaxCertificateChainSize',
MaxEnergyOnInvalidId = 'MaxEnergyOnInvalidId',
NonEvseSpecific = 'NonEvseSpecific',
+ PublicKeyWithSignedMeterValue = 'PublicKeyWithSignedMeterValue',
ReportingValueSize = 'ReportingValueSize',
RetryBackOffRandomRange = 'RetryBackOffRandomRange',
RetryBackOffRepeatTimes = 'RetryBackOffRepeatTimes',
RetryBackOffWaitMinimum = 'RetryBackOffWaitMinimum',
+ SignReadings = 'SignReadings',
ValueSize = 'ValueSize',
WebSocketPingInterval = 'WebSocketPingInterval',
}
export enum OCPP20VendorVariableName {
CertificatePrivateKey = 'CertificatePrivateKey',
ConnectionUrl = 'ConnectionUrl',
+ PublicKey = 'PublicKey',
+ SigningMethod = 'SigningMethod',
+ SignStartedReadings = 'SignStartedReadings',
+ SignUpdatedReadings = 'SignUpdatedReadings',
SimulateSignatureVerificationFailure = 'SimulateSignatureVerificationFailure',
}
Unknown = 'Unknown',
}
+export enum PublicKeyWithSignedMeterValueEnumType {
+ EveryMeterValue = 'EveryMeterValue',
+ Never = 'Never',
+ OncePerTransaction = 'OncePerTransaction',
+}
+
export type ConfigurationKeyType = StandardParametersKey | string | VendorParametersKey
export interface OCPPConfigurationKey extends JsonObject {
--- /dev/null
+/**
+ * @file Tests for OCPP 1.6 signed meter value support
+ * @module OCPP 1.6 — Signed MeterValues (OCA Application Note v1.0)
+ * @description Verifies signed meter value integration in transaction begin/end functions,
+ * buildSignedOCPP16SampledValue, and periodic meter values.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { buildSignedOCPP16SampledValue } from '../../../../src/charging-station/ocpp/1.6/OCPP16RequestBuilders.js'
+import { OCPP16ServiceUtils } from '../../../../src/charging-station/ocpp/1.6/OCPP16ServiceUtils.js'
+import {
+ type OCPP16MeterValue,
+ OCPP16MeterValueContext,
+ OCPP16MeterValueFormat,
+ OCPP16MeterValueLocation,
+ OCPP16MeterValueMeasurand,
+ OCPP16MeterValueUnit,
+ type OCPP16SampledValue,
+ type OCPP16SignedMeterValue,
+ OCPP16VendorParametersKey,
+ OCPPVersion,
+} from '../../../../src/types/index.js'
+import { standardCleanup, withMockTimers } from '../../../helpers/TestLifecycleHelpers.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+import { createMeterValuesTemplate, upsertConfigurationKey } from './OCPP16TestUtils.js'
+
+await describe('OCPP 1.6 — Signed MeterValues', async () => {
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ // ─── buildSignedOCPP16SampledValue ──────────────────────────────────────
+
+ await describe('buildSignedOCPP16SampledValue', async () => {
+ await it('should return SampledValue with format=SignedData', () => {
+ const signedData: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'dGVzdA==',
+ signingMethod: '',
+ }
+
+ const result = buildSignedOCPP16SampledValue(
+ OCPP16MeterValueContext.TRANSACTION_BEGIN,
+ signedData
+ )
+
+ assert.strictEqual(result.format, OCPP16MeterValueFormat.SIGNED_DATA)
+ })
+
+ await it('should set measurand to Energy.Active.Import.Register', () => {
+ const signedData: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'dGVzdA==',
+ signingMethod: '',
+ }
+
+ const result = buildSignedOCPP16SampledValue(
+ OCPP16MeterValueContext.TRANSACTION_END,
+ signedData
+ )
+
+ assert.strictEqual(result.measurand, OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
+ })
+
+ await it('should set location to Outlet', () => {
+ const signedData: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'dGVzdA==',
+ signingMethod: '',
+ }
+
+ const result = buildSignedOCPP16SampledValue(
+ OCPP16MeterValueContext.SAMPLE_PERIODIC,
+ signedData
+ )
+
+ assert.strictEqual(result.location, OCPP16MeterValueLocation.OUTLET)
+ })
+
+ await it('should set value to JSON-serialized SignedMeterValue', () => {
+ const signedData: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: 'abc123',
+ signedMeterData: 'dGVzdA==',
+ signingMethod: '',
+ }
+
+ const result = buildSignedOCPP16SampledValue(
+ OCPP16MeterValueContext.TRANSACTION_BEGIN,
+ signedData
+ )
+
+ const parsed = JSON.parse(result.value) as OCPP16SignedMeterValue
+ assert.strictEqual(parsed.encodingMethod, 'OCMF')
+ assert.strictEqual(parsed.signingMethod, '')
+ assert.strictEqual(parsed.signedMeterData, 'dGVzdA==')
+ assert.strictEqual(parsed.publicKey, 'abc123')
+ })
+
+ await it('should use the provided context', () => {
+ const signedData: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: '',
+ signedMeterData: 'dGVzdA==',
+ signingMethod: '',
+ }
+
+ const result = buildSignedOCPP16SampledValue(
+ OCPP16MeterValueContext.TRANSACTION_END,
+ signedData
+ )
+
+ assert.strictEqual(result.context, OCPP16MeterValueContext.TRANSACTION_END)
+ })
+ })
+
+ // ─── buildTransactionBeginMeterValue with signing ───────────────────────
+
+ await describe('buildTransactionBeginMeterValue — signing', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+ })
+
+ await it('should not include signed SampledValue when signing is disabled', () => {
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000)
+
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 0)
+ })
+
+ await it('should include signed SampledValue when SampledDataSignReadings and SampledDataSignStartedReadings are true', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.transactionId = 42
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignStartedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'EveryMeterValue'
+ )
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 5000)
+
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 1)
+ assert.strictEqual(
+ signedSamples[0].measurand,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_BEGIN)
+ })
+
+ await it('should not include signed SampledValue when SampledDataSignReadings=true but SampledDataSignStartedReadings=false', () => {
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 1000)
+
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 0)
+ })
+
+ await it('should set publicKeySentInTransaction=true after signing begin value', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.transactionId = 42
+ connectorStatus.publicKeySentInTransaction = false
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignStartedReadings,
+ 'true'
+ )
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'OncePerTransaction'
+ )
+ upsertConfigurationKey(station, `${OCPP16VendorParametersKey.MeterPublicKey}1`, 'abcd1234')
+
+ OCPP16ServiceUtils.buildTransactionBeginMeterValue(station, 1, 0)
+
+ assert.strictEqual(connectorStatus?.publicKeySentInTransaction, true)
+ })
+ })
+
+ // ─── buildTransactionEndMeterValue with signing ─────────────────────────
+
+ await describe('buildTransactionEndMeterValue — signing', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ }
+ })
+
+ await it('should not include signed SampledValue when signing is disabled', () => {
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000)
+
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 0)
+ })
+
+ await it('should include signed SampledValue when SampledDataSignReadings=true', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.transactionId = 42
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 50000)
+
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 1)
+ assert.strictEqual(signedSamples[0].context, OCPP16MeterValueContext.TRANSACTION_END)
+ })
+
+ await it('should produce signed SampledValue with valid JSON containing all 4 fields', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.transactionId = 99
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 25000)
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+
+ assert.strictEqual(signedSamples.length, 1)
+ const parsed = JSON.parse(signedSamples[0].value) as OCPP16SignedMeterValue
+ assert.strictEqual(typeof parsed.encodingMethod, 'string')
+ assert.strictEqual(typeof parsed.signingMethod, 'string')
+ assert.strictEqual(typeof parsed.signedMeterData, 'string')
+ assert.strictEqual(typeof parsed.publicKey, 'string')
+ assert.strictEqual(parsed.encodingMethod, 'OCMF')
+ assert.strictEqual(parsed.signingMethod, '')
+ })
+ })
+
+ await describe('signing — spec edge cases', async () => {
+ await describe('with standard energy config', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionId = 42
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ })
+
+ await it('should not sign non-energy measurands even when signing is enabled', () => {
+ const meterValue = OCPP16ServiceUtils.buildTransactionEndMeterValue(station, 1, 10000)
+ const signedSamples = meterValue.sampledValue.filter(
+ sv => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+
+ assert.strictEqual(signedSamples.length, 1)
+ for (const sv of signedSamples) {
+ assert.strictEqual(sv.measurand, OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER)
+ }
+ })
+ })
+
+ await describe('with transactionDataMeterValues disabled', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ transactionDataMeterValues: false,
+ },
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionId = 42
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ })
+
+ await it('should force transactionData when signing enabled even without energy template', () => {
+ assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), true)
+ assert.strictEqual(station.stationInfo?.transactionDataMeterValues, false)
+ })
+ })
+ })
+
+ await describe('startUpdatedMeterValues — periodic signing', async () => {
+ let station: ChargingStation
+ let capturedMeterValue: OCPP16MeterValue | undefined
+
+ beforeEach(() => {
+ capturedMeterValue = undefined
+ const { station: s } = createMockChargingStation({
+ ocppRequestService: {
+ requestHandler: (...args: unknown[]): Promise<unknown> => {
+ const payload = args[2] as undefined | { meterValue?: OCPP16MeterValue[] }
+ if (payload?.meterValue?.[0] != null) {
+ capturedMeterValue = payload.meterValue[0]
+ }
+ return Promise.resolve()
+ },
+ },
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: {
+ meterSerialNumber: 'SIM-001',
+ ocppVersion: OCPPVersion.VERSION_16,
+ },
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = createMeterValuesTemplate([
+ {
+ measurand: OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: OCPP16MeterValueUnit.WATT_HOUR,
+ value: '0',
+ },
+ ])
+ connectorStatus.transactionStarted = true
+ connectorStatus.transactionId = 42
+ }
+
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+ })
+
+ await it('should include signed SampledValue in periodic meter values when SampledDataSignUpdatedReadings=true', async t => {
+ await withMockTimers(t, ['setInterval'], () => {
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'true'
+ )
+
+ // Act
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
+ t.mock.timers.tick(60000)
+
+ // Assert
+ assert.ok(capturedMeterValue != null)
+ const signedSamples = capturedMeterValue.sampledValue.filter(
+ (sv: OCPP16SampledValue) => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.ok(signedSamples.length > 0)
+ assert.strictEqual(
+ signedSamples[0].measurand,
+ OCPP16MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ )
+ })
+ })
+
+ await it('should not include signed SampledValue when SampledDataSignUpdatedReadings=false', async t => {
+ await withMockTimers(t, ['setInterval'], () => {
+ upsertConfigurationKey(
+ station,
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'false'
+ )
+
+ // Act
+ OCPP16ServiceUtils.startUpdatedMeterValues(station, 1, 60)
+ t.mock.timers.tick(60000)
+
+ // Assert
+ assert.ok(capturedMeterValue != null)
+ const signedSamples = capturedMeterValue.sampledValue.filter(
+ (sv: OCPP16SampledValue) => sv.format === OCPP16MeterValueFormat.SIGNED_DATA
+ )
+ assert.strictEqual(signedSamples.length, 0)
+ })
+ })
+ })
+
+ // ─── isSigningEnabled ───────────────────────────────────────────────────
+
+ await describe('isSigningEnabled', async () => {
+ let station: ChargingStation
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ ocppVersion: OCPPVersion.VERSION_16,
+ stationInfo: { ocppVersion: OCPPVersion.VERSION_16 },
+ })
+ station = s
+ })
+
+ await it('should return false when SampledDataSignReadings is not configured', () => {
+ assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), false)
+ })
+
+ await it('should return true when SampledDataSignReadings=true', () => {
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'true')
+
+ assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), true)
+ })
+
+ await it('should return false when SampledDataSignReadings=false', () => {
+ upsertConfigurationKey(station, OCPP16VendorParametersKey.SampledDataSignReadings, 'false')
+
+ assert.strictEqual(OCPP16ServiceUtils.isSigningEnabled(station), false)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for OCPP 2.0 signed meter value support
+ * @description Verifies signedMeterValue population in sampled value building,
+ * context-dependent sub-switch logic, and public key inclusion.
+ */
+
+import assert from 'node:assert/strict'
+import { afterEach, beforeEach, describe, it } from 'node:test'
+
+import type { ChargingStation } from '../../../../src/charging-station/index.js'
+
+import { addConfigurationKey, buildConfigKey } from '../../../../src/charging-station/index.js'
+import { buildOCPP20SampledValue } from '../../../../src/charging-station/ocpp/2.0/OCPP20RequestBuilders.js'
+import { buildMeterValue } from '../../../../src/charging-station/ocpp/OCPPServiceUtils.js'
+import { type SampledValueSigningConfig } from '../../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js'
+import {
+ MeterValueMeasurand,
+ OCPP20ComponentName,
+ OCPP20ReadingContextEnumType,
+ type OCPP20SampledValue,
+ OCPPVersion,
+ PublicKeyWithSignedMeterValueEnumType,
+ type SampledValueTemplate,
+} from '../../../../src/types/index.js'
+import { Constants } from '../../../../src/utils/index.js'
+import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js'
+import {
+ TEST_CHARGING_STATION_BASE_NAME,
+ TEST_TRANSACTION_ID_STRING,
+} from '../../ChargingStationTestConstants.js'
+import { createMockChargingStation } from '../../ChargingStationTestUtils.js'
+
+const energyTemplate: SampledValueTemplate = {
+ measurand: MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER,
+ unit: 'Wh',
+ value: '0',
+} as unknown as SampledValueTemplate
+
+const voltageTemplate: SampledValueTemplate = {
+ measurand: MeterValueMeasurand.VOLTAGE,
+ unit: 'V',
+ value: '230',
+} as unknown as SampledValueTemplate
+
+await describe('OCPP 2.0 Signed Meter Values', async () => {
+ await describe('buildOCPP20SampledValue with signing config', async () => {
+ await it('should add signedMeterValue when signing is enabled for energy measurand', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeyHex: 'abcdef1234567890',
+ publicKeySentInTransaction: false,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue,
+ transactionId: 'tx-1',
+ }
+
+ const { sampledValue } = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+
+ assert.ok(sampledValue.signedMeterValue != null)
+ assert.strictEqual(sampledValue.signedMeterValue.signingMethod, '')
+ assert.strictEqual(sampledValue.signedMeterValue.encodingMethod, 'OCMF')
+ })
+
+ await it('should not add signedMeterValue when signing is disabled', () => {
+ const { sampledValue } = buildOCPP20SampledValue(energyTemplate, 1500)
+
+ assert.strictEqual(sampledValue.signedMeterValue, undefined)
+ })
+
+ await it('should not add signedMeterValue for non-energy measurands', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeySentInTransaction: false,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue,
+ transactionId: 'tx-1',
+ }
+
+ const { sampledValue } = buildOCPP20SampledValue(
+ voltageTemplate,
+ 230,
+ undefined,
+ undefined,
+ signingConfig
+ )
+
+ assert.strictEqual(sampledValue.signedMeterValue, undefined)
+ })
+
+ await it('should include publicKey when configured as EveryMeterValue', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeyHex: 'abcdef1234567890',
+ publicKeySentInTransaction: false,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.EveryMeterValue,
+ transactionId: 'tx-1',
+ }
+
+ const { sampledValue } = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+
+ assert.notStrictEqual(sampledValue.signedMeterValue?.publicKey, '')
+ })
+
+ await it('should set publicKey to empty string when configured as Never', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeyHex: 'abcdef1234567890',
+ publicKeySentInTransaction: false,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.Never,
+ transactionId: 'tx-1',
+ }
+
+ const { sampledValue } = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+
+ assert.strictEqual(sampledValue.signedMeterValue?.publicKey, '')
+ })
+
+ await it('should set publicKeySentInTransaction after including publicKey with OncePerTransaction', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeyHex: 'abcdef1234567890',
+ publicKeySentInTransaction: false,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ transactionId: 'tx-1',
+ }
+
+ const firstResult = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+ assert.strictEqual(firstResult.publicKeyIncluded, true)
+
+ signingConfig.publicKeySentInTransaction = true
+ const secondResult = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+ assert.strictEqual(secondResult.publicKeyIncluded, false)
+ })
+
+ await it('should not include publicKey on second call with OncePerTransaction', () => {
+ const signingConfig: SampledValueSigningConfig = {
+ enabled: true,
+ meterSerialNumber: 'SIM-METER-001',
+ publicKeyHex: 'abcdef1234567890',
+ publicKeySentInTransaction: true,
+ publicKeyWithSignedMeterValue: PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ transactionId: 'tx-1',
+ }
+
+ const { sampledValue } = buildOCPP20SampledValue(
+ energyTemplate,
+ 1500,
+ undefined,
+ undefined,
+ signingConfig
+ )
+
+ assert.strictEqual(sampledValue.signedMeterValue?.publicKey, '')
+ })
+ })
+
+ await describe('buildMeterValue with OCPP 2.0 signing integration', async () => {
+ let station: ChargingStation
+
+ afterEach(() => {
+ standardCleanup()
+ })
+
+ beforeEach(() => {
+ const { station: s } = createMockChargingStation({
+ baseName: TEST_CHARGING_STATION_BASE_NAME,
+ connectorsCount: 1,
+ evseConfiguration: { evsesCount: 1 },
+ stationInfo: {
+ meterSerialNumber: 'SIM-METER-001',
+ ocppVersion: OCPPVersion.VERSION_201,
+ },
+ websocketPingInterval: Constants.DEFAULT_WS_PING_INTERVAL_SECONDS,
+ })
+ station = s
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = [energyTemplate]
+ connectorStatus.transactionId = TEST_TRANSACTION_ID_STRING
+ }
+ })
+
+ await it('should add signedMeterValue on energy sampled value when SignReadings is true', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+
+ const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0)
+
+ assert.ok(meterValue.sampledValue.length > 0)
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.notStrictEqual(energySampledValue, undefined)
+ assert.ok(energySampledValue?.signedMeterValue != null)
+ assert.strictEqual(energySampledValue.signedMeterValue.signingMethod, '')
+ assert.strictEqual(energySampledValue.signedMeterValue.encodingMethod, 'OCMF')
+ })
+
+ await it('should not add signedMeterValue when SignReadings is not configured', () => {
+ const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0)
+
+ assert.ok(meterValue.sampledValue.length > 0)
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.notStrictEqual(energySampledValue, undefined)
+ assert.strictEqual(energySampledValue?.signedMeterValue, undefined)
+ })
+
+ await it('should not add signedMeterValue when SignReadings is false', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'false'
+ )
+
+ const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0)
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.strictEqual(energySampledValue?.signedMeterValue, undefined)
+ })
+
+ await it('should not sign non-energy measurands even when signing is enabled', () => {
+ const connectorStatus = station.getConnectorStatus(1)
+ if (connectorStatus != null) {
+ connectorStatus.MeterValues = [voltageTemplate, energyTemplate]
+ }
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'TxUpdatedMeasurands'),
+ 'Voltage,Energy.Active.Import.Register'
+ )
+
+ const meterValue = buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0)
+
+ const voltageSampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.VOLTAGE
+ ) as OCPP20SampledValue | undefined
+ assert.strictEqual(
+ voltageSampledValue?.signedMeterValue,
+ undefined,
+ 'Voltage measurand should not have signedMeterValue'
+ )
+ })
+
+ await it('should set publicKeySentInTransaction on connector status with OncePerTransaction', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ 'abcdef1234567890'
+ )
+
+ const connectorStatus = station.getConnectorStatus(1)
+ assert.strictEqual(connectorStatus?.publicKeySentInTransaction ?? false, false)
+
+ buildMeterValue(station, TEST_TRANSACTION_ID_STRING, 0)
+
+ assert.strictEqual(connectorStatus?.publicKeySentInTransaction, true)
+ })
+
+ await it('should not sign when SignReadings=true but SignStartedReadings=false with TRANSACTION_BEGIN context', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'),
+ 'false'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID_STRING,
+ 0,
+ undefined,
+ OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ )
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.strictEqual(
+ energySampledValue?.signedMeterValue,
+ undefined,
+ 'Should not sign when SignStartedReadings is false'
+ )
+ })
+
+ await it('should sign when SignReadings=true and SignStartedReadings=true with TRANSACTION_BEGIN context', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ 'abcdef1234567890'
+ )
+
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID_STRING,
+ 0,
+ undefined,
+ OCPP20ReadingContextEnumType.TRANSACTION_BEGIN
+ )
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.ok(
+ energySampledValue?.signedMeterValue != null,
+ 'Should sign when SignStartedReadings is true'
+ )
+ })
+
+ await it('should not sign when SignReadings=true but SignUpdatedReadings=false with periodic context', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'),
+ 'false'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID_STRING,
+ 0,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ )
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.strictEqual(
+ energySampledValue?.signedMeterValue,
+ undefined,
+ 'Should not sign when SignUpdatedReadings is false'
+ )
+ })
+
+ await it('should sign when SignReadings=true and SignUpdatedReadings=true with periodic context', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ 'abcdef1234567890'
+ )
+
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID_STRING,
+ 0,
+ undefined,
+ OCPP20ReadingContextEnumType.SAMPLE_PERIODIC
+ )
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.ok(
+ energySampledValue?.signedMeterValue != null,
+ 'Should sign when SignUpdatedReadings is true'
+ )
+ })
+
+ await it('should sign when SignReadings=true with TRANSACTION_END context regardless of sub-switches', () => {
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignReadings'),
+ 'true'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignStartedReadings'),
+ 'false'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.SampledDataCtrlr, 'SignUpdatedReadings'),
+ 'false'
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.OCPPCommCtrlr, 'PublicKeyWithSignedMeterValue'),
+ PublicKeyWithSignedMeterValueEnumType.Never
+ )
+ addConfigurationKey(
+ station,
+ buildConfigKey(OCPP20ComponentName.FiscalMetering, 'PublicKey'),
+ 'abcdef1234567890'
+ )
+
+ const meterValue = buildMeterValue(
+ station,
+ TEST_TRANSACTION_ID_STRING,
+ 0,
+ undefined,
+ OCPP20ReadingContextEnumType.TRANSACTION_END
+ )
+
+ const energySampledValue = meterValue.sampledValue.find(
+ sv => sv.measurand === MeterValueMeasurand.ENERGY_ACTIVE_IMPORT_REGISTER
+ ) as OCPP20SampledValue | undefined
+ assert.ok(
+ energySampledValue?.signedMeterValue != null,
+ 'Should always sign TRANSACTION_END regardless of sub-switches'
+ )
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for SignedMeterDataGenerator
+ * @description Verifies OCMF-like signed meter data generation for simulation purposes.
+ *
+ * Covers:
+ * - generateSignedMeterData — output structure and field values
+ * - generateSignedMeterData — signedMeterData is valid Base64 containing OCMF payload
+ * - generateSignedMeterData — publicKey handling with and without publicKeyHex
+ * - generateSignedMeterData — TX codes for different contexts
+ * - generateSignedMeterData — different meterValues produce different output
+ * - buildPublicKeyValue — produces valid Base64 containing oca:base16:asn1: prefix
+ */
+
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import {
+ buildPublicKeyValue,
+ generateSignedMeterData,
+ type SignedMeterDataParams,
+} from '../../../src/charging-station/ocpp/OCPPSignedMeterDataGenerator.js'
+import { MeterValueContext, MeterValueUnit } from '../../../src/types/index.js'
+
+const DEFAULT_PARAMS: SignedMeterDataParams = {
+ context: MeterValueContext.SAMPLE_PERIODIC,
+ meterSerialNumber: 'SIM-METER-001',
+ meterValue: 12345000,
+ timestamp: new Date('2025-01-15T10:30:00.000Z'),
+ transactionId: 42,
+}
+
+const TEST_PUBLIC_KEY_HEX = '3059301306072a8648ce3d020106082a8648ce3d03010703420004abc123'
+
+await describe('SignedMeterDataGenerator', async () => {
+ await it('should return an object with all required fields', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+
+ assert.ok('encodingMethod' in result)
+ assert.ok('publicKey' in result)
+ assert.ok('signedMeterData' in result)
+ assert.ok('signingMethod' in result)
+ })
+
+ await it('should produce valid Base64 in signedMeterData', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+ const reEncoded = Buffer.from(Buffer.from(result.signedMeterData, 'base64')).toString('base64')
+
+ assert.strictEqual(result.signedMeterData, reEncoded)
+ })
+
+ await it('should produce signedMeterData that decodes to an OCMF string', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+ const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(decoded.startsWith('OCMF|'))
+ })
+
+ await it('should set signingMethod to empty string when included in signedMeterData', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+
+ assert.strictEqual(result.signingMethod, '')
+ })
+
+ await it('should set encodingMethod to OCMF', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+
+ assert.strictEqual(result.encodingMethod, 'OCMF')
+ })
+
+ await it('should return empty publicKey when no publicKeyHex provided', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+
+ assert.strictEqual(result.publicKey, '')
+ })
+
+ await it('should return non-empty publicKey when publicKeyHex provided', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS, TEST_PUBLIC_KEY_HEX)
+
+ assert.ok(result.publicKey.length > 0)
+ })
+
+ await it('should produce valid Base64 containing oca:base16:asn1: in buildPublicKeyValue', () => {
+ const result = buildPublicKeyValue(TEST_PUBLIC_KEY_HEX)
+ const reEncoded = Buffer.from(Buffer.from(result, 'base64')).toString('base64')
+
+ assert.strictEqual(result, reEncoded)
+
+ const decoded = Buffer.from(result, 'base64').toString('utf8')
+ assert.ok(decoded.includes('oca:base16:asn1:'))
+ })
+
+ await it('should produce TX=B for Transaction.Begin', () => {
+ const params: SignedMeterDataParams = {
+ ...DEFAULT_PARAMS,
+ context: MeterValueContext.TRANSACTION_BEGIN,
+ }
+ const result = generateSignedMeterData(params)
+ const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(decoded.includes('"TX":"B"'))
+ })
+
+ await it('should produce TX=E for Transaction.End', () => {
+ const params: SignedMeterDataParams = {
+ ...DEFAULT_PARAMS,
+ context: MeterValueContext.TRANSACTION_END,
+ }
+ const result = generateSignedMeterData(params)
+ const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(decoded.includes('"TX":"E"'))
+ })
+
+ await it('should produce TX=P for Sample.Periodic', () => {
+ const result = generateSignedMeterData(DEFAULT_PARAMS)
+ const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(decoded.includes('"TX":"P"'))
+ })
+
+ await it('should produce TX=P for Sample.Clock', () => {
+ const params: SignedMeterDataParams = {
+ ...DEFAULT_PARAMS,
+ context: MeterValueContext.SAMPLE_CLOCK,
+ }
+ const result = generateSignedMeterData(params)
+ const decoded = Buffer.from(result.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(decoded.includes('"TX":"P"'))
+ })
+
+ await it('should produce different signedMeterData for different meterValues', () => {
+ const result1 = generateSignedMeterData(DEFAULT_PARAMS)
+ const result2 = generateSignedMeterData({ ...DEFAULT_PARAMS, meterValue: 99999000 })
+
+ assert.notStrictEqual(result1.signedMeterData, result2.signedMeterData)
+ })
+
+ await it('should handle kWh input without double-dividing', () => {
+ const whParams: SignedMeterDataParams = { ...DEFAULT_PARAMS, meterValue: 12345 }
+ const kwhParams: SignedMeterDataParams = {
+ ...DEFAULT_PARAMS,
+ meterValue: 12.345,
+ meterValueUnit: MeterValueUnit.KILO_WATT_HOUR,
+ }
+ const whResult = generateSignedMeterData(whParams)
+ const kwhResult = generateSignedMeterData(kwhParams)
+ const whDecoded = Buffer.from(whResult.signedMeterData, 'base64').toString('utf8')
+ const kwhDecoded = Buffer.from(kwhResult.signedMeterData, 'base64').toString('utf8')
+
+ assert.ok(whDecoded.includes('"RV":12.345'))
+ assert.ok(kwhDecoded.includes('"RV":12.345'))
+ })
+})
--- /dev/null
+/**
+ * @file Tests for SignedMeterValueUtils
+ * @description Unit tests for PublicKeyWithSignedMeterValueEnumType and shouldIncludePublicKey helper
+ */
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { shouldIncludePublicKey } from '../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js'
+import { PublicKeyWithSignedMeterValueEnumType } from '../../../src/types/index.js'
+
+await describe('SignedMeterValueUtils', async () => {
+ await describe('PublicKeyWithSignedMeterValueEnumType', async () => {
+ await it('should have EveryMeterValue value', () => {
+ assert.strictEqual(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, 'EveryMeterValue')
+ })
+
+ await it('should have Never value', () => {
+ assert.strictEqual(PublicKeyWithSignedMeterValueEnumType.Never, 'Never')
+ })
+
+ await it('should have OncePerTransaction value', () => {
+ assert.strictEqual(
+ PublicKeyWithSignedMeterValueEnumType.OncePerTransaction,
+ 'OncePerTransaction'
+ )
+ })
+
+ await it('should have exactly 3 values', () => {
+ const values = Object.values(PublicKeyWithSignedMeterValueEnumType)
+ assert.strictEqual(values.length, 3)
+ })
+ })
+
+ await describe('shouldIncludePublicKey', async () => {
+ await it('should return false when config is Never and key not sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.Never, false),
+ false
+ )
+ })
+
+ await it('should return false when config is Never and key already sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.Never, true),
+ false
+ )
+ })
+
+ await it('should return true when config is EveryMeterValue and key not sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, false),
+ true
+ )
+ })
+
+ await it('should return true when config is EveryMeterValue and key already sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.EveryMeterValue, true),
+ true
+ )
+ })
+
+ await it('should return true when config is OncePerTransaction and key not sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, false),
+ true
+ )
+ })
+
+ await it('should return false when config is OncePerTransaction and key already sent', () => {
+ assert.strictEqual(
+ shouldIncludePublicKey(PublicKeyWithSignedMeterValueEnumType.OncePerTransaction, true),
+ false
+ )
+ })
+ })
+})
]
+def _log_signed_meter_values(meter_value: list) -> None:
+ """Log signed meter value details from a list of meter value dicts."""
+ for mv in meter_value:
+ for sv in mv.get("sampled_value", []):
+ signed_mv = sv.get("signed_meter_value")
+ if signed_mv is not None:
+ logger.info(
+ "Received signed meter value: encoding=%s, signing=%s",
+ signed_mv.get("encoding_method"),
+ signed_mv.get("signing_method"),
+ )
+
+
def _random_request_id() -> int:
"""Generate a random OCPP request ID within the valid range."""
return randint(1, MAX_REQUEST_ID) # noqa: S311
transaction_info,
**kwargs,
):
+ meter_value = kwargs.get("meter_value")
match event_type:
case TransactionEventEnumType.started:
logger.info("Received %s Started", Action.transaction_event)
token_id,
id_token_info["status"],
)
+ if meter_value is not None:
+ _log_signed_meter_values(meter_value)
return ocpp.v201.call_result.TransactionEvent(
id_token_info=id_token_info
)
case TransactionEventEnumType.updated:
logger.info("Received %s Updated", Action.transaction_event)
+ if meter_value is not None:
+ _log_signed_meter_values(meter_value)
return ocpp.v201.call_result.TransactionEvent(
total_cost=self._total_cost
)
logger.info("Received %s Ended", Action.transaction_event)
transaction_id = transaction_info.get("transaction_id", "")
self._active_transactions.pop(transaction_id, None)
+ if meter_value is not None:
+ _log_signed_meter_values(meter_value)
return ocpp.v201.call_result.TransactionEvent()
case _:
logger.warning("Unknown transaction event type: %s", event_type)
@on(Action.meter_values)
async def on_meter_values(self, evse_id: int, meter_value, **kwargs):
logger.info("Received %s", Action.meter_values)
+ _log_signed_meter_values(meter_value)
return ocpp.v201.call_result.MeterValues()
@on(Action.notify_report)
assert response.total_cost is None
assert response.id_token_info is None
+ async def test_started_with_signed_meter_value(self, charge_point):
+ signed_mv = {
+ "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl",
+ "signing_method": "ECDSA-secp256r1-SHA256",
+ "encoding_method": "OCMF",
+ "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=",
+ }
+ mv = {
+ "timestamp": TEST_TIMESTAMP,
+ "sampled_value": [
+ {
+ "value": 0.0,
+ "measurand": "Energy.Active.Import.Register",
+ "signed_meter_value": signed_mv,
+ }
+ ],
+ }
+ response = await charge_point.on_transaction_event(
+ event_type=TransactionEventEnumType.started,
+ timestamp=TEST_TIMESTAMP,
+ trigger_reason="Authorized",
+ seq_no=0,
+ transaction_info={"transaction_id": TEST_TRANSACTION_ID},
+ id_token={"id_token": TEST_TOKEN, "type": "ISO14443"},
+ meter_value=[mv],
+ )
+ assert isinstance(response, ocpp.v201.call_result.TransactionEvent)
+ assert response.id_token_info["status"] == AuthorizationStatusEnumType.accepted
+
+ async def test_updated_with_signed_meter_value(self, charge_point):
+ signed_mv = {
+ "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl",
+ "signing_method": "ECDSA-secp256r1-SHA256",
+ "encoding_method": "OCMF",
+ "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=",
+ }
+ mv = {
+ "timestamp": TEST_TIMESTAMP,
+ "sampled_value": [
+ {
+ "value": 1500.5,
+ "measurand": "Energy.Active.Import.Register",
+ "signed_meter_value": signed_mv,
+ }
+ ],
+ }
+ response = await charge_point.on_transaction_event(
+ event_type=TransactionEventEnumType.updated,
+ timestamp=TEST_TIMESTAMP,
+ trigger_reason="MeterValuePeriodic",
+ seq_no=1,
+ transaction_info={"transaction_id": TEST_TRANSACTION_ID},
+ meter_value=[mv],
+ )
+ assert isinstance(response, ocpp.v201.call_result.TransactionEvent)
+ assert response.total_cost == DEFAULT_TOTAL_COST
+
+ async def test_ended_with_signed_meter_value(self, charge_point):
+ signed_mv = {
+ "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl",
+ "signing_method": "ECDSA-secp256r1-SHA256",
+ "encoding_method": "OCMF",
+ "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=",
+ }
+ begin_mv = {
+ "timestamp": TEST_TIMESTAMP,
+ "sampled_value": [
+ {
+ "value": 0.0,
+ "measurand": "Energy.Active.Import.Register",
+ "context": "Transaction.Begin",
+ "signed_meter_value": signed_mv,
+ }
+ ],
+ }
+ end_mv = {
+ "timestamp": TEST_TIMESTAMP,
+ "sampled_value": [
+ {
+ "value": 15000.5,
+ "measurand": "Energy.Active.Import.Register",
+ "context": "Transaction.End",
+ "signed_meter_value": signed_mv,
+ }
+ ],
+ }
+ response = await charge_point.on_transaction_event(
+ event_type=TransactionEventEnumType.ended,
+ timestamp=TEST_TIMESTAMP,
+ trigger_reason="EVDeparture",
+ seq_no=2,
+ transaction_info={"transaction_id": TEST_TRANSACTION_ID},
+ meter_value=[begin_mv, end_mv],
+ )
+ assert isinstance(response, ocpp.v201.call_result.TransactionEvent)
+
class TestDataTransferHandler:
"""Tests for the DataTransfer incoming handler."""
)
assert isinstance(response, ocpp.v201.call_result.MeterValues)
+ async def test_meter_values_with_signed_meter_value(self, charge_point):
+ signed_mv = {
+ "signed_meter_data": "T0NNRnx7fXxmYWtlc2lnbmF0dXJl",
+ "signing_method": "ECDSA-secp256r1-SHA256",
+ "encoding_method": "OCMF",
+ "public_key": "b2NhOmJhc2UxNjphc24xOmZha2VrZXk=",
+ }
+ mv = {
+ "timestamp": TEST_TIMESTAMP,
+ "sampled_value": [
+ {
+ "value": 1500.5,
+ "measurand": "Energy.Active.Import.Register",
+ "signed_meter_value": signed_mv,
+ }
+ ],
+ }
+ response = await charge_point.on_meter_values(
+ evse_id=TEST_EVSE_ID, meter_value=[mv]
+ )
+ assert isinstance(response, ocpp.v201.call_result.MeterValues)
+
async def test_notify_report(self, charge_point):
response = await charge_point.on_notify_report(
request_id=1,
--- /dev/null
+/**
+ * @file OCPP 1.6 MeterValues types test suite
+ * @description Tests for OCPP16MeterValueFormat enum and OCPP16SignedMeterValue interface
+ */
+
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { OCPP16MeterValueFormat, type OCPP16SignedMeterValue } from '../../../../src/types/index.js'
+
+await describe('OCPP 1.6 meter value types', async () => {
+ await describe('OCPP16MeterValueFormat', async () => {
+ await it('should have RAW enum value equal to "Raw"', () => {
+ assert.strictEqual(OCPP16MeterValueFormat.RAW, 'Raw')
+ })
+
+ await it('should have SIGNED_DATA enum value equal to "SignedData"', () => {
+ assert.strictEqual(OCPP16MeterValueFormat.SIGNED_DATA, 'SignedData')
+ })
+ })
+
+ await describe('OCPP16SignedMeterValue', async () => {
+ await it('should compile as an interface with correct field names', () => {
+ const signedMeterValue: OCPP16SignedMeterValue = {
+ encodingMethod: 'OCMF',
+ publicKey: 'b2NhOmJhc2UxNjphc24xOmZha2VrZXk=', // cspell:disable-line
+ signedMeterData: 'T0NNRnx7fXxmYWtlc2lnbmF0dXJl', // cspell:disable-line
+ signingMethod: 'ECDSA-secp256r1-SHA256',
+ }
+ assert.strictEqual(signedMeterValue.encodingMethod, 'OCMF')
+ assert.strictEqual(signedMeterValue.signingMethod, 'ECDSA-secp256r1-SHA256')
+ assert.ok(signedMeterValue.publicKey.length > 0)
+ assert.ok(signedMeterValue.signedMeterData.length > 0)
+ })
+ })
+})
--- /dev/null
+/**
+ * @file Tests for OCPP16VendorParametersKey enum
+ * @description Unit tests for signed meter values vendor configuration keys
+ */
+import assert from 'node:assert/strict'
+import { describe, it } from 'node:test'
+
+import { OCPP16VendorParametersKey } from '../../../../src/types/index.js'
+
+await describe('OCPP16VendorParametersKey', async () => {
+ await it('should have AlignedDataSignReadings key', () => {
+ assert.strictEqual(OCPP16VendorParametersKey.AlignedDataSignReadings, 'AlignedDataSignReadings')
+ })
+
+ await it('should have AlignedDataSignUpdatedReadings key', () => {
+ assert.strictEqual(
+ OCPP16VendorParametersKey.AlignedDataSignUpdatedReadings,
+ 'AlignedDataSignUpdatedReadings'
+ )
+ })
+
+ await it('should have ConnectionUrl key', () => {
+ assert.strictEqual(OCPP16VendorParametersKey.ConnectionUrl, 'ConnectionUrl')
+ })
+
+ await it('should have MeterPublicKey key', () => {
+ assert.strictEqual(OCPP16VendorParametersKey.MeterPublicKey, 'MeterPublicKey')
+ })
+
+ await it('should have PublicKeyWithSignedMeterValue key', () => {
+ assert.strictEqual(
+ OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue,
+ 'PublicKeyWithSignedMeterValue'
+ )
+ })
+
+ await it('should have SampledDataSignReadings key', () => {
+ assert.strictEqual(OCPP16VendorParametersKey.SampledDataSignReadings, 'SampledDataSignReadings')
+ })
+
+ await it('should have SampledDataSignStartedReadings key', () => {
+ assert.strictEqual(
+ OCPP16VendorParametersKey.SampledDataSignStartedReadings,
+ 'SampledDataSignStartedReadings'
+ )
+ })
+
+ await it('should have SampledDataSignUpdatedReadings key', () => {
+ assert.strictEqual(
+ OCPP16VendorParametersKey.SampledDataSignUpdatedReadings,
+ 'SampledDataSignUpdatedReadings'
+ )
+ })
+
+ await it('should have StartTxnSampledData key', () => {
+ assert.strictEqual(OCPP16VendorParametersKey.StartTxnSampledData, 'StartTxnSampledData')
+ })
+})