]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ui-cli): add custom JSON payload option for OCPP and transaction commands
authorJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 23:10:32 +0000 (01:10 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Fri, 17 Apr 2026 23:10:32 +0000 (01:10 +0200)
Add -p, --payload <json|@file|-> 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
ui/cli/skills/evse-simulator/SKILL.md
ui/cli/src/commands/action.ts
ui/cli/src/commands/ocpp.ts
ui/cli/src/commands/payload.ts
ui/cli/src/commands/resolve-payload.ts [new file with mode: 0644]
ui/cli/src/commands/transaction.ts
ui/cli/tests/resolve-payload.test.ts [new file with mode: 0644]

index cd180d5e8bf396d80ae03a675068255b6515d3c9..e2c0a81164d2183380cefcbca6ebfb0f82020b43 100644 (file)
@@ -214,6 +214,16 @@ evse-cli ocpp data-transfer --vendor-id <id> [--message-id <id>] [--data <json>]
 
 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 <json|@file|->` 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
index af5af7612ffed5e2b6cb80815b8e5c10ee8e3617..63553be40ac48132b2733d79ad45fcc0a689576f 100644 (file)
@@ -118,6 +118,14 @@ evse-cli ocpp data-transfer --vendor-id <id> [--message-id <id>] [--data <json>]
 
 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 <json|@file|->` 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
index 0eb1e51fef566f9e61a6e8abe082a8165c5243e4..4483103e2d8069857c761cf2f5755add2bc5d9af 100644 (file)
@@ -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<void> => {
   const rootOpts = program.opts<GlobalOptions>()
   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),
       }
     }
 
index 17ea825ac22abd50d6ef0a828aa6e35121a0b235..d57906dbdbbf0692ef1906ceeea5da6e9a592ada 100644 (file)
@@ -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 <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 <id>', 'vendor identifier')
     .option('--message-id <id>', 'message identifier')
     .option('--data <json>', '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<string, unknown>, {
@@ -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 <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 <id>', 'connector ID', parseInteger)
     .requiredOption('--error-code <code>', 'connector error code')
     .requiredOption('--status <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)
       })
   }
 
index d99b46e391944774b9116edbfe04a6b1cd6a2751..e3b67d874663d0d6c46eb3ed9f9728bcbd6f41c9 100644 (file)
@@ -1,5 +1,8 @@
 import type { RequestPayload } from 'ui-common'
 
+export const PAYLOAD_OPTION = '-p, --payload <json|@file|->'
+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 (file)
index 0000000..8fe985c
--- /dev/null
@@ -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<RequestPayload> => {
+  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
+}
index ec9fe99249e5896552be4b513108eebe3e49958d..96c155d9ceed6aacf8651e472512ae19a80c9664 100644 (file)
@@ -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 <id>', 'connector ID', parseInteger)
     .requiredOption('--id-tag <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 <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 (file)
index 0000000..24aa47e
--- /dev/null
@@ -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 })
+    })
+  })
+})