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
18 changes: 18 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
name: CI

on:
push:
branches: [main]
pull_request:

jobs:
conformance:
name: Validate schema and fixtures
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/setup-node@v5
with:
node-version: 22
- run: npm ci
- run: npm test
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules/
24 changes: 24 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,30 @@ consume it (analyzers, debuggers, CI pipelines, reproducible bug reports).
This repository is neutral ground: the format lives here so that no single
project's release cadence or roadmap governs it.

## Repository layout

| Path | Contents |
| --- | --- |
| [`schema/`](./schema) | The machine-readable record schema (major version 1, currently v1.1). |
| [`fixtures/`](./fixtures) | Reference traces, each with the consumer view a conformant implementation derives from it. |
| [`conformance/`](./conformance) | What conformance means for producers and consumers, and the corpus self-check. |

## Validating the corpus

```
npm ci
npm test
```

The self-check validates every fixture record against the schema, verifies
`raw` fidelity, and recomputes each fixture's expected consumer view. CI runs
it on every push and pull request.

The format's current definition (v1.1) was designed in
[shiv3/ocpp-cp-simulator#188](https://github.com/shiv3/ocpp-cp-simulator/issues/188);
its prose specification lives in that repository's `docs/trace-format.md`
until it migrates here.

## License

- Specification text (this README and the prose in `docs/`) is licensed under
Expand Down
82 changes: 82 additions & 0 deletions conformance/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Conformance

How to check an implementation against this repository, and the rules the
fixtures pin down. A JSON Schema tells you a record is shaped legally; these
fixtures tell you two implementations agree on what a trace means.

## What conformance means

A **producer** is conformant when the records it emits:

1. validate against [`../schema/trace-v1.schema.json`](../schema/trace-v1.schema.json);
2. carry the verbatim frame text in `raw` whenever the original bytes are
available, and that text decodes to the same frame the record decomposes;
3. set `action` on every CALL, and, when setting the optional `action` on a
CALLRESULT or CALLERROR, set it equal to the action of the CALL it
correlates with;
4. keep extensions inside `meta`, never in undeclared top-level fields.

A **consumer** is conformant when it:

1. accepts any record that validates against the schema and ignores unknown
fields (forward compatibility within a major version);
2. derives the consumer view defined below, reproducing each fixture's
`expected.json` exactly.

## Correlation rule

A CALLRESULT or CALLERROR correlates with the most recent preceding CALL in
the trace that satisfies all three conditions:

1. same `messageId`;
2. opposite `direction`;
3. not already correlated with an earlier response.

A response with no such CALL is an **orphan response**. A CALL that no
response has correlated with by the end of the trace is an **unanswered
call**. The effective `action` of a correlated response is its CALL's
`action`; an orphan response has no effective action unless its record
carries one explicitly.

## The consumer view (`expected.json`)

| Field | Meaning |
| --- | --- |
| `schemaVersion` | Schema version of the trace's records. |
| `counts.records` | Total records in the trace. |
| `counts.calls` / `counts.callResults` / `counts.callErrors` | Records per message type. |
| `records[]` | One entry per record, in trace order. |
| `records[].index` | Zero-based position in the trace. |
| `records[].messageType` | Echoed from the record. |
| `records[].messageId` | Echoed from the record. |
| `records[].action` | The effective action: explicit for a CALL, derived by correlation for a response. Absent when underivable. |
| `records[].correlatesWith` | For a correlated response, the index of its CALL. Absent otherwise. |
| `unansweredCalls` | Indexes of CALLs never correlated, ascending. |
| `orphanResponses` | Indexes of responses that correlate with nothing, ascending. |

Fields the view does not repeat (timestamps, payloads, directions, identity
fields) are covered by the schema and by the `raw` fidelity rule; the view
pins only what a consumer must compute.

## Checking an implementation

For each directory under [`../fixtures`](../fixtures): parse `trace.jsonl`
(one record per line), derive the consumer view, and compare it structurally
to `expected.json`. Any mismatch is a conformance failure. Producers can
additionally round-trip: emit a trace, feed it to a conformant consumer, and
confirm the view is what they intended.

## Checking this repository

```
npm ci
npm test
```

[`validate.mjs`](./validate.mjs) is the corpus self-check and the reference
consumer for the rules above. For every fixture it validates each record
against the schema, verifies `raw` decodes to the fields the record
decomposes, recomputes the consumer view with the correlation rule, and
requires an exact match with `expected.json`. CI runs it on every push and
pull request, so a fixture and its expected view cannot drift apart
unnoticed.
195 changes: 195 additions & 0 deletions conformance/validate.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// Corpus self-check for the Open OCPP Trace fixtures.
//
// This script is the reference consumer for the format's derivation rules:
// it validates every fixture record against the JSON Schema, checks the
// invariants the schema alone cannot express (raw fidelity, action
// consistency), recomputes the consumer view from trace.jsonl using the
// correlation rule in conformance/README.md, and requires it to match
// expected.json exactly.
//
// Usage: npm test (or: node conformance/validate.mjs)

import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';

const root = join(dirname(fileURLToPath(import.meta.url)), '..');
const schema = JSON.parse(readFileSync(join(root, 'schema', 'trace-v1.schema.json'), 'utf8'));

const ajv = new Ajv2020({ allErrors: true });
addFormats(ajv);
const validateRecord = ajv.compile(schema);

const MESSAGE_TYPE = { 2: 'CALL', 3: 'CALLRESULT', 4: 'CALLERROR' };

let failures = 0;
function problem(fixture, message) {
failures += 1;
console.error(`FAIL ${fixture}: ${message}`);
}

function deepEqual(a, b) {
if (Object.is(a, b)) return true;
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
if (Array.isArray(a) !== Array.isArray(b)) return false;
const keysA = Object.keys(a);
const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false;
return keysA.every((k) => Object.hasOwn(b, k) && deepEqual(a[k], b[k]));
}

// The normative correlation rule: a CALLRESULT or CALLERROR correlates with
// the most recent preceding CALL that has the same messageId, travels in the
// opposite direction, and is not yet answered.
function buildConsumerView(records) {
const view = records.map((r, index) => {
const entry = { index, messageType: r.messageType, messageId: r.messageId };
if (r.messageType === 'CALL') entry.action = r.action;
return entry;
});
const answered = new Set();
const orphanResponses = [];
for (let i = 0; i < records.length; i++) {
const r = records[i];
if (r.messageType === 'CALL') continue;
let match = -1;
for (let j = i - 1; j >= 0; j--) {
const c = records[j];
if (
c.messageType === 'CALL' &&
c.messageId === r.messageId &&
c.direction !== r.direction &&
!answered.has(j)
) {
match = j;
break;
}
}
if (match === -1) {
orphanResponses.push(i);
} else {
answered.add(match);
view[i].action = records[match].action;
view[i].correlatesWith = match;
}
}
const unansweredCalls = records
.map((r, i) => ({ r, i }))
.filter(({ r, i }) => r.messageType === 'CALL' && !answered.has(i))
.map(({ i }) => i);
const counts = {
records: records.length,
calls: records.filter((r) => r.messageType === 'CALL').length,
callResults: records.filter((r) => r.messageType === 'CALLRESULT').length,
callErrors: records.filter((r) => r.messageType === 'CALLERROR').length,
};
return {
schemaVersion: records[0]?.schemaVersion ?? 'unknown',
counts,
records: view,
unansweredCalls,
orphanResponses,
};
}

function checkRawFidelity(fixture, record, line) {
if (record.raw === undefined) return;
let frame;
try {
frame = JSON.parse(record.raw);
} catch {
problem(fixture, `line ${line}: raw is present but does not parse as JSON`);
return;
}
if (!Array.isArray(frame)) {
problem(fixture, `line ${line}: raw does not decode to an OCPP-J array`);
return;
}
if (MESSAGE_TYPE[frame[0]] !== record.messageType) {
problem(fixture, `line ${line}: raw frame kind ${frame[0]} contradicts messageType ${record.messageType}`);
}
if (record.messageId !== undefined && frame[1] !== record.messageId) {
problem(fixture, `line ${line}: raw messageId contradicts record messageId`);
}
if (record.messageType === 'CALL') {
if (frame[2] !== record.action) {
problem(fixture, `line ${line}: raw action contradicts record action`);
}
if (record.payload !== undefined && !deepEqual(frame[3], record.payload)) {
problem(fixture, `line ${line}: raw payload contradicts record payload`);
}
} else if (record.messageType === 'CALLRESULT') {
if (record.payload !== undefined && !deepEqual(frame[2], record.payload)) {
problem(fixture, `line ${line}: raw payload contradicts record payload`);
}
} else if (record.messageType === 'CALLERROR') {
if (record.error?.code !== undefined && frame[2] !== record.error.code) {
problem(fixture, `line ${line}: raw error code contradicts record error.code`);
}
if (record.error?.description !== undefined && frame[3] !== record.error.description) {
problem(fixture, `line ${line}: raw error description contradicts record error.description`);
}
if (record.error?.details !== undefined && !deepEqual(frame[4], record.error.details)) {
problem(fixture, `line ${line}: raw error details contradicts record error.details`);
}
}
}

const fixturesDir = join(root, 'fixtures');
const fixtureNames = readdirSync(fixturesDir).filter((name) =>
statSync(join(fixturesDir, name)).isDirectory(),
);

if (fixtureNames.length === 0) {
console.error('FAIL: no fixtures found');
process.exit(1);
}

for (const name of fixtureNames.sort()) {
const problemsBefore = failures;
const dir = join(fixturesDir, name);
const lines = readFileSync(join(dir, 'trace.jsonl'), 'utf8').split('\n').filter(Boolean);
const expected = JSON.parse(readFileSync(join(dir, 'expected.json'), 'utf8'));

const records = [];
lines.forEach((text, i) => {
let record;
try {
record = JSON.parse(text);
} catch {
problem(name, `line ${i + 1}: not valid JSON`);
return;
}
if (!validateRecord(record)) {
problem(name, `line ${i + 1}: schema violation ${ajv.errorsText(validateRecord.errors)}`);
}
checkRawFidelity(name, record, i + 1);
records.push(record);
});

const view = buildConsumerView(records);

// A response that carries its own action must agree with its correlated CALL.
view.records.forEach((entry, i) => {
const own = records[i]?.action;
if (entry.correlatesWith !== undefined && own !== undefined && own !== entry.action) {
problem(name, `record ${i}: explicit action ${own} contradicts correlated CALL action ${entry.action}`);
}
});

if (!deepEqual(view, expected)) {
problem(name, 'recomputed consumer view does not match expected.json');
}

if (failures === problemsBefore) {
console.log(`ok ${name} (${view.counts.records} records, ${view.unansweredCalls.length} unanswered, ${view.orphanResponses.length} orphans)`);
}
}

if (failures > 0) {
console.error(`\n${failures} problem(s) found`);
process.exit(1);
}
console.log(`\nall ${fixtureNames.length} fixtures conform`);
57 changes: 57 additions & 0 deletions fixtures/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
# Fixtures

Reference traces in the Open OCPP Trace Format, each paired with the consumer
view a conformant implementation must derive from it.

Layout per fixture:

```
<name>/
trace.jsonl # the trace: one format record per line
expected.json # the consumer view derived from it (see ../conformance/README.md)
```

## Corpus

| Fixture | Records | Calls | Results | Errors | Unanswered | Situation |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| [`normal-session`](./normal-session) | 22 | 11 | 11 | 0 | 0 | Complete charging session: boot, authorize, start transaction, meter values, stop transaction. |
| [`failed-auth`](./failed-auth) | 14 | 7 | 7 | 0 | 0 | Authorization rejected by the CSMS; no transaction is started. |
| [`connector-fault`](./connector-fault) | 20 | 10 | 10 | 0 | 0 | Connector faults mid-session; the transaction stops with a fault reason. |
| [`station-offline`](./station-offline) | 14 | 7 | 7 | 0 | 0 | Transaction starts but the trace ends with no StopTransaction. |
| [`unexpected-stop-reason`](./unexpected-stop-reason) | 18 | 9 | 9 | 0 | 0 | StopTransaction carries an unusual but legal stop reason. |
| [`meter-value-gap`](./meter-value-gap) | 16 | 8 | 8 | 0 | 0 | Transaction with no MeterValues between start and stop. |
| [`invalid-stop-reason`](./invalid-stop-reason) | 18 | 9 | 9 | 0 | 0 | StopTransaction carries a stop reason outside the OCPP 1.6 enumeration. |
| [`unexpected-start`](./unexpected-start) | 8 | 4 | 4 | 0 | 0 | StartTransaction with no preceding BootNotification or Authorize. |
| [`status-transition-violation`](./status-transition-violation) | 10 | 5 | 5 | 0 | 0 | Connector status jumps from Available directly to Finishing. |
| [`diagnostics-failure`](./diagnostics-failure) | 8 | 4 | 4 | 0 | 0 | DiagnosticsStatusNotification reports a failed diagnostics run. |
| [`slow-csms-response`](./slow-csms-response) | 4 | 2 | 2 | 0 | 0 | CSMS answers a BootNotification after a 15 second delay. |
| [`meter-anomaly`](./meter-anomaly) | 14 | 7 | 7 | 0 | 0 | Meter readings decrease during an active transaction. |
| [`short-session`](./short-session) | 12 | 6 | 6 | 0 | 0 | Full session lasting only a few seconds. |
| [`heartbeat-irregular`](./heartbeat-irregular) | 8 | 4 | 4 | 0 | 0 | Heartbeat cadence deviates from the interval the CSMS requested. |
| [`unresponsive-csms`](./unresponsive-csms) | 3 | 2 | 1 | 0 | 1 | BootNotification is never answered; a later Heartbeat is. |
| [`orphan-response`](./orphan-response) | 3 | 1 | 2 | 0 | 0 | A response whose CALL is not in the trace (capture started mid-session), so it correlates with nothing. |

## Provenance and properties

- All data is synthetic. Station identifiers, transaction ids, and idTag
values are invented and carry no real-world information.
- The corpus was seeded from the OCPP DebugKit scenario suite (OCPP 1.6J),
converted record by record into this format.
- Responses deliberately omit the optional `action` field, so a consumer must
derive it by correlation to reproduce `expected.json`.
- `raw` is present on every record and decodes to exactly the frame the
record decomposes.
- `connectorId` is populated on requests whose OCPP 1.6 payload carries a
top-level `connectorId`.

## Known coverage gaps

Contributions are welcome for what this corpus does not yet exercise:

- CALLERROR records (the `error` object rules are specified but uncovered)
- `messageId` reuse within one trace
- traces spanning multiple charge points
- OCPP 2.0.1 sessions
- SOAP transport records
- malformed frames, once their representation is settled in the specification
Loading