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
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
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)) {
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),
}
}
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')
.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
.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>, {
}),
...buildHashIdsPayload(hashIds),
} as RequestPayload
- await runAction(program, ProcedureName.DATA_TRANSFER, payload)
+ await runAction(program, ProcedureName.DATA_TRANSFER, payload, options.payload)
}
)
.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
.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,
status: options.status,
...buildHashIdsPayload(hashIds),
}
- await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload)
+ await runAction(program, ProcedureName.STATUS_NOTIFICATION, payload, options.payload)
}
)
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)
})
}
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 } : {}
--- /dev/null
+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
+}
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')
.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
--- /dev/null
+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 })
+ })
+ })
+})