From 331705d19e0f1541720cb72ffe83c0f405cb8bb8 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Sat, 18 Apr 2026 01:10:32 +0200 Subject: [PATCH] feat(ui-cli): add custom JSON payload option for OCPP and transaction commands Add -p, --payload to all 16 OCPP commands and both transaction commands. Supports three input modes: - Inline JSON: -p '{"reason":"PowerUp"}' - File: -p @collections/boot.json - Stdin: echo '{}' | evse-cli ocpp heartbeat -p - Payload is merged beneath command-specific options (--id-tag, etc.) so CLI flags always take precedence. Implementation: - New resolve-payload.ts: async resolver with validation (object check, empty check, JSON parse error with preview) - Payload resolution integrated into runAction try/catch for clean error handling (no stack traces on user input errors) - PAYLOAD_OPTION/PAYLOAD_DESC constants shared via payload.ts - 11 unit tests for resolvePayload (inline, @file, edge cases) - README and SKILL.md updated with examples --- ui/cli/README.md | 10 ++ ui/cli/skills/evse-simulator/SKILL.md | 8 ++ ui/cli/src/commands/action.ts | 19 +++- ui/cli/src/commands/ocpp.ts | 27 +++--- ui/cli/src/commands/payload.ts | 3 + ui/cli/src/commands/resolve-payload.ts | 39 ++++++++ ui/cli/src/commands/transaction.ts | 27 ++++-- ui/cli/tests/resolve-payload.test.ts | 122 +++++++++++++++++++++++++ 8 files changed, 229 insertions(+), 26 deletions(-) create mode 100644 ui/cli/src/commands/resolve-payload.ts create mode 100644 ui/cli/tests/resolve-payload.test.ts diff --git a/ui/cli/README.md b/ui/cli/README.md index cd180d5e..e2c0a811 100644 --- a/ui/cli/README.md +++ b/ui/cli/README.md @@ -214,6 +214,16 @@ evse-cli ocpp data-transfer --vendor-id [--message-id ] [--data ] Other OCPP commands (no extra options): `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `log-status-notification`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `transaction-event`. +All OCPP commands accept `-p, --payload ` to pass a custom JSON payload: + +```shell +evse-cli ocpp boot-notification -p '{"reason":"PowerUp"}' [hashId...] # Inline JSON +evse-cli ocpp boot-notification -p @collections/boot.json [hashId...] # From file +cat boot.json | jq '.reason = "RemoteReset"' | evse-cli ocpp boot-notification -p - [hashId...] # From stdin +``` + +The payload is merged with command-specific options (e.g., `--id-tag`, `--connector-id`). Command options take precedence over payload fields. + #### supervision ```shell diff --git a/ui/cli/skills/evse-simulator/SKILL.md b/ui/cli/skills/evse-simulator/SKILL.md index af5af761..63553be4 100644 --- a/ui/cli/skills/evse-simulator/SKILL.md +++ b/ui/cli/skills/evse-simulator/SKILL.md @@ -118,6 +118,14 @@ evse-cli ocpp data-transfer --vendor-id [--message-id ] [--data ] Other OCPP commands (no extra options): `diagnostics-status-notification`, `firmware-status-notification`, `get-15118-ev-certificate`, `get-certificate-status`, `log-status-notification`, `notify-customer-information`, `notify-report`, `security-event-notification`, `sign-certificate`, `transaction-event`. +All OCPP commands accept `-p, --payload ` for custom JSON payloads: + +```shell +evse-cli ocpp boot-notification -p '{"reason":"PowerUp"}' [hashId...] # Inline +evse-cli ocpp boot-notification -p @payload.json [hashId...] # From file +cat payload.json | evse-cli ocpp boot-notification -p - [hashId...] # From stdin +``` + ### Supervision ```shell diff --git a/ui/cli/src/commands/action.ts b/ui/cli/src/commands/action.ts index 0eb1e51f..4483103e 100644 --- a/ui/cli/src/commands/action.ts +++ b/ui/cli/src/commands/action.ts @@ -14,6 +14,8 @@ import type { GlobalOptions, StationListPayload } from '../types.js' import { executeCommand } from '../client/lifecycle.js' import { loadConfig } from '../config/loader.js' import { createFormatter } from '../output/formatter.js' +import { resolvePayload } from './resolve-payload.js' + export const parseInteger = (value: string): number => { const n = Number.parseInt(value, 10) if (Number.isNaN(n)) { @@ -73,18 +75,25 @@ const resolveShortHashIds = async ( export const runAction = async ( program: Command, procedureName: ProcedureName, - payload: RequestPayload + payload: RequestPayload, + rawPayload?: string ): Promise => { const rootOpts = program.opts() const formatter = createFormatter(rootOpts.json) try { + let mergedPayload = payload + if (rawPayload != null) { + const extra = await resolvePayload(rawPayload) + mergedPayload = { ...extra, ...payload } + } + const config = await loadConfig({ configPath: rootOpts.config, url: rootOpts.serverUrl }) - let resolvedPayload = payload - if (Array.isArray(payload.hashIds) && payload.hashIds.length > 0) { + let resolvedPayload = mergedPayload + if (Array.isArray(mergedPayload.hashIds) && mergedPayload.hashIds.length > 0) { resolvedPayload = { - ...payload, - hashIds: await resolveShortHashIds(payload.hashIds, config), + ...mergedPayload, + hashIds: await resolveShortHashIds(mergedPayload.hashIds, config), } } diff --git a/ui/cli/src/commands/ocpp.ts b/ui/cli/src/commands/ocpp.ts index 17ea825a..d57906db 100644 --- a/ui/cli/src/commands/ocpp.ts +++ b/ui/cli/src/commands/ocpp.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { ProcedureName, type RequestPayload } from 'ui-common' import { parseInteger, runAction } from './action.js' -import { buildHashIdsPayload, pickDefined } from './payload.js' +import { buildHashIdsPayload, PAYLOAD_DESC, PAYLOAD_OPTION, pickDefined } from './payload.js' export const createOcppCommands = (program: Command): Command => { const cmd = new Command('ocpp').description('OCPP protocol commands') @@ -11,12 +11,13 @@ export const createOcppCommands = (program: Command): Command => { .command('authorize [hashIds...]') .description('Request station(s) to send OCPP Authorize') .requiredOption('--id-tag ', 'RFID tag for authorization') - .action(async (hashIds: string[], options: { idTag: string }) => { + .option(PAYLOAD_OPTION, PAYLOAD_DESC) + .action(async (hashIds: string[], options: { idTag: string; payload?: string }) => { const payload: RequestPayload = { idTag: options.idTag, ...buildHashIdsPayload(hashIds), } - await runAction(program, ProcedureName.AUTHORIZE, payload) + await runAction(program, ProcedureName.AUTHORIZE, payload, options.payload) }) cmd @@ -25,10 +26,11 @@ export const createOcppCommands = (program: Command): Command => { .option('--vendor-id ', 'vendor identifier') .option('--message-id ', 'message identifier') .option('--data ', 'data payload (JSON string)') + .option(PAYLOAD_OPTION, PAYLOAD_DESC) .action( async ( hashIds: string[], - options: { data?: string; messageId?: string; vendorId?: string } + options: { data?: string; messageId?: string; payload?: string; vendorId?: string } ) => { const payload: RequestPayload = { ...pickDefined(options as Record, { @@ -38,7 +40,7 @@ export const createOcppCommands = (program: Command): Command => { }), ...buildHashIdsPayload(hashIds), } as RequestPayload - await runAction(program, ProcedureName.DATA_TRANSFER, payload) + await runAction(program, ProcedureName.DATA_TRANSFER, payload, options.payload) } ) @@ -46,12 +48,13 @@ export const createOcppCommands = (program: Command): Command => { .command('meter-values [hashIds...]') .description('Request station(s) to send OCPP MeterValues') .requiredOption('--connector-id ', 'connector ID', parseInteger) - .action(async (hashIds: string[], options: { connectorId: number }) => { + .option(PAYLOAD_OPTION, PAYLOAD_DESC) + .action(async (hashIds: string[], options: { connectorId: number; payload?: string }) => { const payload: RequestPayload = { connectorId: options.connectorId, ...buildHashIdsPayload(hashIds), } - await runAction(program, ProcedureName.METER_VALUES, payload) + await runAction(program, ProcedureName.METER_VALUES, payload, options.payload) }) cmd @@ -60,10 +63,11 @@ export const createOcppCommands = (program: Command): Command => { .requiredOption('--connector-id ', 'connector ID', parseInteger) .requiredOption('--error-code ', 'connector error code') .requiredOption('--status ', 'connector status') + .option(PAYLOAD_OPTION, PAYLOAD_DESC) .action( async ( hashIds: string[], - options: { connectorId: number; errorCode: string; status: string } + options: { connectorId: number; errorCode: string; payload?: string; status: string } ) => { const payload: RequestPayload = { connectorId: options.connectorId, @@ -71,7 +75,7 @@ export const createOcppCommands = (program: Command): Command => { status: options.status, ...buildHashIdsPayload(hashIds), } - await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload) + await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload, options.payload) } ) @@ -134,8 +138,9 @@ export const createOcppCommands = (program: Command): Command => { cmd .command(`${name} [hashIds...]`) .description(description) - .action(async (hashIds: string[]) => { - await runAction(program, procedureName, buildHashIdsPayload(hashIds)) + .option(PAYLOAD_OPTION, PAYLOAD_DESC) + .action(async (hashIds: string[], options: { payload?: string }) => { + await runAction(program, procedureName, buildHashIdsPayload(hashIds), options.payload) }) } diff --git a/ui/cli/src/commands/payload.ts b/ui/cli/src/commands/payload.ts index d99b46e3..e3b67d87 100644 --- a/ui/cli/src/commands/payload.ts +++ b/ui/cli/src/commands/payload.ts @@ -1,5 +1,8 @@ import type { RequestPayload } from 'ui-common' +export const PAYLOAD_OPTION = '-p, --payload ' +export const PAYLOAD_DESC = 'JSON payload: inline string, @filename, or - for stdin' + export const buildHashIdsPayload = (hashIds: string[]): RequestPayload => hashIds.length > 0 ? { hashIds } : {} diff --git a/ui/cli/src/commands/resolve-payload.ts b/ui/cli/src/commands/resolve-payload.ts new file mode 100644 index 00000000..8fe985c6 --- /dev/null +++ b/ui/cli/src/commands/resolve-payload.ts @@ -0,0 +1,39 @@ +import type { RequestPayload } from 'ui-common' + +import { readFile } from 'node:fs/promises' +import process from 'node:process' + +export const resolvePayload = async (value: string): Promise => { + let raw: string + + if (value === '-') { + const { text } = await import('node:stream/consumers') + raw = await text(process.stdin) + } else if (value.startsWith('@')) { + const path = value.slice(1) + if (path.length === 0) { + throw new Error('Missing file path after @') + } + raw = await readFile(path, 'utf8') + } else { + raw = value + } + + raw = raw.trim() + if (raw.length === 0) { + throw new Error('Empty payload') + } + + let parsed: unknown + try { + parsed = JSON.parse(raw) + } catch { + throw new Error(`Invalid JSON payload: ${raw.slice(0, 120)}`) + } + + if (typeof parsed !== 'object' || parsed == null || Array.isArray(parsed)) { + throw new Error('Payload must be a JSON object') + } + + return parsed as RequestPayload +} diff --git a/ui/cli/src/commands/transaction.ts b/ui/cli/src/commands/transaction.ts index ec9fe992..96c155d9 100644 --- a/ui/cli/src/commands/transaction.ts +++ b/ui/cli/src/commands/transaction.ts @@ -2,7 +2,7 @@ import { Command } from 'commander' import { ProcedureName, type RequestPayload } from 'ui-common' import { parseInteger, runAction } from './action.js' -import { buildHashIdsPayload } from './payload.js' +import { buildHashIdsPayload, PAYLOAD_DESC, PAYLOAD_OPTION } from './payload.js' export const createTransactionCommands = (program: Command): Command => { const cmd = new Command('transaction').description('Transaction management') @@ -12,25 +12,32 @@ export const createTransactionCommands = (program: Command): Command => { .description('Start a transaction on station(s)') .requiredOption('--connector-id ', 'connector ID', parseInteger) .requiredOption('--id-tag ', 'RFID tag for authorization') - .action(async (hashIds: string[], options: { connectorId: number; idTag: string }) => { - const payload: RequestPayload = { - connectorId: options.connectorId, - idTag: options.idTag, - ...buildHashIdsPayload(hashIds), + .option(PAYLOAD_OPTION, PAYLOAD_DESC) + .action( + async ( + hashIds: string[], + options: { connectorId: number; idTag: string; payload?: string } + ) => { + const payload: RequestPayload = { + connectorId: options.connectorId, + idTag: options.idTag, + ...buildHashIdsPayload(hashIds), + } + await runAction(program, ProcedureName.START_TRANSACTION, payload, options.payload) } - await runAction(program, ProcedureName.START_TRANSACTION, payload) - }) + ) cmd .command('stop [hashIds...]') .description('Stop a transaction on station(s)') .requiredOption('--transaction-id ', 'transaction ID', parseInteger) - .action(async (hashIds: string[], options: { transactionId: number }) => { + .option(PAYLOAD_OPTION, PAYLOAD_DESC) + .action(async (hashIds: string[], options: { payload?: string; transactionId: number }) => { const payload: RequestPayload = { transactionId: options.transactionId, ...buildHashIdsPayload(hashIds), } - await runAction(program, ProcedureName.STOP_TRANSACTION, payload) + await runAction(program, ProcedureName.STOP_TRANSACTION, payload, options.payload) }) return cmd diff --git a/ui/cli/tests/resolve-payload.test.ts b/ui/cli/tests/resolve-payload.test.ts new file mode 100644 index 00000000..24aa47e5 --- /dev/null +++ b/ui/cli/tests/resolve-payload.test.ts @@ -0,0 +1,122 @@ +import assert from 'node:assert' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, it } from 'node:test' + +import { resolvePayload } from '../src/commands/resolve-payload.js' + +await describe('resolvePayload', async () => { + await describe('inline JSON', async () => { + await it('parses valid JSON object', async () => { + const result = await resolvePayload('{"key":"value"}') + assert.deepStrictEqual(result, { key: 'value' }) + }) + + await it('trims whitespace', async () => { + const result = await resolvePayload(' {"key":"value"} ') + assert.deepStrictEqual(result, { key: 'value' }) + }) + + await it('rejects invalid JSON', async () => { + await assert.rejects( + () => resolvePayload('not json'), + (error: Error) => { + assert.ok(error.message.includes('Invalid JSON payload')) + return true + } + ) + }) + + await it('rejects JSON array', async () => { + await assert.rejects( + () => resolvePayload('[1,2,3]'), + (error: Error) => { + assert.ok(error.message.includes('must be a JSON object')) + return true + } + ) + }) + + await it('rejects JSON string', async () => { + await assert.rejects( + () => resolvePayload('"hello"'), + (error: Error) => { + assert.ok(error.message.includes('must be a JSON object')) + return true + } + ) + }) + + await it('rejects empty string', async () => { + await assert.rejects( + () => resolvePayload(''), + (error: Error) => { + assert.ok(error.message.includes('Empty payload')) + return true + } + ) + }) + + await it('rejects whitespace-only string', async () => { + await assert.rejects( + () => resolvePayload(' '), + (error: Error) => { + assert.ok(error.message.includes('Empty payload')) + return true + } + ) + }) + }) + + await describe('@file', async () => { + let tmpDir: string + + await it('reads JSON from file', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'evse-cli-test-')) + const filePath = join(tmpDir, 'test.json') + writeFileSync(filePath, '{"fromFile":true}', 'utf8') + + const result = await resolvePayload(`@${filePath}`) + assert.deepStrictEqual(result, { fromFile: true }) + + rmSync(tmpDir, { recursive: true }) + }) + + await it('rejects missing file', async () => { + await assert.rejects( + () => resolvePayload('@/nonexistent/path.json'), + (error: Error) => { + assert.ok(error.message.includes('ENOENT')) + return true + } + ) + }) + + await it('rejects empty path after @', async () => { + await assert.rejects( + () => resolvePayload('@'), + (error: Error) => { + assert.ok(error.message.includes('Missing file path after @')) + return true + } + ) + }) + + await it('rejects file with invalid JSON', async () => { + tmpDir = mkdtempSync(join(tmpdir(), 'evse-cli-test-')) + const filePath = join(tmpDir, 'bad.json') + writeFileSync(filePath, 'not json', 'utf8') + + await assert.rejects( + () => resolvePayload(`@${filePath}`), + (error: Error) => { + assert.ok(error.message.includes('Invalid JSON payload')) + return true + } + ) + + rmSync(tmpDir, { recursive: true }) + }) + }) +}) -- 2.53.0