From: Jérôme Benoit Date: Tue, 21 Oct 2025 19:11:50 +0000 (+0200) Subject: fix: make NotifyReport OCPP2 spec compliant X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=805d6e59e61e84f626ad7e91e21f7ff754413348;p=e-mobility-charging-stations-simulator.git fix: make NotifyReport OCPP2 spec compliant Signed-off-by: Jérôme Benoit --- diff --git a/eslint.config.js b/eslint.config.js index 52728b39..7950a96b 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -51,6 +51,9 @@ export default defineConfig([ 'shutdowning', 'VCAP', 'workerd', + // OCPP 2.0 Component Names + 'cppwm', + 'recloser', ], }, }, diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index f2eb02ee..c77014e0 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -7,6 +7,7 @@ import type { ChargingStation } from '../../../charging-station/index.js' import { OCPPError } from '../../../exception/index.js' import { ConnectorEnumType, + ConnectorStatusEnum, ErrorType, GenericDeviceModelStatusEnumType, type IncomingRequestHandler, @@ -80,33 +81,14 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { response: OCPP20GetBaseReportResponse ) => { if (response.status === GenericDeviceModelStatusEnumType.Accepted) { - const { reportBase, requestId } = request - const reportData = this.buildReportData(chargingStation, reportBase) - chargingStation.ocppRequestService - .requestHandler( - chargingStation, - OCPP20RequestCommand.NOTIFY_REPORT, - { - generatedAt: new Date(), - reportData, - requestId, - seqNo: 0, - tbc: false, - } - ) - .then(() => { - logger.debug( - // eslint-disable-next-line @typescript-eslint/restrict-template-expressions - `${chargingStation.logPrefix()} ${moduleName}.constructor: NotifyReport sent for requestId ${requestId} with ${reportData.length} report items` - ) - return undefined - }) - .catch((error: unknown) => { + this.sendNotifyReportRequest(chargingStation, request, response).catch( + (error: unknown) => { logger.error( `${chargingStation.logPrefix()} ${moduleName}.constructor: NotifyReport error:`, error ) - }) + } + ) } } ) @@ -244,13 +226,12 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { break case ReportBaseEnumType.FullInventory: - // Include all device model variables - // 1. Station information + // 1. Charging Station information if (chargingStation.stationInfo) { const stationInfo = chargingStation.stationInfo if (stationInfo.chargePointModel) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'Model' }, variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointModel }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -258,7 +239,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } if (stationInfo.chargePointVendor) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'VendorName' }, variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointVendor }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -266,7 +247,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } if (stationInfo.chargePointSerialNumber) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'SerialNumber' }, variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointSerialNumber }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -274,7 +255,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } if (stationInfo.firmwareVersion) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'FirmwareVersion' }, variableAttribute: [{ type: 'Actual', value: stationInfo.firmwareVersion }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -285,11 +266,20 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { // 2. OCPP configuration if (chargingStation.ocppConfiguration?.configurationKey) { for (const configKey of chargingStation.ocppConfiguration.configurationKey) { + const variableAttributes = [] + variableAttributes.push({ type: 'Actual', value: configKey.value }) + if (!configKey.readonly) { + variableAttributes.push({ type: 'Target', value: undefined }) + } + reportData.push({ component: { name: OCPP20ComponentName.OCPPCommCtrlr }, variable: { name: configKey.key }, - variableAttribute: [{ type: 'Actual', value: configKey.value }], - variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, + variableAttribute: variableAttributes, + variableCharacteristics: { + dataType: 'string', + supportsMonitoring: false, + }, }) } } @@ -300,7 +290,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { reportData.push({ component: { evse: { id: evseId }, - name: OCPP20ComponentName.DeviceDataCtrlr, + name: OCPP20ComponentName.EVSE, }, variable: { name: 'AvailabilityState' }, variableAttribute: [{ type: 'Actual', value: evse.availability }], @@ -310,8 +300,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { for (const [connectorId, connector] of evse.connectors) { reportData.push({ component: { - evse: { connectorId: connectorId.toString(), id: evseId }, - name: OCPP20ComponentName.DeviceDataCtrlr, + evse: { connectorId, id: evseId }, + name: OCPP20ComponentName.EVSE, }, variable: { name: 'ConnectorType' }, variableAttribute: [ @@ -328,8 +318,8 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { if (connectorId > 0) { reportData.push({ component: { - evse: { connectorId: connectorId.toString(), id: 1 }, - name: 'Connector', + evse: { connectorId, id: 1 }, + name: OCPP20ComponentName.Connector, }, variable: { name: 'ConnectorType' }, variableAttribute: [ @@ -343,12 +333,11 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { break case ReportBaseEnumType.SummaryInventory: - // Include essential variables only if (chargingStation.stationInfo) { const stationInfo = chargingStation.stationInfo if (stationInfo.chargePointModel) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'Model' }, variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointModel }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -356,7 +345,7 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } if (stationInfo.chargePointVendor) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'VendorName' }, variableAttribute: [{ type: 'Actual', value: stationInfo.chargePointVendor }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, @@ -364,13 +353,56 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } if (stationInfo.firmwareVersion) { reportData.push({ - component: { name: OCPP20ComponentName.DeviceDataCtrlr }, + component: { name: OCPP20ComponentName.ChargingStation }, variable: { name: 'FirmwareVersion' }, variableAttribute: [{ type: 'Actual', value: stationInfo.firmwareVersion }], variableCharacteristics: { dataType: 'string', supportsMonitoring: false }, }) } } + + reportData.push({ + component: { name: OCPP20ComponentName.ChargingStation }, + variable: { name: 'AvailabilityState' }, + variableAttribute: [ + { + type: 'Actual', + value: chargingStation.inAcceptedState() ? 'Available' : 'Unavailable', + }, + ], + variableCharacteristics: { dataType: 'string', supportsMonitoring: true }, + }) + + if (chargingStation.evses.size > 0) { + for (const [evseId, evse] of chargingStation.evses) { + reportData.push({ + component: { + evse: { id: evseId }, + name: OCPP20ComponentName.EVSE, + }, + variable: { name: 'AvailabilityState' }, + variableAttribute: [{ type: 'Actual', value: evse.availability }], + variableCharacteristics: { dataType: 'string', supportsMonitoring: true }, + }) + } + } else if (chargingStation.connectors.size > 0) { + // Fallback to connectors if no EVSE structure + for (const [connectorId, connector] of chargingStation.connectors) { + if (connectorId > 0) { + reportData.push({ + component: { + evse: { connectorId, id: 1 }, + name: OCPP20ComponentName.Connector, + }, + variable: { name: 'AvailabilityState' }, + variableAttribute: [ + { type: 'Actual', value: connector.status ?? ConnectorStatusEnum.Unavailable }, + ], + variableCharacteristics: { dataType: 'string', supportsMonitoring: true }, + }) + } + } + } break default: @@ -391,7 +423,22 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { // eslint-disable-next-line @typescript-eslint/restrict-template-expressions `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: GetBaseReport request received with requestId ${commandPayload.requestId} and reportBase ${commandPayload.reportBase}` ) - // Build report data to check if any data is available + + const supportedReportBases = [ + ReportBaseEnumType.ConfigurationInventory, + ReportBaseEnumType.FullInventory, + ReportBaseEnumType.SummaryInventory, + ] + + if (!supportedReportBases.includes(commandPayload.reportBase)) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestGetBaseReport: Unsupported reportBase ${commandPayload.reportBase}` + ) + return { + status: GenericDeviceModelStatusEnumType.NotSupported, + } + } + const reportData = this.buildReportData(chargingStation, commandPayload.reportBase) if (reportData.length === 0) { logger.info( @@ -406,6 +453,54 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { } } + private async sendNotifyReportRequest ( + chargingStation: ChargingStation, + request: OCPP20GetBaseReportRequest, + response: OCPP20GetBaseReportResponse + ): Promise { + const { reportBase, requestId } = request + const reportData = this.buildReportData(chargingStation, reportBase) + + // Fragment report data if needed (OCPP2 spec recommends max 100 items per message) + const maxItemsPerMessage = 100 + const chunks = [] + for (let i = 0; i < reportData.length; i += maxItemsPerMessage) { + chunks.push(reportData.slice(i, i + maxItemsPerMessage)) + } + + // Ensure we always send at least one message (even if empty) + if (chunks.length === 0) { + chunks.push([]) + } + + // Send fragmented NotifyReport messages + for (let seqNo = 0; seqNo < chunks.length; seqNo++) { + const isLastChunk = seqNo === chunks.length - 1 + const chunk = chunks[seqNo] + + await chargingStation.ocppRequestService.requestHandler< + OCPP20NotifyReportRequest, + OCPP20NotifyReportResponse + >(chargingStation, OCPP20RequestCommand.NOTIFY_REPORT, { + generatedAt: new Date(), + reportData: chunk, + requestId, + seqNo, + tbc: !isLastChunk, + }) + + logger.debug( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: NotifyReport sent seqNo=${seqNo} for requestId ${requestId} with ${chunk.length} report items (tbc=${!isLastChunk})` + ) + } + + logger.debug( + // eslint-disable-next-line @typescript-eslint/restrict-template-expressions + `${chargingStation.logPrefix()} ${moduleName}.sendNotifyReportRequest: Completed NotifyReport for requestId ${requestId} with ${reportData.length} total items in ${chunks.length} message(s)` + ) + } + private validatePayload ( chargingStation: ChargingStation, commandName: OCPP20IncomingRequestCommand, diff --git a/src/types/index.ts b/src/types/index.ts index bf4024f4..fb737e72 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -143,6 +143,7 @@ export { } from './ocpp/1.6/Transaction.js' export { BootReasonEnumType, + type CustomDataType, GenericDeviceModelStatusEnumType, OCPP20ComponentName, OCPP20ConnectorStatusEnumType, diff --git a/src/types/ocpp/2.0/Common.ts b/src/types/ocpp/2.0/Common.ts index 2a16d840..7e1842df 100644 --- a/src/types/ocpp/2.0/Common.ts +++ b/src/types/ocpp/2.0/Common.ts @@ -86,24 +86,81 @@ export enum InstallCertificateUseEnumType { } export enum OCPP20ComponentName { + // Physical and Logical Components + AccessBarrier = 'AccessBarrier', + AcDcConverter = 'AcDcConverter', + AcPhaseSelector = 'AcPhaseSelector', + Actuator = 'Actuator', + AirCoolingSystem = 'AirCoolingSystem', AlignedDataCtrlr = 'AlignedDataCtrlr', + AreaVentilation = 'AreaVentilation', AuthCacheCtrlr = 'AuthCacheCtrlr', AuthCtrlr = 'AuthCtrlr', + BayOccupancySensor = 'BayOccupancySensor', + BeaconLighting = 'BeaconLighting', + CableBreakawaySensor = 'CableBreakawaySensor', + CaseAccessSensor = 'CaseAccessSensor', CHAdeMOCtrlr = 'CHAdeMOCtrlr', + ChargingStation = 'ChargingStation', + ChargingStatusIndicator = 'ChargingStatusIndicator', ClockCtrlr = 'ClockCtrlr', + ConnectedEV = 'ConnectedEV', + Connector = 'Connector', + ConnectorHolsterRelease = 'ConnectorHolsterRelease', + ConnectorHolsterSensor = 'ConnectorHolsterSensor', + ConnectorPlugRetentionLock = 'ConnectorPlugRetentionLock', + ConnectorProtectionRelease = 'ConnectorProtectionRelease', + Controller = 'Controller', + ControlMetering = 'ControlMetering', + CPPWMController = 'CPPWMController', CustomizationCtrlr = 'CustomizationCtrlr', + DataLink = 'DataLink', DeviceDataCtrlr = 'DeviceDataCtrlr', + Display = 'Display', DisplayMessageCtrlr = 'DisplayMessageCtrlr', + DistributionPanel = 'DistributionPanel', + ElectricalFeed = 'ElectricalFeed', + ELVSupply = 'ELVSupply', + EmergencyStopSensor = 'EmergencyStopSensor', + EnvironmentalLighting = 'EnvironmentalLighting', + EVRetentionLock = 'EVRetentionLock', + EVSE = 'EVSE', + ExternalTemperatureSensor = 'ExternalTemperatureSensor', + FiscalMetering = 'FiscalMetering', + FloodSensor = 'FloodSensor', + GroundIsolationProtection = 'GroundIsolationProtection', + Heater = 'Heater', + HumiditySensor = 'HumiditySensor', ISO15118Ctrlr = 'ISO15118Ctrlr', + LightSensor = 'LightSensor', + LiquidCoolingSystem = 'LiquidCoolingSystem', LocalAuthListCtrlr = 'LocalAuthListCtrlr', + LocalAvailabilitySensor = 'LocalAvailabilitySensor', + LocalController = 'LocalController', + LocalEnergyStorage = 'LocalEnergyStorage', MonitoringCtrlr = 'MonitoringCtrlr', OCPPCommCtrlr = 'OCPPCommCtrlr', + OverCurrentProtection = 'OverCurrentProtection', + OverCurrentProtectionRecloser = 'OverCurrentProtectionRecloser', + PowerContactor = 'PowerContactor', + RCD = 'RCD', + RCDRecloser = 'RCDRecloser', + RealTimeClock = 'RealTimeClock', ReservationCtrlr = 'ReservationCtrlr', SampledDataCtrlr = 'SampledDataCtrlr', SecurityCtrlr = 'SecurityCtrlr', + ShockSensor = 'ShockSensor', SmartChargingCtrlr = 'SmartChargingCtrlr', + SpacesCountSignage = 'SpacesCountSignage', + Switch = 'Switch', TariffCostCtrlr = 'TariffCostCtrlr', + TemperatureSensor = 'TemperatureSensor', + TiltSensor = 'TiltSensor', + TokenReader = 'TokenReader', TxCtrlr = 'TxCtrlr', + UIInput = 'UIInput', + UpstreamProtectionTrigger = 'UpstreamProtectionTrigger', + VehicleIdSensor = 'VehicleIdSensor', } export enum OCPP20ConnectorEnumType { @@ -166,21 +223,22 @@ export interface CertificateHashDataType extends JsonObject { export type CertificateSignedStatusEnumType = GenericStatusEnumType export interface ChargingStationType extends JsonObject { + customData?: CustomDataType firmwareVersion?: string model: string modem?: ModemType serialNumber?: string vendorName: string } + export interface ComponentType extends JsonObject { evse?: EVSEType instance?: string name: OCPP20ComponentName | string } -export interface EVSEType extends JsonObject { - connectorId?: string - id: number +export interface CustomDataType extends JsonObject { + vendorId: string } export type GenericStatusEnumType = GenericStatus @@ -205,7 +263,13 @@ export interface StatusInfoType extends JsonObject { reasonCode: string } +interface EVSEType extends JsonObject { + connectorId?: number + id: number +} + interface ModemType extends JsonObject { + customData?: CustomDataType iccid?: string imsi?: string }