Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/open-ocpp-trace-export.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@ocpp-debugkit/toolkit': minor
---

Write the Open OCPP Trace interchange format. `toOpenOcppTraceRecords()` / `toOpenOcppTraceJsonl()` export any parsed trace as v1.1 records, with `raw` serialized from the stored frame, response `action` back-filled by messageId correlation, and skip-and-flag warnings for events the format cannot represent. A new `ocpp-debugkit convert <file>` CLI command emits the JSONL, carrying trace-level metadata over from JSON Object inputs. Every exported record is validated against the specification's published JSON Schema in CI, and round-trip tests prove export-then-reparse preserves the consumer view and the events.
23 changes: 18 additions & 5 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@

## Active Milestone

**v0.4.0 - Open OCPP Trace Interop (in progress)**
**v0.4.0 - Open OCPP Trace Interop (feature complete)**

v0.3.1 is published to npm (16 detection rules, 15 scenarios, trace diffing,
rich scenario assertions, and the ci/anonymize/diff CLI commands). The interop
milestone reads and writes the shared Open OCPP Trace v1.1 interchange format,
checked against the specification's conformance fixtures. The input half
(#121) has landed; the exporter and `convert` CLI command (#122) remain.
checked against the specification's conformance fixtures: the input adapter
(#121) and the exporter with the `convert` CLI command (#122) have both
landed. Remaining: cut the `v0.4.0` release.

## What's Done

Expand Down Expand Up @@ -236,10 +237,22 @@ checked against the specification's conformance fixtures. The input half
- ✅ `deriveOpenOcppTraceView()` exposes the format's consumer view
- ✅ 15 specification conformance fixtures vendored and asserted in CI

### Open OCPP Trace Exporter + convert CLI (Issue #122)

- ✅ `toOpenOcppTraceRecords()` / `toOpenOcppTraceJsonl()` export any parsed
trace as v1.1 records: `raw` from the stored frame, response `action`
back-filled by correlation, skip-and-flag for events the format cannot
represent
- ✅ `ocpp-debugkit convert <file> [--output]` emits the JSONL, carrying
trace-level metadata over from JSON Object inputs
- ✅ Every exported record validates against the specification's JSON Schema
(vendored) in CI; round-trip tests prove export-then-reparse preserves the
consumer view and the events

## What's Next

1. **v0.4.0 - Open OCPP Trace Interop** - reading (#121) is done; remaining:
the exporter and `convert` CLI command (#122), then the `v0.4.0` release
1. **v0.4.0 release** - both interop halves (#121, #122) are in; cut and
publish `v0.4.0`
2. **v0.5.0 - OCPP 2.0.1 Support** - extend the engine beyond 1.6J: message
set, device model, scenarios, and detection
3. **v1.0.0 - Stable FOSS Ecosystem** - API stabilization, 20+ scenarios, docs
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,10 @@ validate behavior against known scenarios.
- **Replay Engine** — Deterministic, pure replay engine with step forward/back,
jump-to-event, and configurable playback speed.
- **Report Generation** — Export session analysis as Markdown or HTML reports.
- **Open OCPP Trace Interop** - Read the vendor-neutral
- **Open OCPP Trace Interop** - Read and write the vendor-neutral
[Open OCPP Trace](https://github.com/open-ocpp-trace/specification) interchange
format, so traces from other OCPP tools can be inspected and analyzed here.
format, so traces move between DebugKit and other OCPP tools in both
directions.
- **React Components** — Reusable, SSR-safe components for building custom
inspector UIs (SessionTimeline, MessageInspector, FailureSummary,
ReportViewer, ReplayControls).
Expand Down
11 changes: 10 additions & 1 deletion docs/trace-format-spec.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,9 +139,18 @@ it directly. How records are consumed:
- Unknown fields are ignored, so a trace from a later minor version of the
format still parses. The same size and event-count [limits](#limits) apply.

DebugKit is also a producer of the format: `toOpenOcppTraceRecords()` /
`toOpenOcppTraceJsonl()` export any parsed trace as v1.1 records (`raw`
serialized from the stored frame, response `action` back-filled by
correlation), and `ocpp-debugkit convert <file>` does the same from the
command line. Events the format cannot represent (no timestamp, or an unknown
direction, as with the bare array input) are skipped with a warning rather
than invented.

The format is governed independently at
[open-ocpp-trace/specification](https://github.com/open-ocpp-trace/specification),
which ships a conformance suite that DebugKit's parser is checked against.
which ships a conformance suite that DebugKit's parser and exporter are
checked against.

---

Expand Down
15 changes: 10 additions & 5 deletions packages/toolkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,11 @@ report generation, React components, and CLI.

- **Trace Parser** — Parse OCPP 1.6 JSON traces in JSON Object, JSONL, or bare
array format. Safe parsing with size and event-count limits.
- **Open OCPP Trace Interop** - Read the vendor-neutral
[Open OCPP Trace](https://github.com/open-ocpp-trace/specification) format via
`parseOpenOcppTrace()` (and `parseTrace()` auto-detection), checked against the
specification's conformance fixtures.
- **Open OCPP Trace Interop** - Read and write the vendor-neutral
[Open OCPP Trace](https://github.com/open-ocpp-trace/specification) format:
`parseOpenOcppTrace()` (with `parseTrace()` auto-detection) reads it,
`toOpenOcppTraceJsonl()` and the `convert` CLI command emit it, both checked
against the specification's conformance fixtures.
- **Failure Detection** — 16 detection rules (4 critical, 10 warning, 2 info)
covering common failure patterns: failed authorization, connector faults,
station offline, heartbeat timeout, meter value gaps, invalid stop reasons,
Expand Down Expand Up @@ -234,6 +235,9 @@ ocpp-debugkit scenario run failed-auth

# Run an external scenario file
ocpp-debugkit scenario run --file ./my-scenario.json

# Convert any readable trace to Open OCPP Trace v1.1 JSONL
ocpp-debugkit convert trace.json --output trace.openocpp.jsonl
```

## Trace Formats
Expand All @@ -252,7 +256,8 @@ Each event has a `message` field containing a raw OCPP 1.6 JSON array:
`parseTrace()` additionally auto-detects the vendor-neutral
[Open OCPP Trace](https://github.com/open-ocpp-trace/specification) interchange
format (records carrying `messageType` and `direction`); parse it directly with
`parseOpenOcppTrace()`.
`parseOpenOcppTrace()`, and emit it with `toOpenOcppTraceJsonl()` or the
`convert` CLI command.

See the [trace format specification](https://github.com/ocpp-debugkit/toolkit/blob/main/docs/trace-format-spec.md) for full details.

Expand Down
6 changes: 4 additions & 2 deletions packages/toolkit/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -81,8 +81,8 @@
"clean": "rm -rf dist .turbo"
},
"dependencies": {
"zod": "^4.4.3",
"commander": "^13.0.0"
"commander": "^13.0.0",
"zod": "^4.4.3"
},
"peerDependencies": {
"react": "^18.0.0 || ^19.0.0",
Expand All @@ -100,6 +100,8 @@
"@types/node": "^22.0.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"ajv": "^8.20.0",
"ajv-formats": "^3.0.1",
"execa": "^9.0.0",
"typescript": "^5.7.0",
"vitest": "^3.0.0"
Expand Down
54 changes: 53 additions & 1 deletion packages/toolkit/src/cli/cli.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { describe, it, expect } from 'vitest';
import { execa } from 'execa';
import { writeFileSync, mkdtempSync } from 'node:fs';
import { writeFileSync, mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fixtures } from '../core/index.js';
Expand Down Expand Up @@ -299,4 +299,56 @@ describe('CLI integration', () => {
expect(parsed).toHaveProperty('onlyInB');
});
});

describe('convert', () => {
it('converts a JSON Object trace to Open OCPP Trace JSONL with metadata carried over', async () => {
const filePath = writeTempTrace(fixtures.normalSession);
const result = await runCli('convert', filePath);
expect(result.exitCode).toBe(0);

const lines = result.stdout.trim().split('\n');
expect(lines).toHaveLength(fixtures.normalSession.events.length);

const first = JSON.parse(lines[0] ?? '') as Record<string, unknown>;
expect(first.schemaVersion).toBe('1.1');
expect(first.messageType).toBe('CALL');
expect(first.action).toBe('BootNotification');
expect(first.direction).toBe('cp-to-csms');
expect(first.ocppVersion).toBe('1.6');
expect(first.chargePointId).toBe('CS-SYNTHETIC-001');
expect(typeof first.raw).toBe('string');

// Every line is a valid record shape.
for (const line of lines) {
const record = JSON.parse(line) as Record<string, unknown>;
expect(record.schemaVersion).toBe('1.1');
expect(['CALL', 'CALLRESULT', 'CALLERROR']).toContain(record.messageType);
}
});

it('writes to file with --output', async () => {
const filePath = writeTempTrace(fixtures.failedAuth);
const outPath = filePath.replace('trace.json', 'out.jsonl');
const result = await runCli('convert', filePath, '--output', outPath);
expect(result.exitCode).toBe(0);
expect(result.stdout).toContain('Open OCPP Trace written to');
const written = readFileSync(outPath, 'utf8');
expect(written.trim().split('\n')).toHaveLength(fixtures.failedAuth.events.length);
});

it('fails cleanly when no event can be represented (bare array has no timestamps)', async () => {
const dir = mkdtempSync(join(tmpdir(), 'ocpp-test-'));
const filePath = join(dir, 'bare.json');
writeFileSync(filePath, JSON.stringify([[2, 'msg-001', 'Heartbeat', {}]]), 'utf8');
const result = await runCli('convert', filePath);
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain('No events could be converted');
});

it('handles missing file gracefully', async () => {
const result = await runCli('convert', '/nonexistent/trace.json');
expect(result.exitCode).not.toBe(0);
expect(result.stderr).toContain('File not found');
});
});
});
76 changes: 76 additions & 0 deletions packages/toolkit/src/cli/commands/convert.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* `ocpp-debugkit convert <file>` - convert a trace file to the Open OCPP
* Trace v1.1 interchange format (JSONL).
*
* Accepts any input format `parseTrace()` understands, including the Open
* OCPP Trace format itself. When the input is a JSON Object trace, its
* `metadata.ocppVersion` and `metadata.stationId` are carried onto every
* exported record; other input formats have no trace-level metadata to carry.
*
* Converted JSONL goes to stdout (or `--output`); warnings for events the
* format cannot represent go to stderr, so piped output stays clean.
*/

import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import {
parseTrace,
toOpenOcppTraceJsonl,
ParseError,
type OpenOcppTraceExportOptions,
} from '../../core/index.js';
import { CliError, readTraceFile } from '../utils.js';

export interface ConvertOptions {
output?: string;
}

/** Best-effort trace-level metadata from a JSON Object input. */
function extractExportOptions(content: string): OpenOcppTraceExportOptions {
try {
const parsed: unknown = JSON.parse(content);
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) return {};
const metadata = (parsed as Record<string, unknown>).metadata;
if (metadata === null || typeof metadata !== 'object' || Array.isArray(metadata)) return {};
const { ocppVersion, stationId } = metadata as Record<string, unknown>;
return {
...(typeof ocppVersion === 'string' && { ocppVersion }),
...(typeof stationId === 'string' && { chargePointId: stationId }),
};
} catch {
return {}; // JSONL input; no trace-level metadata wrapper.
}
}

export async function convertCommand(file: string, options: ConvertOptions): Promise<void> {
const content = readTraceFile(file);

let result;
try {
result = parseTrace(content);
} catch (e) {
if (e instanceof ParseError || e instanceof Error) {
throw new CliError(e.message);
}
throw new CliError('Unknown error during parsing');
}

const { jsonl, warnings } = toOpenOcppTraceJsonl(result.events, extractExportOptions(content));

for (const warning of [...result.warnings, ...warnings]) {
console.error(`warning: ${warning.message}`);
}

if (jsonl === '') {
throw new CliError(
'No events could be converted: the Open OCPP Trace format requires a timestamp and a known direction on every record.',
);
}

if (options.output) {
writeFileSync(resolve(options.output), jsonl, 'utf8');
console.log(`Open OCPP Trace written to: ${options.output}`);
} else {
process.stdout.write(jsonl);
}
}
10 changes: 10 additions & 0 deletions packages/toolkit/src/cli/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from './commands/scenario.js';
import { ciCommand } from './commands/ci.js';
import { anonymizeCommand } from './commands/anonymize.js';
import { convertCommand } from './commands/convert.js';
import { diffCommand } from './commands/diff.js';

const program = new Command();
Expand Down Expand Up @@ -84,6 +85,15 @@ program
await anonymizeCommand(file, options);
});

// convert
program
.command('convert <file>')
.description('Convert a trace file to Open OCPP Trace v1.1 JSONL')
.option('-o, --output <file>', 'Write converted trace to file (default: stdout)')
.action(async (file: string, options: { output?: string }) => {
await convertCommand(file, options);
});

// diff
program
.command('diff <a> <b>')
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ Conformance fixtures from the Open OCPP Trace specification, vendored here so
DebugKit's parser is checked against them in CI.

- **Source:** [open-ocpp-trace/specification](https://github.com/open-ocpp-trace/specification)
(`fixtures/`). All data is synthetic.
(`fixtures/`, and `schema/trace-v1.schema.json` vendored alongside for
validating exporter output). All data is synthetic.
- Each `<name>/trace.jsonl` is a trace in the shared format. Each
`<name>/expected.json` is the consumer view a conformant implementation
derives from it: correlation pairs, effective actions, unanswered calls, and
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://open-ocpp-trace.github.io/schema/trace-v1.schema.json",
"title": "OcppTraceRecord",
"type": "object",
"required": ["schemaVersion", "timestamp", "transport", "direction", "messageType"],
"additionalProperties": true,
"properties": {
"schemaVersion": { "type": "string" },
"timestamp": { "type": "string", "format": "date-time" },
"ocppVersion": { "type": "string" },
"transport": { "type": "string", "enum": ["json", "soap"] },
"chargePointId": { "type": "string" },
"connectorId": { "type": "integer", "minimum": 0 },
"direction": { "type": "string", "enum": ["cp-to-csms", "csms-to-cp"] },
"messageType": {
"type": "string",
"enum": ["CALL", "CALLRESULT", "CALLERROR"]
},
"messageId": { "type": "string" },
"action": { "type": "string" },
"payload": {},
"raw": { "type": "string" },
"error": {
"type": "object",
"additionalProperties": true,
"properties": {
"code": { "type": "string" },
"description": { "type": "string" },
"details": {}
}
},
"meta": { "type": "object" }
},
"allOf": [
{
"if": { "properties": { "messageType": { "const": "CALL" } } },
"then": { "required": ["action"] }
},
{
"if": { "properties": { "messageType": { "const": "CALLERROR" } } },
"then": { "required": ["error"] },
"else": { "properties": { "error": false } }
}
]
}
5 changes: 5 additions & 0 deletions packages/toolkit/src/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@ export type {
OpenOcppTraceDirection,
OpenOcppTraceMessageType,
} from './openOcppTrace.js';
export { toOpenOcppTraceRecords, toOpenOcppTraceJsonl } from './openOcppTraceExport.js';
export type {
OpenOcppTraceExportOptions,
OpenOcppTraceExportResult,
} from './openOcppTraceExport.js';

// Normalizer
export {
Expand Down
Loading
Loading