From: Jérôme Benoit Date: Thu, 30 Apr 2026 13:30:13 +0000 (+0200) Subject: fix(ui): make Authorize version-aware for OCPP 2.0.1 stations X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=1d31a910211733b0deaf64d6c1398d3248d94b55;p=e-mobility-charging-stations-simulator.git fix(ui): make Authorize version-aware for OCPP 2.0.1 stations Extract shared OCPP version-aware payload builders into ui-common/src/utils/payloadBuilders.ts (buildAuthorizePayload, buildStartTransactionPayload, buildStopTransactionPayload, isOCPP20x, buildIdToken) and consume them from both Web UI and CLI. The Web UI Authorize was always sending flat { idTag } regardless of OCPP version, causing "Request PDU is invalid" on OCPP 2.0.1 stations which require { idToken: { idToken, type } }. Closes #1817 --- diff --git a/ui/cli/src/commands/ocpp.ts b/ui/cli/src/commands/ocpp.ts index 33209a9d..31743b34 100644 --- a/ui/cli/src/commands/ocpp.ts +++ b/ui/cli/src/commands/ocpp.ts @@ -1,5 +1,5 @@ import { Command, Option } from 'commander' -import { OCPP20IdTokenEnumType, OCPPVersion, ProcedureName, type RequestPayload } from 'ui-common' +import { buildAuthorizePayload, OCPPVersion, ProcedureName, type RequestPayload } from 'ui-common' import { handleActionErrors, @@ -30,22 +30,9 @@ export const createOcppCommands = (program: Command): Command => { program, hashIds ) - switch (ocppVersion) { - case OCPPVersion.VERSION_16: - payload = { - idTag: options.idTag, - ...buildHashIdsPayload(resolvedHashIds), - } - break - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: - payload = { - idToken: { idToken: options.idTag, type: OCPP20IdTokenEnumType.ISO14443 }, - ...buildHashIdsPayload(resolvedHashIds), - } - break - default: - throw new Error(UNSUPPORTED_OCPP_VERSION_ERROR) + payload = { + ...buildAuthorizePayload(options.idTag, ocppVersion), + ...buildHashIdsPayload(resolvedHashIds), } await runAction(program, ProcedureName.AUTHORIZE, payload, undefined, config) } else { diff --git a/ui/cli/src/commands/transaction.ts b/ui/cli/src/commands/transaction.ts index 8d79e83c..43624e9f 100644 --- a/ui/cli/src/commands/transaction.ts +++ b/ui/cli/src/commands/transaction.ts @@ -1,7 +1,8 @@ import { Command, Option } from 'commander' import { - OCPP20IdTokenEnumType, - OCPP20TransactionEventEnumType, + buildStartTransactionPayload, + buildStopTransactionPayload, + isOCPP20x, OCPPVersion, ProcedureName, type RequestPayload, @@ -17,9 +18,6 @@ import { buildHashIdsPayload, PAYLOAD_DESC, PAYLOAD_OPTION } from './payload.js' export const createTransactionCommands = (program: Command): Command => { const cmd = new Command('transaction').description('Transaction management') - const UNSUPPORTED_OCPP_VERSION_ERROR = - 'Unsupported OCPP version for this command. ' + - 'Use ocpp transaction-event -p to pass the payload directly.' cmd .command('start [hashIds...]') @@ -60,28 +58,18 @@ export const createTransactionCommands = (program: Command): Command => { program, hashIds ) - switch (ocppVersion) { - case OCPPVersion.VERSION_16: - procedureName = ProcedureName.START_TRANSACTION - payload = { - connectorId: options.connectorId, - idTag: options.idTag, - ...buildHashIdsPayload(resolvedHashIds), - } - break - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: - procedureName = ProcedureName.TRANSACTION_EVENT - payload = { - connectorId: options.connectorId, - eventType: OCPP20TransactionEventEnumType.STARTED, - ...(options.evseId != null && { evseId: options.evseId }), - idToken: { idToken: options.idTag, type: OCPP20IdTokenEnumType.ISO14443 }, - ...buildHashIdsPayload(resolvedHashIds), - } - break - default: - throw new Error(UNSUPPORTED_OCPP_VERSION_ERROR) + const { payload: built, procedureName: proc } = buildStartTransactionPayload( + options.connectorId, + ocppVersion, + { evseId: options.evseId, idTag: options.idTag } + ) + procedureName = + proc === 'transactionEvent' + ? ProcedureName.TRANSACTION_EVENT + : ProcedureName.START_TRANSACTION + payload = { + ...built, + ...buildHashIdsPayload(resolvedHashIds), } await runAction(program, procedureName, payload, undefined, config) } else { @@ -127,32 +115,26 @@ export const createTransactionCommands = (program: Command): Command => { program, hashIds ) - switch (ocppVersion) { - case OCPPVersion.VERSION_16: - procedureName = ProcedureName.STOP_TRANSACTION - payload = { - transactionId: parseInteger( - options.transactionId, - '--transaction-id (OCPP 1.6 requires integer)' - ), - ...buildHashIdsPayload(resolvedHashIds), - } - break - case OCPPVersion.VERSION_20: - case OCPPVersion.VERSION_201: - if (options.connectorId == null) { - throw new Error('--connector-id is required for OCPP 2.0.x stations') - } - procedureName = ProcedureName.TRANSACTION_EVENT - payload = { - connectorId: options.connectorId, - eventType: OCPP20TransactionEventEnumType.ENDED, - transactionId: options.transactionId, - ...buildHashIdsPayload(resolvedHashIds), - } - break - default: - throw new Error(UNSUPPORTED_OCPP_VERSION_ERROR) + if (isOCPP20x(ocppVersion) && options.connectorId == null) { + throw new Error('--connector-id is required for OCPP 2.0.x stations') + } + const { payload: built, procedureName: proc } = buildStopTransactionPayload( + ocppVersion === OCPPVersion.VERSION_16 + ? parseInteger( + options.transactionId, + '--transaction-id (OCPP 1.6 requires integer)' + ) + : options.transactionId, + ocppVersion, + options.connectorId + ) + procedureName = + proc === 'transactionEvent' + ? ProcedureName.TRANSACTION_EVENT + : ProcedureName.STOP_TRANSACTION + payload = { + ...built, + ...buildHashIdsPayload(resolvedHashIds), } await runAction(program, procedureName, payload, undefined, config) } else { diff --git a/ui/common/src/index.ts b/ui/common/src/index.ts index d79e9be3..944db50a 100644 --- a/ui/common/src/index.ts +++ b/ui/common/src/index.ts @@ -11,5 +11,6 @@ export * from './types/JsonType.js' export * from './types/UIProtocol.js' export * from './types/UUID.js' export * from './utils/converters.js' +export * from './utils/payloadBuilders.js' export * from './utils/UUID.js' export * from './utils/websocket.js' diff --git a/ui/common/src/utils/payloadBuilders.ts b/ui/common/src/utils/payloadBuilders.ts new file mode 100644 index 00000000..fbd43f93 --- /dev/null +++ b/ui/common/src/utils/payloadBuilders.ts @@ -0,0 +1,123 @@ +import type { RequestPayload } from '../types/UIProtocol.js' + +import { + OCPP20IdTokenEnumType, + type OCPP20IdTokenType, + OCPP20TransactionEventEnumType, + OCPPVersion, +} from '../types/ChargingStationType.js' + +/** + * Builds an Authorize request payload adapted to the station's OCPP version. + * @param idTag - RFID tag identifier + * @param ocppVersion - Target OCPP version (1.6 format if undefined) + * @returns Payload with flat `idTag` for 1.6 or nested `idToken` for 2.0.x + */ +export function buildAuthorizePayload ( + idTag: string, + ocppVersion: OCPPVersion | undefined +): RequestPayload { + if (isOCPP20x(ocppVersion)) { + return { + idToken: { idToken: idTag, type: OCPP20IdTokenEnumType.ISO14443 }, + } + } + assertOCPP16OrUndefined(ocppVersion) + return { idTag } +} + +/** + * Builds an OCPP 2.0.x IdTokenType object. + * @param idTag - RFID tag identifier + * @param type - Token type enumeration value + * @returns IdTokenType object + */ +export function buildIdToken ( + idTag: string, + type: OCPP20IdTokenEnumType = OCPP20IdTokenEnumType.ISO14443 +): OCPP20IdTokenType { + return { idToken: idTag, type } +} + +/** + * Builds a StartTransaction/TransactionEvent payload adapted to the station's OCPP version. + * @param connectorId - Connector identifier + * @param ocppVersion - Target OCPP version + * @param options - Optional fields + * @param options.evseId - EVSE identifier (OCPP 2.0.x only) + * @param options.idTag - RFID tag identifier + * @returns Payload and procedure name to use + */ +export function buildStartTransactionPayload ( + connectorId: number, + ocppVersion: OCPPVersion | undefined, + options?: { evseId?: number; idTag?: string } +): { payload: RequestPayload; procedureName: 'startTransaction' | 'transactionEvent' } { + if (isOCPP20x(ocppVersion)) { + return { + payload: { + connectorId, + eventType: OCPP20TransactionEventEnumType.STARTED, + ...(options?.evseId != null && { evseId: options.evseId }), + ...(options?.idTag != null && { + idToken: { idToken: options.idTag, type: OCPP20IdTokenEnumType.ISO14443 }, + }), + }, + procedureName: 'transactionEvent', + } + } + assertOCPP16OrUndefined(ocppVersion) + return { + payload: { connectorId, ...(options?.idTag != null && { idTag: options.idTag }) }, + procedureName: 'startTransaction', + } +} + +/** + * Builds a StopTransaction/TransactionEvent payload adapted to the station's OCPP version. + * @param transactionId - Transaction identifier (integer for 1.6, string for 2.0.x) + * @param ocppVersion - Target OCPP version + * @param connectorId - Connector identifier (OCPP 2.0.x only) + * @returns Payload and procedure name to use + */ +export function buildStopTransactionPayload ( + transactionId: number | string, + ocppVersion: OCPPVersion | undefined, + connectorId?: number +): { payload: RequestPayload; procedureName: 'stopTransaction' | 'transactionEvent' } { + if (isOCPP20x(ocppVersion)) { + return { + payload: { + ...(connectorId != null && { connectorId }), + eventType: OCPP20TransactionEventEnumType.ENDED, + transactionId: transactionId.toString(), + }, + procedureName: 'transactionEvent', + } + } + assertOCPP16OrUndefined(ocppVersion) + return { + payload: { transactionId }, + procedureName: 'stopTransaction', + } +} + +/** + * Checks whether the given OCPP version is 2.0 or 2.0.1. + * @param version - OCPP version to check + * @returns `true` if version is 2.0 or 2.0.1 + */ +export function isOCPP20x (version: OCPPVersion | undefined): boolean { + return version === OCPPVersion.VERSION_20 || version === OCPPVersion.VERSION_201 +} + +/** + * Asserts that the OCPP version is 1.6 or undefined (legacy default). + * @param version - OCPP version to validate + * @throws {Error} if version is not 1.6, undefined, or a known 2.0.x variant + */ +function assertOCPP16OrUndefined (version: OCPPVersion | undefined): void { + if (version != null && version !== OCPPVersion.VERSION_16) { + throw new Error(`Unsupported OCPP version for payload building: ${version}`) + } +} diff --git a/ui/common/tests/payloadBuilders.test.ts b/ui/common/tests/payloadBuilders.test.ts new file mode 100644 index 00000000..bfed3a09 --- /dev/null +++ b/ui/common/tests/payloadBuilders.test.ts @@ -0,0 +1,176 @@ +import assert from 'node:assert' +import { describe, it } from 'node:test' + +import { + OCPP20IdTokenEnumType, + OCPP20TransactionEventEnumType, + OCPPVersion, +} from '../src/types/ChargingStationType.js' +import { + buildAuthorizePayload, + buildIdToken, + buildStartTransactionPayload, + buildStopTransactionPayload, + isOCPP20x, +} from '../src/utils/payloadBuilders.js' + +await describe('payloadBuilders', async () => { + await describe('isOCPP20x', async () => { + await it('should return true for VERSION_20', () => { + assert.strictEqual(isOCPP20x(OCPPVersion.VERSION_20), true) + }) + + await it('should return true for VERSION_201', () => { + assert.strictEqual(isOCPP20x(OCPPVersion.VERSION_201), true) + }) + + await it('should return false for VERSION_16', () => { + assert.strictEqual(isOCPP20x(OCPPVersion.VERSION_16), false) + }) + + await it('should return false for undefined', () => { + assert.strictEqual(isOCPP20x(undefined), false) + }) + }) + + await describe('buildAuthorizePayload', async () => { + await it('should build flat idTag payload for OCPP 1.6', () => { + const result = buildAuthorizePayload('RFID123', OCPPVersion.VERSION_16) + assert.deepStrictEqual(result, { idTag: 'RFID123' }) + }) + + await it('should build idToken payload for OCPP 2.0', () => { + const result = buildAuthorizePayload('RFID123', OCPPVersion.VERSION_20) + assert.deepStrictEqual(result, { + idToken: { idToken: 'RFID123', type: OCPP20IdTokenEnumType.ISO14443 }, + }) + }) + + await it('should build idToken payload for OCPP 2.0.1', () => { + const result = buildAuthorizePayload('RFID123', OCPPVersion.VERSION_201) + assert.deepStrictEqual(result, { + idToken: { idToken: 'RFID123', type: OCPP20IdTokenEnumType.ISO14443 }, + }) + }) + + await it('should default to OCPP 1.6 format when version is undefined', () => { + const result = buildAuthorizePayload('RFID123', undefined) + assert.deepStrictEqual(result, { idTag: 'RFID123' }) + }) + + await it('should throw on unsupported OCPP version', () => { + assert.throws( + () => buildAuthorizePayload('RFID123', '3.0' as unknown as OCPPVersion), + (error: Error) => error.message.includes('Unsupported OCPP version') + ) + }) + }) + + await describe('buildStartTransactionPayload', async () => { + await it('should build OCPP 1.6 payload with startTransaction procedure', () => { + const result = buildStartTransactionPayload(1, OCPPVersion.VERSION_16, { idTag: 'TAG1' }) + assert.deepStrictEqual(result, { + payload: { connectorId: 1, idTag: 'TAG1' }, + procedureName: 'startTransaction', + }) + }) + + await it('should build OCPP 2.0.1 payload with transactionEvent procedure', () => { + const result = buildStartTransactionPayload(1, OCPPVersion.VERSION_201, { idTag: 'TAG1' }) + assert.deepStrictEqual(result, { + payload: { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.STARTED, + idToken: { idToken: 'TAG1', type: OCPP20IdTokenEnumType.ISO14443 }, + }, + procedureName: 'transactionEvent', + }) + }) + + await it('should include evseId for OCPP 2.0.x when provided', () => { + const result = buildStartTransactionPayload(1, OCPPVersion.VERSION_201, { + evseId: 2, + idTag: 'TAG1', + }) + assert.deepStrictEqual(result, { + payload: { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.STARTED, + evseId: 2, + idToken: { idToken: 'TAG1', type: OCPP20IdTokenEnumType.ISO14443 }, + }, + procedureName: 'transactionEvent', + }) + }) + + await it('should not include evseId for OCPP 1.6 even if provided', () => { + const result = buildStartTransactionPayload(1, OCPPVersion.VERSION_16, { + evseId: 2, + idTag: 'TAG1', + }) + assert.deepStrictEqual(result, { + payload: { connectorId: 1, idTag: 'TAG1' }, + procedureName: 'startTransaction', + }) + }) + + await it('should omit idTag/idToken when not provided', () => { + const result = buildStartTransactionPayload(1, OCPPVersion.VERSION_201) + assert.deepStrictEqual(result, { + payload: { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.STARTED, + }, + procedureName: 'transactionEvent', + }) + }) + }) + + await describe('buildStopTransactionPayload', async () => { + await it('should build OCPP 1.6 payload with stopTransaction procedure', () => { + const result = buildStopTransactionPayload(12345, OCPPVersion.VERSION_16) + assert.deepStrictEqual(result, { + payload: { transactionId: 12345 }, + procedureName: 'stopTransaction', + }) + }) + + await it('should build OCPP 2.0.1 payload with transactionEvent procedure', () => { + const result = buildStopTransactionPayload('uuid-123', OCPPVersion.VERSION_201, 1) + assert.deepStrictEqual(result, { + payload: { + connectorId: 1, + eventType: OCPP20TransactionEventEnumType.ENDED, + transactionId: 'uuid-123', + }, + procedureName: 'transactionEvent', + }) + }) + + await it('should convert numeric transactionId to string for OCPP 2.0.x', () => { + const result = buildStopTransactionPayload(99, OCPPVersion.VERSION_201, 1) + assert.strictEqual((result.payload as Record).transactionId, '99') + }) + + await it('should omit connectorId for OCPP 2.0.x when not provided', () => { + const result = buildStopTransactionPayload('uuid-123', OCPPVersion.VERSION_201) + assert.strictEqual((result.payload as Record).connectorId, undefined) + }) + }) + + await describe('buildIdToken', async () => { + await it('should build idToken with default ISO14443 type', () => { + assert.deepStrictEqual(buildIdToken('TAG1'), { + idToken: 'TAG1', + type: OCPP20IdTokenEnumType.ISO14443, + }) + }) + + await it('should build idToken with custom type', () => { + assert.deepStrictEqual(buildIdToken('TAG1', OCPP20IdTokenEnumType.CENTRAL), { + idToken: 'TAG1', + type: OCPP20IdTokenEnumType.CENTRAL, + }) + }) + }) +}) diff --git a/ui/web/src/core/UIClient.ts b/ui/web/src/core/UIClient.ts index 18ff2732..b95b9ead 100644 --- a/ui/web/src/core/UIClient.ts +++ b/ui/web/src/core/UIClient.ts @@ -1,10 +1,12 @@ +import type { OCPPVersion } from 'ui-common' + import { + buildAuthorizePayload, + buildStartTransactionPayload, + buildStopTransactionPayload, type ChargingStationOptions, createBrowserWsAdapter, - OCPP20IdTokenEnumType, - OCPP20TransactionEventEnumType, - type OCPP20TransactionEventRequest, - OCPPVersion, + isOCPP20x, ProcedureName, type RequestPayload, type ResponsePayload, @@ -46,10 +48,6 @@ export class UIClient { return UIClient.instance } - private static isOCPP20x (version: OCPPVersion | undefined): boolean { - return version === OCPPVersion.VERSION_20 || version === OCPPVersion.VERSION_201 - } - public async addChargingStations ( template: string, numberOfStations: number, @@ -62,10 +60,14 @@ export class UIClient { }) } - public async authorize (hashId: string, idTag: string): Promise { + public async authorize ( + hashId: string, + idTag: string, + ocppVersion?: OCPPVersion + ): Promise { return this.sendRequest(ProcedureName.AUTHORIZE, { hashIds: [hashId], - idTag, + ...buildAuthorizePayload(idTag, ocppVersion), }) } @@ -180,23 +182,17 @@ export class UIClient { ocppVersion?: OCPPVersion } ): Promise { - if (UIClient.isOCPP20x(options.ocppVersion)) { - return this.transactionEvent(hashId, { - eventType: OCPP20TransactionEventEnumType.STARTED, - evse: - options.evseId != null - ? { connectorId: options.connectorId, id: options.evseId } - : undefined, - idToken: - options.idTag != null - ? { idToken: options.idTag, type: OCPP20IdTokenEnumType.ISO14443 } - : undefined, - }) + const { payload, procedureName } = buildStartTransactionPayload( + options.connectorId, + options.ocppVersion, + { evseId: options.evseId, idTag: options.idTag } + ) + if (procedureName === 'transactionEvent') { + return this.transactionEvent(hashId, payload) } return this.sendRequest(ProcedureName.START_TRANSACTION, { - connectorId: options.connectorId, hashIds: [hashId], - idTag: options.idTag, + ...payload, }) } @@ -227,13 +223,19 @@ export class UIClient { transactionId: number | string | undefined } ): Promise { - if (UIClient.isOCPP20x(options.ocppVersion)) { - return this.transactionEvent(hashId, { - eventType: OCPP20TransactionEventEnumType.ENDED, - transactionId: options.transactionId?.toString(), - }) + if (options.transactionId == null) { + return { + responsesFailed: [ + { + errorMessage: 'transactionId is required', + hashId, + status: ResponseStatus.FAILURE, + }, + ], + status: ResponseStatus.FAILURE, + } } - if (typeof options.transactionId === 'string') { + if (!isOCPP20x(options.ocppVersion) && typeof options.transactionId === 'string') { return { responsesFailed: [ { @@ -245,9 +247,16 @@ export class UIClient { status: ResponseStatus.FAILURE, } } + const { payload, procedureName } = buildStopTransactionPayload( + options.transactionId, + options.ocppVersion + ) + if (procedureName === 'transactionEvent') { + return this.transactionEvent(hashId, payload) + } return this.sendRequest(ProcedureName.STOP_TRANSACTION, { hashIds: [hashId], - transactionId: options.transactionId, + ...payload, }) } @@ -375,7 +384,7 @@ export class UIClient { private async transactionEvent ( hashId: string, - payload: OCPP20TransactionEventRequest + payload: RequestPayload ): Promise { return this.sendRequest(ProcedureName.TRANSACTION_EVENT, { hashIds: [hashId], diff --git a/ui/web/src/shared/composables/useStartTxForm.ts b/ui/web/src/shared/composables/useStartTxForm.ts index 2f561594..7d5784eb 100644 --- a/ui/web/src/shared/composables/useStartTxForm.ts +++ b/ui/web/src/shared/composables/useStartTxForm.ts @@ -66,7 +66,7 @@ export function useStartTxForm (config: StartTxFormConfig): { return false } try { - await $uiClient.authorize(hashId, idTag) + await $uiClient.authorize(hashId, idTag, toValue(ocppVersion)) } catch (error: unknown) { $toast.error('Error at authorizing RFID tag') console.error('Error at authorizing RFID tag:', error) diff --git a/ui/web/src/skins/modern/ModernLayout.vue b/ui/web/src/skins/modern/ModernLayout.vue index 76ef9a7d..e9f2a0c7 100644 --- a/ui/web/src/skins/modern/ModernLayout.vue +++ b/ui/web/src/skins/modern/ModernLayout.vue @@ -69,6 +69,7 @@ v-if="showAuthorizeDialog" :hash-id="showAuthorizeDialog.hashId" :charging-station-id="showAuthorizeDialog.chargingStationId" + :ocpp-version="showAuthorizeDialog.ocppVersion" @close="showAuthorizeDialog = null" /> @@ -158,6 +159,7 @@ const showStartTxDialog = ref(null) const confirmStopSimulator = (): void => { diff --git a/ui/web/src/skins/modern/components/StationCard.vue b/ui/web/src/skins/modern/components/StationCard.vue index 3d056eb7..d874cf39 100644 --- a/ui/web/src/skins/modern/components/StationCard.vue +++ b/ui/web/src/skins/modern/components/StationCard.vue @@ -188,7 +188,7 @@ const props = defineProps<{ const emit = defineEmits<{ 'need-refresh': [] - 'open-authorize': [data: { chargingStationId: string; hashId: string }] + 'open-authorize': [data: { chargingStationId: string; hashId: string; ocppVersion?: OCPPVersion }] 'open-set-url': [data: { chargingStationId: string; hashId: string }] 'open-start-tx': [ data: { @@ -255,6 +255,7 @@ const emitOpenAuthorize = (): void => { emit('open-authorize', { chargingStationId: props.chargingStation.stationInfo.chargingStationId, hashId: props.chargingStation.stationInfo.hashId, + ocppVersion: props.chargingStation.stationInfo.ocppVersion, }) } diff --git a/ui/web/src/skins/modern/components/dialogs/AuthorizeDialog.vue b/ui/web/src/skins/modern/components/dialogs/AuthorizeDialog.vue index 61e8d3c8..664d0640 100644 --- a/ui/web/src/skins/modern/components/dialogs/AuthorizeDialog.vue +++ b/ui/web/src/skins/modern/components/dialogs/AuthorizeDialog.vue @@ -60,6 +60,7 @@