From 064899cb7498f82e0f83002ddd3a54decfc2f36d Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Wed, 8 Jul 2026 00:21:42 +0300 Subject: [PATCH] docs: protocol scope + trace-format design spike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolve all foundational design decisions before core implementation (Milestone 0.5 — Protocol & Trace-Format Design Phase). Deliverables: - 9 Architecture Decision Records (docs/adr/): - ADR-0001: OCPP version scope (1.6 JSON primary) - ADR-0002: Input trace formats (JSON Object + JSONL) - ADR-0003: Canonical internal event model - ADR-0004: Message direction representation - ADR-0005: Timestamp normalization - ADR-0006: Session correlation strategy - ADR-0007: Malformed trace handling - ADR-0008: Browser-local processing & privacy - ADR-0009: Future protocol-version extensibility - Trace format specification (docs/trace-format-spec.md) - 3 synthetic trace fixtures (packages/core/src/__fixtures__/): - normal-session.json (complete session, no failures) - failed-auth.json (failed authorization) - connector-fault.json (connector fault during session) - Proposed canonical types (packages/core/src/types.ts) - 28 validation tests proving fixtures conform to the proposed event model - Updated AGENTS.md with design decisions summary - Updated CURRENT_STATE.md with M0.5 progress Closes #18 --- AGENTS.md | 16 + CURRENT_STATE.md | 53 ++- docs/adr/0001-ocpp-version-scope.md | 34 ++ docs/adr/0002-input-trace-formats.md | 68 ++++ docs/adr/0003-canonical-event-model.md | 72 ++++ docs/adr/0004-message-direction.md | 56 +++ docs/adr/0005-timestamp-normalization.md | 55 +++ docs/adr/0006-session-correlation.md | 68 ++++ docs/adr/0007-malformed-trace-handling.md | 83 +++++ docs/adr/0008-browser-local-processing.md | 65 ++++ docs/adr/0009-protocol-extensibility.md | 66 ++++ docs/adr/README.md | 38 +++ docs/trace-format-spec.md | 224 ++++++++++++ packages/core/package.json | 34 ++ .../src/__fixtures__/connector-fault.json | 230 +++++++++++++ .../core/src/__fixtures__/failed-auth.json | 172 ++++++++++ .../core/src/__fixtures__/normal-session.json | 260 ++++++++++++++ packages/core/src/fixtures.test.ts | 318 ++++++++++++++++++ packages/core/src/fixtures/index.ts | 22 ++ packages/core/src/index.ts | 6 + packages/core/src/types.ts | 143 ++++++++ packages/core/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 23 files changed, 2090 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0001-ocpp-version-scope.md create mode 100644 docs/adr/0002-input-trace-formats.md create mode 100644 docs/adr/0003-canonical-event-model.md create mode 100644 docs/adr/0004-message-direction.md create mode 100644 docs/adr/0005-timestamp-normalization.md create mode 100644 docs/adr/0006-session-correlation.md create mode 100644 docs/adr/0007-malformed-trace-handling.md create mode 100644 docs/adr/0008-browser-local-processing.md create mode 100644 docs/adr/0009-protocol-extensibility.md create mode 100644 docs/adr/README.md create mode 100644 docs/trace-format-spec.md create mode 100644 packages/core/package.json create mode 100644 packages/core/src/__fixtures__/connector-fault.json create mode 100644 packages/core/src/__fixtures__/failed-auth.json create mode 100644 packages/core/src/__fixtures__/normal-session.json create mode 100644 packages/core/src/fixtures.test.ts create mode 100644 packages/core/src/fixtures/index.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/tsconfig.json diff --git a/AGENTS.md b/AGENTS.md index 9bc6141..83bd690 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,22 @@ test/ # e.g. test/core-coverage See [`CURRENT_STATE.md`](./CURRENT_STATE.md) for what has been built, what is in progress, and what is next. +## Design Decisions + +The protocol and trace-format design is documented in +[`docs/adr/`](./docs/adr/) (Architecture Decision Records) and +[`docs/trace-format-spec.md`](./docs/trace-format-spec.md). Key decisions: + +- **OCPP 1.6 JSON** is the primary protocol for v0.1. OCPP 2.0.1 is deferred but the architecture supports it (ADR-0001). +- **Two trace formats:** JSON Object (metadata + events array) and JSONL (one event per line). Bare arrays accepted as degenerate (ADR-0002). +- **Canonical `Event` type** with `id`, `messageId`, `timestamp`, `direction`, `messageType`, `action`, `payload`, `rawMessage` (ADR-0003). +- **Direction** is explicit (`CS_TO_CSMS`, `CSMS_TO_CS`, `UNKNOWN`), inferred from action name when missing (ADR-0004). +- **Timestamps** normalized to epoch milliseconds. Missing timestamps are `null`. Out-of-order events are flagged, not silently reordered (ADR-0005). +- **Sessions** derived by correlating `transactionId`, with `connectorId` and `stationId` as secondary groupings (ADR-0006). +- **Malformed traces:** structural errors fail-fast; event-level errors skip-and-flag; size/count limits enforced (ADR-0007). +- **Browser-local processing:** all trace processing client-side. No auto-upload. No telemetry on trace content (ADR-0008). +- **Extensibility:** version-aware, not version-hardcoded. Adding OCPP 2.0.1 is additive (ADR-0009). + ## Contributor Guide See [`CONTRIBUTING.md`](./CONTRIBUTING.md) for setup, conventions, and the diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 2107960..09f30c0 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,20 +8,22 @@ ## Active Milestone -**M0 — Repository & Tooling Foundation** +**M0.5 — Protocol & Trace-Format Design Phase** -Setting up the professional monorepo skeleton with CI, linting, testing, -release tooling, GitHub metadata, and agent onboarding docs — before any -feature code lands. +Design spike resolving all foundational design decisions before core +implementation. Produces ADRs, trace format specification, and synthetic +trace fixtures that validate the proposed internal event model. ## What's Done ### GitHub Infrastructure + - ✅ GitHub milestones created (M0, M0.5, v0.1.0, v0.2.0, v0.3.0, v1.0.0) - ✅ GitHub labels created (type, package, priority, workflow) -- ✅ 11 tracking issues created and assigned to M0 milestone +- ✅ Tracking issues created for M0 and M0.5 ### Monorepo & Tooling (PR #12) + - ✅ Root `package.json` with pnpm workspace - ✅ `pnpm-workspace.yaml` (`packages/*`, `apps/*`) - ✅ `tsconfig.base.json` (strict TypeScript config) @@ -33,38 +35,67 @@ feature code lands. - ✅ `AGENTS.md` + `CURRENT_STATE.md` (initial versions) ### CI & Release (PR #13) + - ✅ `.github/workflows/ci.yml` — lint, format check, typecheck, test, build on PR + push - ✅ `.github/workflows/release.yml` — Changesets version PR, npm publish, ecosystem tag + GitHub release - ✅ `.changeset/config.json` — public access, base branch main ### GitHub Templates (PR #14) + - ✅ `.github/PULL_REQUEST_TEMPLATE.md` - ✅ `.github/ISSUE_TEMPLATE/bug_report.md` - ✅ `.github/ISSUE_TEMPLATE/feature_request.md` - ✅ `.github/ISSUE_TEMPLATE/scenario_request.md` -### Community Docs (in progress — this PR) +### Community Docs (PR #15) + - ✅ `CONTRIBUTING.md` (setup, conventions, PR process, AI-assisted dev section) - ✅ `CODE_OF_CONDUCT.md` (Contributor Covenant 2.1) - ✅ `ROADMAP.md` (milestone summary) - ✅ `README.md` (description, badges, architecture, quickstart, support, links) - ✅ `SECURITY.md` (vulnerability reporting, security principles) +### Protocol & Trace-Format Design (in progress — this PR) + +- ✅ 9 ADRs covering all design decisions: + - ADR-0001: OCPP version scope (1.6 JSON primary) + - ADR-0002: Input trace formats (JSON Object + JSONL) + - ADR-0003: Canonical internal event model + - ADR-0004: Message direction representation + - ADR-0005: Timestamp normalization + - ADR-0006: Session correlation strategy + - ADR-0007: Malformed trace handling + - ADR-0008: Browser-local processing & privacy + - ADR-0009: Future protocol-version extensibility +- ✅ `docs/trace-format-spec.md` — full trace format specification +- ✅ 3 synthetic trace fixtures in `packages/core/src/__fixtures__/`: + - `normal-session.json` — complete charging session (no failures) + - `failed-auth.json` — failed authorization (expects `FAILED_AUTHORIZATION`) + - `connector-fault.json` — connector fault during session (expects `CONNECTOR_FAULT`) +- ✅ `packages/core/src/types.ts` — proposed canonical types (`Event`, `Trace`, `Session`, etc.) +- ✅ `packages/core/src/fixtures/index.ts` — fixture registry +- ✅ `packages/core/src/fixtures.test.ts` — 28 validation tests proving fixtures conform to the proposed event model + ## What's Next -1. **M0 complete** → maintainer reviews and merges PRs #12–#15 -2. Add required status checks to branch protection (after CI runs on main) -3. Proceed to M0.5 (Protocol & Trace-Format Design Phase) +1. **M0.5 complete** → maintainer reviews and merges this PR +2. Proceed to v0.1.0 (Inspector MVP): + - Issue #13: Core data model + trace parser + event normalizer + - Issue #14: Core timeline + failure detection + summarizer + validator + - Issue #15: Core public API export + package config + - Issue #16: Scenarios package (format + 5 initial scenarios) + - Issue #17: Reporter package (Markdown report generator) + - Issue #18: CLI package (scaffold + inspect + report + scenario commands) ## Known Blockers / Decisions Pending -- None currently. +- None currently. All design decisions resolved in ADRs. ## Package Status Table | Package | Status | Version | |---------|--------|---------| -| `@ocpp-debugkit/core` | not started | — | +| `@ocpp-debugkit/core` | in progress (types + fixtures) | 0.0.0 | | `@ocpp-debugkit/scenarios` | not started | — | | `@ocpp-debugkit/reporter` | not started | — | | `@ocpp-debugkit/cli` | not started | — | diff --git a/docs/adr/0001-ocpp-version-scope.md b/docs/adr/0001-ocpp-version-scope.md new file mode 100644 index 0000000..25d1f00 --- /dev/null +++ b/docs/adr/0001-ocpp-version-scope.md @@ -0,0 +1,34 @@ +# ADR-0001: OCPP Version Scope — 1.6 JSON Primary + +## Status + +Accepted + +## Context + +OCPP has multiple versions in production use (1.6, 2.0.1) and two transport +encodings (SOAP and JSON). OCPP 1.6 JSON is the most widely deployed variant +in modern EV charging infrastructure — it is the default for nearly all +new charge point and CSMS implementations. OCPP 2.0.1 adoption is growing but +not yet dominant. SOAP is legacy. + +The tool must start with a focused scope. Supporting all versions and transports +from day one would dilute quality and delay the first release. However, the +internal model must not preclude adding 2.0.1 later. + +## Decision + +**v0.1 supports OCPP 1.6 JSON only.** + +- The parser understands OCPP 1.6 JSON message format: `[MessageTypeId, UniqueId, Action, Payload]` for Call, `[MessageTypeId, UniqueId, Payload]` for CallResult, `[MessageTypeId, UniqueId, ErrorCode, ErrorDescription, ErrorDetails]` for CallError. +- The event model and trace format are designed to be version-aware (an `ocppVersion` field exists in trace metadata) so that 2.0.1 support can be added without breaking changes. +- SOAP is not supported and is not planned. +- OCPP 2.0.1 is explicitly out of scope for v0.1 but the architecture does not prevent its addition in a future version. + +## Consequences + +- v0.1 parser, normalizer, and detection rules are built for OCPP 1.6 message shapes only. +- The trace format includes an `ocppVersion` field (default `"1.6"`) so future traces can declare their version. +- The `Event` type's `action` field uses OCPP 1.6 action names (e.g., `BootNotification`, `Authorize`, `StartTransaction`). +- When OCPP 2.0.1 is added, it will require a new parser variant and potentially new detection rules, but the `Event` model and trace format will remain stable. +- Users with OCPP 2.0.1 traces will receive a clear "unsupported version" message, not silent misinterpretation. diff --git a/docs/adr/0002-input-trace-formats.md b/docs/adr/0002-input-trace-formats.md new file mode 100644 index 0000000..f5dd8c6 --- /dev/null +++ b/docs/adr/0002-input-trace-formats.md @@ -0,0 +1,68 @@ +# ADR-0002: Input Trace Formats — JSON Object + JSONL + +## Status + +Accepted + +## Context + +Users capture OCPP traces from different sources: WebSocket proxies, CSMS +logs, network captures, manual reconstruction. These sources produce data in +different shapes. The parser must accept the most common formats without +requiring users to manually transform their data. + +Two formats are prevalent: + +1. **JSON Object** — a structured file with metadata and an events array. Suitable for curated trace files, scenario fixtures, and saved debug sessions. +2. **JSONL (JSON Lines)** — one event per line. Suitable for streaming captures, log files, and real-time traces from CSMS logs. + +Some users may also paste a single OCPP message or a bare JSON array of messages, but these are secondary cases handled as degenerate forms of the above. + +## Decision + +**`parseTrace()` accepts two formats: JSON Object and JSONL.** + +### JSON Object format + +```json +{ + "traceId": "string (optional)", + "metadata": { + "stationId": "string (optional)", + "ocppVersion": "1.6", + "source": "string (optional)" + }, + "events": [ + { + "timestamp": "ISO 8601 string (optional)", + "direction": "CS_TO_CSMS | CSMS_TO_CS", + "message": [2, "unique-id", "Action", { ...payload }] + } + ] +} +``` + +### JSONL format + +Each line is a single event object (same shape as elements in the `events` array above). No top-level metadata wrapper. Station ID and OCPP version are inferred from event content (e.g., `BootNotification` payload) or left unknown. + +### Detection strategy + +The parser detects format by: +1. Attempting JSON parse of the entire input → if it yields an object with an `events` array, treat as JSON Object format. +2. If full JSON parse fails, split by newlines and parse each non-empty line as JSON → JSONL format. +3. If a single JSON array is provided (bare `[[2, "id", "Action", {}], ...]`), treat each element as a raw OCPP message with unknown direction and timestamp. + +### Size limits + +- Maximum input size: 10 MB. +- Maximum event count: 10,000 events. +- These limits are enforced before parsing begins. + +## Consequences + +- Users can paste traces from CSMS logs (JSONL) or load curated files (JSON Object) without transformation. +- JSONL traces lack top-level metadata; station ID and version are inferred from message content where possible. +- The parser has a clear detection strategy with no ambiguity. +- Bare OCPP message arrays (no wrapper) are supported as a convenience but lose direction/timestamp info — the UI will show these as "unknown direction" and "unknown time". +- Size and count limits protect against accidental denial-of-service from very large files. diff --git a/docs/adr/0003-canonical-event-model.md b/docs/adr/0003-canonical-event-model.md new file mode 100644 index 0000000..531385f --- /dev/null +++ b/docs/adr/0003-canonical-event-model.md @@ -0,0 +1,72 @@ +# ADR-0003: Canonical Internal Event Model + +## Status + +Accepted + +## Context + +OCPP 1.6 JSON messages arrive as bare arrays: `[2, "id", "Action", {payload}]`. +To build timelines, detect failures, and generate reports, the tool needs a +normalized internal representation that: + +- Captures all information from the raw message. +- Adds derived metadata (direction, timestamp, message type). +- Is easy to query, filter, and correlate. +- Remains stable as new OCPP versions are supported. + +## Decision + +**The canonical internal `Event` type is:** + +```typescript +interface Event { + /** Generated unique event ID (sequential, stable within a parse). */ + id: string; + /** OCPP UniqueId from the message array. */ + messageId: string; + /** Normalized timestamp in epoch milliseconds. null if missing. */ + timestamp: number | null; + /** Direction of the message. */ + direction: Direction; + /** OCPP message type. */ + messageType: MessageType; + /** OCPP action name (e.g., "BootNotification"). Present only for Call messages. */ + action: string | null; + /** OCPP payload object. */ + payload: unknown; + /** Error code, present only for CallError messages. */ + errorCode: string | null; + /** Error description, present only for CallError messages. */ + errorDescription: string | null; + /** The original raw OCPP message array, unmodified. */ + rawMessage: unknown; +} + +type Direction = 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN'; + +type MessageType = 'Call' | 'CallResult' | 'CallError'; +``` + +### Mapping from raw OCPP message + +| OCPP array shape | messageType | action | payload | errorCode | +|---|---|---|---|---| +| `[2, id, action, payload]` | `Call` | `action` | `payload` | `null` | +| `[3, id, payload]` | `CallResult` | `null` | `payload` | `null` | +| `[4, id, errorCode, errorDesc, errorDetails]` | `CallError` | `null` | `errorDetails` | `errorCode` | + +### Event ID generation + +Event IDs are generated as `evt-` (e.g., `evt-0001`, +`evt-0002`) based on the event's position in the trace. This is stable across +parses of the same trace file and human-readable in debug output. + +## Consequences + +- Every event has a consistent shape regardless of OCPP message type. +- The `rawMessage` field preserves the original data for the message inspector UI. +- `action` is `null` for CallResult/CallError — the action must be correlated via `messageId` to the originating Call. +- `timestamp` is `null` when the trace entry doesn't include one — the timeline builder handles this (see ADR-0005). +- `payload` is `unknown` at this layer — Zod schema validation happens in `validateMessage()` (v0.1), not in the normalizer. +- The `Event` type is the foundation for `Session`, `TimelineEntry`, and `Failure` types in v0.1. diff --git a/docs/adr/0004-message-direction.md b/docs/adr/0004-message-direction.md new file mode 100644 index 0000000..2abddb6 --- /dev/null +++ b/docs/adr/0004-message-direction.md @@ -0,0 +1,56 @@ +# ADR-0004: Message Direction Representation + +## Status + +Accepted + +## Context + +OCPP 1.6 JSON is a bidirectional protocol over WebSocket. A charging station +(Charge Point / CS) sends messages to the CSMS, and the CSMS sends messages +back. Understanding which side initiated a message is critical for debugging: + +- A `BootNotification` sent CS→CSMS is normal. +- A `BootNotification` response sent CSMS→CS is the reply. +- Direction determines whether a message is a request or a response. + +However, many trace capture methods (network proxies, log files) may not +explicitly record direction. The tool must handle both cases. + +## Decision + +**Direction is an explicit field in the trace entry, with an `UNKNOWN` fallback.** + +```typescript +type Direction = 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN'; +``` + +### Trace entry includes direction + +Each event in a trace file (JSON Object or JSONL) includes a `direction` field: +```json +{ "direction": "CS_TO_CSMS", "message": [2, "id", "BootNotification", {}] } +``` + +### Inference when direction is missing + +When `direction` is missing or `"UNKNOWN"`, the normalizer infers direction from message type and action: + +1. **Call messages (type 2):** Direction is inferred from the action: + - CS→CSMS actions: `BootNotification`, `Authorize`, `StartTransaction`, `StopTransaction`, `Heartbeat`, `StatusNotification`, `MeterValues`, `DataTransfer`, `FirmwareStatusNotification`, `DiagnosticsStatusNotification`. + - CSMS→CS actions: `Reset`, `RemoteStartTransaction`, `RemoteStopTransaction`, `GetConfiguration`, `ChangeConfiguration`, `ChangeAvailability`, `ClearCache`, `UnlockConnector`, `GetLocalListVersion`, `SendLocalList`, `GetDiagnostics`, `UpdateFirmware`, `TriggerMessage`. + - Bidirectional actions (`DataTransfer`): remains `UNKNOWN` if not specified. + +2. **CallResult and CallError messages (types 3 and 4):** Direction is the reverse of the originating Call. The normalizer correlates by `messageId` — if the originating Call's direction is known, the response direction is the opposite. + +### When inference is not possible + +If direction cannot be inferred (e.g., a CallResult without a matching Call, or a `DataTransfer` Call with no direction), the event retains `UNKNOWN` direction. The UI displays this as "Unknown direction" and the detection rules treat it conservatively (no direction-based failure is triggered on `UNKNOWN`). + +## Consequences + +- Traces with explicit direction are the gold standard — no inference needed. +- Traces without direction (bare message arrays) still work — the tool infers where possible and marks the rest `UNKNOWN`. +- The inference logic is a static mapping of OCPP 1.6 actions, maintained in the normalizer. +- `UNKNOWN` direction does not cause failures but limits detection accuracy — the UI encourages users to provide direction info. +- Bidirectional actions like `DataTransfer` require explicit direction in the trace. diff --git a/docs/adr/0005-timestamp-normalization.md b/docs/adr/0005-timestamp-normalization.md new file mode 100644 index 0000000..2a6ecf1 --- /dev/null +++ b/docs/adr/0005-timestamp-normalization.md @@ -0,0 +1,55 @@ +# ADR-0005: Timestamp Normalization + +## Status + +Accepted + +## Context + +OCPP 1.6 messages do not inherently carry timestamps — the protocol messages +themselves are stateless JSON. Timestamps come from the trace capture layer +(CSMS logs, WebSocket proxy, network capture). This means: + +- Some trace entries have ISO 8601 timestamps. +- Some have Unix epoch timestamps. +- Some have no timestamp at all. +- Timestamps may be out of order (log buffering, clock skew). +- Some messages (CallResult/CallError) may share a timestamp with their Call. + +The timeline builder needs a consistent, ordered view of events. How the tool +handles missing, malformed, and out-of-order timestamps directly affects the +accuracy of failure detection (e.g., "station offline during session" depends +on time gaps). + +## Decision + +**Timestamps are normalized to epoch milliseconds (number). Missing or unparseable timestamps are `null`.** + +### Accepted input formats + +1. **ISO 8601 string** — e.g., `"2024-01-15T10:30:00.000Z"`, `"2024-01-15T10:30:00+02:00"`. Parsed via `Date.parse()`. +2. **Unix epoch number** — e.g., `1705312200000` (milliseconds) or `1705312200` (seconds, detected when value < 10¹²). +3. **Missing** — the `timestamp` field is absent, `null`, or empty string → event timestamp is `null`. + +### Timeline ordering + +1. Events with valid timestamps are sorted chronologically (ascending). +2. Events with `null` timestamps are placed at their original position in the trace (preserving capture order) and flagged in the timeline with a "missing timestamp" indicator. +3. If a CallResult/CallError has `null` timestamp but its matching Call has a timestamp, the response inherits the Call's timestamp for ordering purposes (with a note that it was inferred). + +### Out-of-order timestamps + +Out-of-order events are **not silently reordered**. The timeline preserves the original trace order but flags out-of-order timestamps. The detection engine can then account for both the trace order and the chronological order. The UI shows both the trace position and the timestamp, highlighting inversions. + +### Clock skew + +Clock skew between CS and CSMS is not corrected in v0.1. Events are ordered by their raw timestamp value. A future version may add NTP-based correction or relative-time normalization. + +## Consequences + +- `Event.timestamp` is `number | null` — always epoch milliseconds or `null`. +- The timeline builder handles `null` timestamps gracefully without dropping events. +- Out-of-order detection is a first-class concern — the timeline flags it. +- CallResult timestamps can be inferred from their Call for ordering — this is documented, not silent. +- No clock skew correction in v0.1 — a known limitation documented in the trace format spec. +- Detection rules that depend on time gaps (e.g., "station offline") use the normalized timestamps and must handle `null` gracefully. diff --git a/docs/adr/0006-session-correlation.md b/docs/adr/0006-session-correlation.md new file mode 100644 index 0000000..7a92d12 --- /dev/null +++ b/docs/adr/0006-session-correlation.md @@ -0,0 +1,68 @@ +# ADR-0006: Session Correlation Strategy + +## Status + +Accepted + +## Context + +A trace may contain messages from multiple charging sessions, connectors, or +even stations. To build meaningful timelines and detect failures, the tool +must correlate events into sessions. In OCPP 1.6: + +- **Station identity** is the charge point identity. In a WebSocket deployment, this is typically the URL path (e.g., `/ocpp/CS-001`). In a trace, it may appear in `BootNotification` payload (`chargePointSerialNumber`) or trace metadata. +- **Connector identity** is the `connectorId` field in messages (integer, 0 = charge point as a whole, 1+ = individual connectors). +- **Transaction identity** is the `transactionId` assigned by the CSMS in the `StartTransaction` response and referenced in `StopTransaction` and `MeterValues`. +- **Session** is a DebugKit concept — a logical grouping of events for one charging session on one connector. + +## Decision + +**Sessions are derived by correlating transaction IDs, with connector and station as secondary groupings.** + +### Correlation fields + +| Field | Source | Used for | +|---|---|---| +| `stationId` | Trace metadata, or `BootNotification` payload `chargePointSerialNumber` | Top-level grouping | +| `connectorId` | Event payload `connectorId` field | Sub-grouping within a station | +| `transactionId` | `StartTransaction` response payload, referenced by `StopTransaction` and `MeterValues` | Primary session key | + +### Session derivation algorithm + +1. **Extract station ID** from trace metadata. If absent, infer from the first `BootNotification` Call's payload (`chargePointSerialNumber`). If still absent, use `"unknown"`. + +2. **Find transaction boundaries:** Scan for `StartTransaction` Call messages. Each `StartTransaction` Call + its `CallResult` (which contains `transactionId`) marks the start of a session. + +3. **Match `StopTransaction`:** Find `StopTransaction` calls whose payload contains the matching `transactionId`. This marks the end of the session. + +4. **Associate intermediate events:** Events with a `connectorId` and timestamps between start and stop are associated with the session. `MeterValues` and `StatusNotification` events with matching `connectorId` and timestamps within the session window are included. + +5. **Handle orphaned events:** Events that cannot be associated with a transaction (e.g., `BootNotification`, `Heartbeat`, `Authorize` without a subsequent `StartTransaction`) are grouped into a "pre-session" or "inter-session" bucket per station. + +### Session type + +```typescript +interface Session { + sessionId: string; // generated: "session---" + stationId: string; + connectorId: number | null; + transactionId: number | null; + startTime: number | null; // from StartTransaction timestamp + endTime: number | null; // from StopTransaction timestamp + events: Event[]; // all events in this session + status: 'active' | 'completed' | 'aborted'; +} +``` + +### Multi-station traces + +A trace may contain events from multiple stations (e.g., a CSMS log). Sessions are grouped first by `stationId`, then by transaction. The UI presents a station selector when multiple stations are present. + +## Consequences + +- Sessions are derived, not explicitly declared — the tool reconstructs them from message content. +- Traces without `BootNotification` will have `stationId: "unknown"` unless metadata is provided. +- Traces without `StartTransaction` will not have well-defined sessions — events are grouped into a pre-session bucket. +- `transactionId` is the primary correlation key — traces where it is missing or inconsistent will have degraded session detection. +- The session concept is extensible — future versions can add session-level metrics (duration, energy delivered, etc.). +- This design handles single-station traces (the common case for the Inspector UI) and multi-station traces (CSMS logs) without separate code paths. diff --git a/docs/adr/0007-malformed-trace-handling.md b/docs/adr/0007-malformed-trace-handling.md new file mode 100644 index 0000000..cbffd07 --- /dev/null +++ b/docs/adr/0007-malformed-trace-handling.md @@ -0,0 +1,83 @@ +# ADR-0007: Malformed Trace Handling + +## Status + +Accepted + +## Context + +Trace files are untrusted input. They may contain: + +- Invalid JSON (syntax errors). +- Valid JSON that doesn't conform to the expected shape. +- OCPP messages with missing fields (e.g., a Call with only 3 elements instead of 4). +- Truncated messages. +- Unknown OCPP actions. +- Payloads that don't match the OCPP 1.6 schema. +- Extremely large inputs (intentional or accidental). +- Deeply nested JSON (parser bomb). + +The tool must handle these gracefully — never crash, never execute untrusted code, and always provide a clear error message to the user. + +## Decision + +**Three-tier error strategy: skip-and-flag for individual events, fail-fast for structural errors, hard limits for size and count.** + +### Tier 1: Structural errors (fail-fast) + +These cause the entire parse to fail with a clear error message: + +- Input is not valid JSON at all (neither JSON Object nor JSONL). +- JSON Object format but no `events` array. +- Input exceeds size limit (10 MB). +- Event count exceeds limit (10,000). +- JSON nesting depth exceeds limit (100 levels). + +Error message format: `"Failed to parse trace: "` — no internal file paths or stack traces exposed. + +### Tier 2: Event-level errors (skip-and-flag) + +Individual events that are malformed are skipped and collected as warnings: + +- Event is not an object. +- Event's `message` field is not an array. +- Message array has fewer than 3 elements. +- MessageTypeId is not 2, 3, or 4. +- Message array structure doesn't match its MessageTypeId (e.g., Call with 3 elements). +- Timestamp is present but unparseable (event still loaded with `null` timestamp). +- Direction is present but not a valid value (set to `UNKNOWN`). + +Each skipped event produces a warning object: +```typescript +interface ParseWarning { + index: number; // position in the trace + message: string; // human-readable description + rawInput?: string; // truncated raw input (first 200 chars) +} +``` + +The parser returns `{ events: Event[], warnings: ParseWarning[] }`. The UI displays warnings in a non-blocking banner. + +### Tier 3: Content validation (skip-and-flag, separate pass) + +After normalization, `validateMessage()` checks payloads against OCPP 1.6 schemas: + +- Unknown action names — flagged but not skipped (the action may be from a future OCPP version or a vendor extension). +- Payload shape mismatches — flagged with details (missing required fields, wrong types). +- These are collected as validation warnings, separate from parse warnings. + +### Input sanitization + +- All JSON parsing uses `JSON.parse()` in a try/catch — no `eval()`, no `Function()`. +- No prototype pollution: parsed objects are treated as plain data. The normalizer constructs fresh `Event` objects, never mutating the parsed input. +- Payloads are stored as `unknown` and only accessed via validated paths in downstream functions. +- Regex patterns used in validation are checked for ReDoS vulnerability (no catastrophic backtracking). + +## Consequences + +- The parser never crashes on malformed input — it either fails fast with a clear message or skips individual events with warnings. +- Users see exactly which events were skipped and why, without losing the entire trace. +- Size and count limits protect against accidental or malicious oversized input. +- The two-pass approach (parse → validate) separates structural correctness from OCPP schema compliance. +- `parseTrace()` returns a result object, not just an array — callers must check `warnings`. +- The UI can show a "3 events skipped" banner with expandable details. diff --git a/docs/adr/0008-browser-local-processing.md b/docs/adr/0008-browser-local-processing.md new file mode 100644 index 0000000..53afd61 --- /dev/null +++ b/docs/adr/0008-browser-local-processing.md @@ -0,0 +1,65 @@ +# ADR-0008: Browser-Local Processing & Privacy + +## Status + +Accepted + +## Context + +OCPP traces may contain sensitive operational data: station identifiers, +transaction IDs, idTag values (RFID card identifiers), meter readings, +firmware versions, and network timing information. When a user loads a trace +into the Inspector web app, they are trusting the tool with this data. + +The tool must process traces entirely client-side. No trace data should be +sent to any server. This is both a privacy commitment and a practical design +constraint — the tool has no backend. + +## Decision + +**All trace processing happens in the browser. No automatic uploading. No server-side parsing. No telemetry on trace content.** + +### Processing boundary + +- Trace parsing, normalization, timeline building, failure detection, and report generation all run client-side in the browser. +- The Next.js app is statically generated — no API routes process trace data. +- File upload reads the file locally via the File API — the content never leaves the browser. +- Paste input is processed in-memory — no network request is made. +- Report export (Markdown/HTML) is generated client-side and downloaded via a Blob URL. + +### Data that does NOT leave the browser + +- Raw trace file content. +- Parsed events and normalized data. +- Session timelines and failure analysis. +- Generated reports. +- Any field within a trace (idTag, transactionId, stationId, etc.). + +### Data that MAY leave the browser + +- Page navigation events (Vercel Analytics — page URL, referrer, country). No trace content. +- Error reports if the user explicitly opts in (future feature, not in v0.1). + +### Future anonymize command + +The CLI `anonymize` command (planned for v0.3) will strip or hash sensitive fields from a trace: +- `idTag` values → hashed or replaced with sequential identifiers. +- `chargePointSerialNumber` → hashed. +- `meterValue` readings → kept (not sensitive) or optionally rounded. +- IP addresses (if present in trace metadata) → removed. + +Anonymization is an explicit user action — the tool never auto-anonymizes user data. The original trace is never modified; anonymization produces a new file. + +### Committed artifacts policy + +Trace fixtures, sample data, test data, and examples in the repository are **synthetic** — they contain no real station IDs, transaction IDs, idTags, or personal data. This is enforced during development (see security checklist) and is separate from the browser-local processing rule. + +## Consequences + +- The web app works offline once loaded (no API calls during trace processing). +- No backend infrastructure is needed for the Inspector — only static hosting. +- Users can safely load real production traces into the web app without data leakage. +- Vercel Analytics tracks page views only, never trace content. +- The anonymize command (v0.3) is additive — it gives users a tool to share traces safely, not a filter applied automatically. +- The CLI processes traces locally on the user's machine — same privacy guarantees. +- Error messages never include trace content in logs or telemetry. diff --git a/docs/adr/0009-protocol-extensibility.md b/docs/adr/0009-protocol-extensibility.md new file mode 100644 index 0000000..a7f871d --- /dev/null +++ b/docs/adr/0009-protocol-extensibility.md @@ -0,0 +1,66 @@ +# ADR-0009: Future Protocol-Version Extensibility + +## Status + +Accepted + +## Context + +OCPP 1.6 is the primary target for v0.1, but OCPP 2.0.1 is the next +generation and adoption is growing. The tool's internal model must accommodate +2.0.1 without a breaking rewrite, while not over-engineering for a future +that may differ from expectations. + +Key differences between 1.6 and 2.0.1: + +- 2.0.1 uses a different message structure (still JSON over WebSocket, but with different actions and payload shapes). +- 2.0.1 introduces new message types (e.g., `Request`, `Response`, `EventNotification`). +- 2.0.1 has richer device management (charging station as a group of EVSEs, each with connectors). +- 2.0.1 has variable monitoring and reporting. + +## Decision + +**The architecture is version-aware, not version-hardcoded. Extensibility is structural, not speculative.** + +### Version field in trace format + +The trace format includes `metadata.ocppVersion` (default `"1.6"`). The parser checks this field and dispatches to the appropriate version-specific parser. If the version is unsupported, it returns a clear error. + +### Version-aware event model + +The `Event` type (ADR-0003) is version-agnostic: +- `action` is a string — any OCPP action name from any version. +- `payload` is `unknown` — version-specific schema validation happens separately. +- `messageType` uses generic names (`Call`, `CallResult`, `CallError`) that map to OCPP 1.6's message type IDs. OCPP 2.0.1's message types map into the same three categories. + +### Version-specific modules + +When OCPP 2.0.1 support is added: + +1. A new parser variant (`parseOcpp2Message()`) handles 2.0.1 message shapes. +2. New Zod schemas validate 2.0.1 payloads. +3. New detection rules cover 2.0.1-specific failure patterns. +4. The `Event` type, `Session` type, timeline builder, and reporter remain unchanged. +5. The trace format remains unchanged — only `ocppVersion` differs. + +### What is NOT done for 2.0.1 in v0.1 + +- No 2.0.1 schemas or parsers. +- No 2.0.1 detection rules. +- No 2.0.1 fixtures. +- No abstract "protocol plugin" system — the dispatch is a simple if/switch on version. + +### Extensibility principles + +1. **Add, don't modify.** New versions add new parsers/schemas/rules. Existing ones are not modified. +2. **No premature abstraction.** No plugin system, no protocol interface, no factory pattern — until there are at least two implementations that prove the abstraction. +3. **Stable core types.** `Event`, `Session`, `Failure`, `TimelineEntry` are the stable core. Version-specific code produces these types. +4. **Version in metadata, not in events.** Each event doesn't carry its OCPP version — the trace's `ocppVersion` applies to all events in that trace. + +## Consequences + +- Adding OCPP 2.0.1 is an additive change: new parser, new schemas, new rules — no breaking changes to existing code. +- The `Event` type is the stability contract — downstream code (timeline, reporter, UI) doesn't need to know the OCPP version. +- No over-engineering in v0.1 — the dispatch is a simple conditional, not a plugin architecture. +- Users get a clear "unsupported OCPP version" error for 2.0.1 traces in v0.1, not silent misinterpretation. +- The trace format is forward-compatible — a future 2.0.1 trace uses the same structure with `ocppVersion: "2.0.1"`. diff --git a/docs/adr/README.md b/docs/adr/README.md new file mode 100644 index 0000000..3505d5a --- /dev/null +++ b/docs/adr/README.md @@ -0,0 +1,38 @@ +# Architecture Decision Records + +This directory contains ADRs for OCPP DebugKit. Each ADR documents a +significant architectural decision, its context, and its consequences. + +## Index + +| ADR | Title | Status | +|-----|-------|--------| +| [ADR-0001](0001-ocpp-version-scope.md) | OCPP Version Scope — 1.6 JSON Primary | Accepted | +| [ADR-0002](0002-input-trace-formats.md) | Input Trace Formats — JSON Object + JSONL | Accepted | +| [ADR-0003](0003-canonical-event-model.md) | Canonical Internal Event Model | Accepted | +| [ADR-0004](0004-message-direction.md) | Message Direction Representation | Accepted | +| [ADR-0005](0005-timestamp-normalization.md) | Timestamp Normalization | Accepted | +| [ADR-0006](0006-session-correlation.md) | Session Correlation Strategy | Accepted | +| [ADR-0007](0007-malformed-trace-handling.md) | Malformed Trace Handling | Accepted | +| [ADR-0008](0008-browser-local-processing.md) | Browser-Local Processing & Privacy | Accepted | +| [ADR-0009](0009-protocol-extensibility.md) | Future Protocol-Version Extensibility | Accepted | + +## Format + +Each ADR follows: + +``` +# ADR-NNNN: Title + +## Status +Accepted | Superseded by ADR-XXXX | Deprecated + +## Context +Why this decision was needed. + +## Decision +What was decided. + +## Consequences +What follows from this decision. +``` diff --git a/docs/trace-format-spec.md b/docs/trace-format-spec.md new file mode 100644 index 0000000..12967f9 --- /dev/null +++ b/docs/trace-format-spec.md @@ -0,0 +1,224 @@ +# OCPP DebugKit — Trace Format Specification + +> Version: 1.0 · OCPP 1.6 JSON + +This document defines the trace formats accepted by OCPP DebugKit's +`parseTrace()` function. See [ADR-0002](adr/0002-input-trace-formats.md) for +the rationale behind these format choices. + +--- + +## Overview + +DebugKit accepts two trace formats: + +| Format | Description | Use case | +|--------|-------------|----------| +| **JSON Object** | Structured file with metadata + events array | Curated traces, scenario fixtures, saved sessions | +| **JSONL** | One event per line | CSMS logs, streaming captures, real-time traces | + +A third degenerate form — a bare JSON array of raw OCPP messages — is accepted +as a convenience but has limited metadata. + +--- + +## JSON Object Format + +```json +{ + "traceId": "trace-001", + "metadata": { + "stationId": "CS-SYNTHETIC-001", + "ocppVersion": "1.6", + "source": "csms-log" + }, + "events": [ + { + "timestamp": "2024-01-15T10:30:00.000Z", + "direction": "CS_TO_CSMS", + "message": [2, "msg-001", "BootNotification", { + "chargePointVendor": "SyntheticVendor", + "chargePointModel": "SM-100", + "chargePointSerialNumber": "CS-SYNTHETIC-001", + "firmwareVersion": "1.0.0" + }] + } + ] +} +``` + +### Fields + +#### Top-level + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `traceId` | string | no | Unique identifier for the trace. Auto-generated if absent. | +| `metadata` | object | no | Trace-level metadata. | +| `events` | array | **yes** | Array of event objects. Must not be empty. | + +#### `metadata` + +| Field | Type | Required | Default | Description | +|-------|------|----------|---------|-------------| +| `stationId` | string | no | `"unknown"` | Charge point identity. | +| `ocppVersion` | string | no | `"1.6"` | OCPP version. Only `"1.6"` supported in v0.1. | +| `source` | string | no | — | Origin of the trace (e.g., `"csms-log"`, `"proxy"`). | + +#### Event object + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `timestamp` | string \| number | no | ISO 8601 string or Unix epoch (ms or s). `null` if missing. | +| `direction` | string | no | `"CS_TO_CSMS"`, `"CSMS_TO_CS"`, or `"UNKNOWN"`. Inferred if absent (see [ADR-0004](adr/0004-message-direction.md)). | +| `message` | array | **yes** | Raw OCPP 1.6 JSON message array. | + +--- + +## JSONL Format + +Each line is a JSON object with the same shape as an event in the `events` +array: + +```jsonl +{"timestamp":"2024-01-15T10:30:00.000Z","direction":"CS_TO_CSMS","message":[2,"msg-001","BootNotification",{"chargePointVendor":"SyntheticVendor","chargePointModel":"SM-100","chargePointSerialNumber":"CS-SYNTHETIC-001","firmwareVersion":"1.0.0"}]} +{"timestamp":"2024-01-15T10:30:00.500Z","direction":"CSMS_TO_CS","message":[3,"msg-001",{"currentTime":"2024-01-15T10:30:00.500Z","interval":300,"status":"Accepted"}]} +``` + +- No top-level metadata wrapper. +- Station ID and OCPP version are inferred from message content (e.g., + `BootNotification` payload) or left as defaults. +- Blank lines are ignored. + +--- + +## Bare Array Format (degenerate) + +A JSON array of raw OCPP message arrays, with no event wrapper: + +```json +[ + [2, "msg-001", "BootNotification", {"chargePointSerialNumber": "CS-001"}], + [3, "msg-001", {"status": "Accepted"}] +] +``` + +- Direction is inferred from action name (see [ADR-0004](adr/0004-message-direction.md)). +- Timestamp is `null` for all events. +- This format is a convenience for quick testing — not recommended for + production use. + +--- + +## OCPP 1.6 JSON Message Structure + +OCPP 1.6 JSON uses WebSocket text frames containing JSON arrays. There are +three message types: + +### Call (MessageTypeId = 2) + +A request from one side to the other. + +``` +[2, UniqueId, Action, Payload] +``` + +| Index | Field | Type | Description | +|-------|-------|------|-------------| +| 0 | MessageTypeId | `2` | Always 2 for Call. | +| 1 | UniqueId | string | Unique message identifier. | +| 2 | Action | string | OCPP action name (e.g., `"BootNotification"`). | +| 3 | Payload | object | Request payload. | + +### CallResult (MessageTypeId = 3) + +A successful response to a Call. + +``` +[3, UniqueId, Payload] +``` + +| Index | Field | Type | Description | +|-------|-------|------|-------------| +| 0 | MessageTypeId | `3` | Always 3 for CallResult. | +| 1 | UniqueId | string | Matches the Call's UniqueId. | +| 2 | Payload | object | Response payload. | + +### CallError (MessageTypeId = 4) + +An error response to a Call. + +``` +[4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails] +``` + +| Index | Field | Type | Description | +|-------|-------|------|-------------| +| 0 | MessageTypeId | `4` | Always 4 for CallError. | +| 1 | UniqueId | string | Matches the Call's UniqueId. | +| 2 | ErrorCode | string | OCPP error code (e.g., `"InternalError"`). | +| 3 | ErrorDescription | string | Human-readable error description. | +| 4 | ErrorDetails | any | Additional error details (may be empty object). | + +--- + +## Limits + +| Limit | Value | Enforced | +|-------|-------|----------| +| Maximum input size | 10 MB | Before parsing | +| Maximum event count | 10,000 | After parsing | +| Maximum JSON nesting depth | 100 | During parsing | + +Inputs exceeding these limits produce a hard error. See +[ADR-0007](adr/0007-malformed-trace-handling.md). + +--- + +## Timestamp Formats + +The `timestamp` field accepts: + +| Format | Example | Handling | +|--------|---------|----------| +| ISO 8601 (UTC) | `"2024-01-15T10:30:00.000Z"` | Parsed via `Date.parse()` | +| ISO 8601 (offset) | `"2024-01-15T10:30:00+02:00"` | Parsed, normalized to UTC | +| Unix epoch (ms) | `1705312200000` | Used directly | +| Unix epoch (s) | `1705312200` | Detected (value < 10¹²), × 1000 | +| Missing / null | — | Event timestamp is `null` | + +See [ADR-0005](adr/0005-timestamp-normalization.md) for ordering and +out-of-order handling. + +--- + +## Direction Values + +| Value | Meaning | +|-------|---------| +| `"CS_TO_CSMS"` | Charge Point → CSMS (request from station) | +| `"CSMS_TO_CS"` | CSMS → Charge Point (response or remote trigger) | +| `"UNKNOWN"` | Direction not specified and not inferable | + +Direction inference rules are defined in +[ADR-0004](adr/0004-message-direction.md). + +--- + +## Synthetic Data Policy + +All trace fixtures, sample data, and examples committed to this repository +**must be synthetic**. No real station identifiers, transaction IDs, idTag +values, or personal data may appear in committed artifacts. + +User-loaded traces and runtime-generated reports are **not** subject to this +restriction — they contain the user's own data and are processed locally. See +[ADR-0008](adr/0008-browser-local-processing.md). + +--- + +## Version History + +| Version | Date | Changes | +|---------|------|---------| +| 1.0 | 2025-01-15 | Initial specification for v0.1 (OCPP 1.6 JSON). | diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..e4a13a9 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,34 @@ +{ + "name": "@ocpp-debugkit/core", + "version": "0.0.0", + "description": "Core data model, parser, normalizer, timeline, and failure detection for OCPP DebugKit.", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./fixtures": { + "types": "./dist/fixtures/index.d.ts", + "import": "./dist/fixtures/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "clean": "rm -rf dist .turbo" + }, + "dependencies": {}, + "devDependencies": { + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/core/src/__fixtures__/connector-fault.json b/packages/core/src/__fixtures__/connector-fault.json new file mode 100644 index 0000000..452325e --- /dev/null +++ b/packages/core/src/__fixtures__/connector-fault.json @@ -0,0 +1,230 @@ +{ + "traceId": "fixture-connector-fault", + "metadata": { + "stationId": "CS-SYNTHETIC-003", + "ocppVersion": "1.6", + "source": "synthetic-fixture", + "description": "Connector fault during active session: station boots, transaction starts, connector faults mid-charging, transaction stops with fault reason. Expects CONNECTOR_FAULT failure." + }, + "events": [ + { + "timestamp": "2024-01-15T12:00:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-001", + "BootNotification", + { + "chargePointVendor": "SyntheticVendor", + "chargePointModel": "SM-100", + "chargePointSerialNumber": "CS-SYNTHETIC-003", + "firmwareVersion": "1.0.0" + } + ] + }, + { + "timestamp": "2024-01-15T12:00:00.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-001", + { + "currentTime": "2024-01-15T12:00:00.500Z", + "interval": 300, + "status": "Accepted" + } + ] + }, + { + "timestamp": "2024-01-15T12:01:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-002", + "StatusNotification", + { + "connectorId": 0, + "status": "Available", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T12:01:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-002", {}] + }, + { + "timestamp": "2024-01-15T12:02:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-003", + "StatusNotification", + { + "connectorId": 1, + "status": "Preparing", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T12:02:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-003", {}] + }, + { + "timestamp": "2024-01-15T12:02:15.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-004", + "Authorize", + { + "idTag": "SYNTHETIC-TAG-002" + } + ] + }, + { + "timestamp": "2024-01-15T12:02:15.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-004", + { + "idTagInfo": { + "status": "Accepted", + "expiryDate": "2024-12-31T23:59:59.000Z" + } + } + ] + }, + { + "timestamp": "2024-01-15T12:02:30.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-005", + "StartTransaction", + { + "connectorId": 1, + "idTag": "SYNTHETIC-TAG-002", + "meterStart": 0, + "timestamp": "2024-01-15T12:02:30.000Z" + } + ] + }, + { + "timestamp": "2024-01-15T12:02:30.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-005", + { + "transactionId": 100002, + "idTagInfo": { + "status": "Accepted" + } + } + ] + }, + { + "timestamp": "2024-01-15T12:02:31.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-006", + "StatusNotification", + { + "connectorId": 1, + "status": "Charging", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T12:02:31.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-006", {}] + }, + { + "timestamp": "2024-01-15T12:15:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-007", + "MeterValues", + { + "connectorId": 1, + "transactionId": 100002, + "meterValue": [ + { + "timestamp": "2024-01-15T12:15:00.000Z", + "sampledValue": [ + { + "value": "3500", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh" + } + ] + } + ] + } + ] + }, + { + "timestamp": "2024-01-15T12:15:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-007", {}] + }, + { + "timestamp": "2024-01-15T12:18:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-008", + "StatusNotification", + { + "connectorId": 1, + "status": "Faulted", + "errorCode": "ConnectorLockFailure", + "info": "Connector lock mechanism failure detected" + } + ] + }, + { + "timestamp": "2024-01-15T12:18:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-008", {}] + }, + { + "timestamp": "2024-01-15T12:18:05.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-009", + "StopTransaction", + { + "transactionId": 100002, + "idTag": "SYNTHETIC-TAG-002", + "meterStop": 3500, + "timestamp": "2024-01-15T12:18:05.000Z", + "reason": "Faulted" + } + ] + }, + { + "timestamp": "2024-01-15T12:18:05.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-009", + { + "idTagInfo": { + "status": "Accepted" + } + } + ] + } + ] +} diff --git a/packages/core/src/__fixtures__/failed-auth.json b/packages/core/src/__fixtures__/failed-auth.json new file mode 100644 index 0000000..87a6d45 --- /dev/null +++ b/packages/core/src/__fixtures__/failed-auth.json @@ -0,0 +1,172 @@ +{ + "traceId": "fixture-failed-auth", + "metadata": { + "stationId": "CS-SYNTHETIC-002", + "ocppVersion": "1.6", + "source": "synthetic-fixture", + "description": "Failed authorization: station boots, connector prepares, idTag is rejected by CSMS. StartTransaction is not attempted. Expects FAILED_AUTHORIZATION failure." + }, + "events": [ + { + "timestamp": "2024-01-15T11:00:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-001", + "BootNotification", + { + "chargePointVendor": "SyntheticVendor", + "chargePointModel": "SM-100", + "chargePointSerialNumber": "CS-SYNTHETIC-002", + "firmwareVersion": "1.0.0" + } + ] + }, + { + "timestamp": "2024-01-15T11:00:00.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-001", + { + "currentTime": "2024-01-15T11:00:00.500Z", + "interval": 300, + "status": "Accepted" + } + ] + }, + { + "timestamp": "2024-01-15T11:01:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-002", + "StatusNotification", + { + "connectorId": 0, + "status": "Available", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T11:01:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-002", {}] + }, + { + "timestamp": "2024-01-15T11:02:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-003", + "StatusNotification", + { + "connectorId": 1, + "status": "Preparing", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T11:02:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-003", {}] + }, + { + "timestamp": "2024-01-15T11:02:15.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-004", + "Authorize", + { + "idTag": "SYNTHETIC-TAG-INVALID" + } + ] + }, + { + "timestamp": "2024-01-15T11:02:15.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-004", + { + "idTagInfo": { + "status": "Invalid" + } + } + ] + }, + { + "timestamp": "2024-01-15T11:02:20.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-005", + "Authorize", + { + "idTag": "SYNTHETIC-TAG-INVALID" + } + ] + }, + { + "timestamp": "2024-01-15T11:02:20.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-005", + { + "idTagInfo": { + "status": "Invalid" + } + } + ] + }, + { + "timestamp": "2024-01-15T11:02:30.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-006", + "Authorize", + { + "idTag": "SYNTHETIC-TAG-INVALID" + } + ] + }, + { + "timestamp": "2024-01-15T11:02:30.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-006", + { + "idTagInfo": { + "status": "Invalid" + } + } + ] + }, + { + "timestamp": "2024-01-15T11:02:40.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-007", + "StatusNotification", + { + "connectorId": 1, + "status": "Faulted", + "errorCode": "OtherError", + "info": "Authorization failed after 3 attempts" + } + ] + }, + { + "timestamp": "2024-01-15T11:02:40.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-007", {}] + } + ] +} diff --git a/packages/core/src/__fixtures__/normal-session.json b/packages/core/src/__fixtures__/normal-session.json new file mode 100644 index 0000000..ab79bd2 --- /dev/null +++ b/packages/core/src/__fixtures__/normal-session.json @@ -0,0 +1,260 @@ +{ + "traceId": "fixture-normal-session", + "metadata": { + "stationId": "CS-SYNTHETIC-001", + "ocppVersion": "1.6", + "source": "synthetic-fixture", + "description": "Normal charging session: boot → authorize → start transaction → meter values → stop transaction. No failures expected." + }, + "events": [ + { + "timestamp": "2024-01-15T10:00:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-001", + "BootNotification", + { + "chargePointVendor": "SyntheticVendor", + "chargePointModel": "SM-100", + "chargePointSerialNumber": "CS-SYNTHETIC-001", + "firmwareVersion": "1.0.0" + } + ] + }, + { + "timestamp": "2024-01-15T10:00:00.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-001", + { + "currentTime": "2024-01-15T10:00:00.500Z", + "interval": 300, + "status": "Accepted" + } + ] + }, + { + "timestamp": "2024-01-15T10:01:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-002", + "StatusNotification", + { + "connectorId": 0, + "status": "Available", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T10:01:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-002", {}] + }, + { + "timestamp": "2024-01-15T10:02:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-003", + "StatusNotification", + { + "connectorId": 1, + "status": "Preparing", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T10:02:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-003", {}] + }, + { + "timestamp": "2024-01-15T10:02:15.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-004", + "Authorize", + { + "idTag": "SYNTHETIC-TAG-001" + } + ] + }, + { + "timestamp": "2024-01-15T10:02:15.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-004", + { + "idTagInfo": { + "status": "Accepted", + "expiryDate": "2024-12-31T23:59:59.000Z", + "parentIdTag": "SYNTHETIC-PARENT-001" + } + } + ] + }, + { + "timestamp": "2024-01-15T10:02:30.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-005", + "StartTransaction", + { + "connectorId": 1, + "idTag": "SYNTHETIC-TAG-001", + "meterStart": 0, + "timestamp": "2024-01-15T10:02:30.000Z" + } + ] + }, + { + "timestamp": "2024-01-15T10:02:30.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-005", + { + "transactionId": 100001, + "idTagInfo": { + "status": "Accepted" + } + } + ] + }, + { + "timestamp": "2024-01-15T10:02:31.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-006", + "StatusNotification", + { + "connectorId": 1, + "status": "Charging", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T10:02:31.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-006", {}] + }, + { + "timestamp": "2024-01-15T10:15:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-007", + "MeterValues", + { + "connectorId": 1, + "transactionId": 100001, + "meterValue": [ + { + "timestamp": "2024-01-15T10:15:00.000Z", + "sampledValue": [ + { + "value": "5000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh" + } + ] + } + ] + } + ] + }, + { + "timestamp": "2024-01-15T10:15:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-007", {}] + }, + { + "timestamp": "2024-01-15T10:30:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-008", + "MeterValues", + { + "connectorId": 1, + "transactionId": 100001, + "meterValue": [ + { + "timestamp": "2024-01-15T10:30:00.000Z", + "sampledValue": [ + { + "value": "10000", + "measurand": "Energy.Active.Import.Register", + "unit": "Wh" + } + ] + } + ] + } + ] + }, + { + "timestamp": "2024-01-15T10:30:00.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-008", {}] + }, + { + "timestamp": "2024-01-15T10:35:00.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-009", + "StopTransaction", + { + "transactionId": 100001, + "idTag": "SYNTHETIC-TAG-001", + "meterStop": 10000, + "timestamp": "2024-01-15T10:35:00.000Z", + "reason": "EVDisconnected" + } + ] + }, + { + "timestamp": "2024-01-15T10:35:00.500Z", + "direction": "CSMS_TO_CS", + "message": [ + 3, + "msg-009", + { + "idTagInfo": { + "status": "Accepted" + } + } + ] + }, + { + "timestamp": "2024-01-15T10:35:01.000Z", + "direction": "CS_TO_CSMS", + "message": [ + 2, + "msg-010", + "StatusNotification", + { + "connectorId": 1, + "status": "Available", + "errorCode": "NoError" + } + ] + }, + { + "timestamp": "2024-01-15T10:35:01.500Z", + "direction": "CSMS_TO_CS", + "message": [3, "msg-010", {}] + } + ] +} diff --git a/packages/core/src/fixtures.test.ts b/packages/core/src/fixtures.test.ts new file mode 100644 index 0000000..1bedc88 --- /dev/null +++ b/packages/core/src/fixtures.test.ts @@ -0,0 +1,318 @@ +import { describe, it, expect } from 'vitest'; +import { + fixtures, + normalSession, + failedAuth, + connectorFault, + fixtureNames, +} from './fixtures/index.js'; +import type { Trace, TraceEventInput, RawOcppMessage } from './types.js'; + +// --------------------------------------------------------------------------- +// Helpers — lightweight validation logic that mirrors the proposed model. +// Full parser + Zod schemas will be implemented in v0.1.0 (Issue #13). +// --------------------------------------------------------------------------- + +const VALID_DIRECTIONS = new Set(['CS_TO_CSMS', 'CSMS_TO_CS', 'UNKNOWN']); + +/** Validate that a value looks like a Trace (JSON Object format). */ +function assertTraceShape(trace: unknown): asserts trace is Trace { + expect(trace).toBeDefined(); + expect(typeof trace).toBe('object'); + const t = trace as Record; + expect(Array.isArray(t.events)).toBe(true); + expect((t.events as unknown[]).length).toBeGreaterThan(0); +} + +/** Validate a single trace event input against the proposed model. */ +function assertEventShape(ev: unknown, _index: number): asserts ev is TraceEventInput { + expect(ev).toBeDefined(); + expect(typeof ev).toBe('object'); + const e = ev as Record; + + // message is required and must be an array + expect(e.message).toBeDefined(); + expect(Array.isArray(e.message)).toBe(true); + + const msg = e.message as unknown[]; + expect(msg.length).toBeGreaterThanOrEqual(3); + expect(typeof msg[0]).toBe('number'); // MessageTypeId + expect(typeof msg[1]).toBe('string'); // UniqueId + + // MessageTypeId must be 2, 3, or 4 + expect([2, 3, 4]).toContain(msg[0]); + + // Call (type 2) needs at least 4 elements: [2, id, action, payload] + if (msg[0] === 2) { + expect(msg.length).toBeGreaterThanOrEqual(4); + expect(typeof msg[2]).toBe('string'); // Action + } + + // CallResult (type 3) needs at least 3 elements: [3, id, payload] + if (msg[0] === 3) { + expect(msg.length).toBeGreaterThanOrEqual(3); + } + + // CallError (type 4) needs at least 5 elements: [4, id, code, desc, details] + if (msg[0] === 4) { + expect(msg.length).toBeGreaterThanOrEqual(5); + expect(typeof msg[2]).toBe('string'); // ErrorCode + } + + // direction (optional) must be valid if present + if (e.direction !== undefined && e.direction !== null) { + expect(VALID_DIRECTIONS.has(e.direction as string)).toBe(true); + } + + // timestamp (optional) must be string or number if present + if (e.timestamp !== undefined && e.timestamp !== null) { + expect(['string', 'number']).toContain(typeof e.timestamp); + } +} + +/** Assert all events in a trace are well-shaped. */ +function assertAllEvents(trace: Trace): void { + trace.events.forEach((ev, i) => assertEventShape(ev, i)); +} + +/** Check for Call / CallResult correlation by messageId. */ +function assertCallResponsePairs(trace: Trace): void { + const callIds = new Set(); + const responseIds = new Set(); + + for (const ev of trace.events) { + const msg = ev.message as RawOcppMessage; + const msgTypeId = msg[0]; + const uniqueId = msg[1]; + + if (msgTypeId === 2) { + callIds.add(uniqueId); + } else { + responseIds.add(uniqueId); + } + } + + // Every response should have a matching Call (unless it's the first message) + for (const respId of responseIds) { + if (!callIds.has(respId)) { + throw new Error(`Response with messageId "${respId}" has no matching Call`); + } + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +describe('Synthetic trace fixtures', () => { + describe('fixture registry', () => { + it('exports exactly 3 fixtures', () => { + expect(Object.keys(fixtures)).toHaveLength(3); + }); + + it('exports fixture names', () => { + expect(fixtureNames).toEqual(['normal-session', 'failed-auth', 'connector-fault']); + }); + + it('each fixture is individually exported', () => { + expect(normalSession).toBeDefined(); + expect(failedAuth).toBeDefined(); + expect(connectorFault).toBeDefined(); + }); + }); + + describe('normal-session fixture', () => { + it('conforms to the Trace shape', () => { + assertTraceShape(normalSession); + }); + + it('all events conform to the proposed event model', () => { + assertTraceShape(normalSession); + assertAllEvents(normalSession); + }); + + it('has matching Call/CallResult pairs', () => { + assertCallResponsePairs(normalSession); + }); + + it('contains a complete charging session flow', () => { + const actions = normalSession.events + .filter((e) => e.message[0] === 2) + .map((e) => e.message[2] as string); + + expect(actions).toContain('BootNotification'); + expect(actions).toContain('Authorize'); + expect(actions).toContain('StartTransaction'); + expect(actions).toContain('MeterValues'); + expect(actions).toContain('StopTransaction'); + }); + + it('has consistent transactionId in StartTransaction response and StopTransaction', () => { + const startResp = normalSession.events.find( + (e) => e.message[0] === 3 && e.message[1] === 'msg-005', + ); + const stopReq = normalSession.events.find( + (e) => e.message[0] === 2 && e.message[2] === 'StopTransaction', + ); + + expect(startResp).toBeDefined(); + expect(stopReq).toBeDefined(); + + const startPayload = (startResp as TraceEventInput).message[2] as { + transactionId?: number; + }; + const stopPayload = (stopReq as TraceEventInput).message[3] as { + transactionId?: number; + }; + + expect(startPayload.transactionId).toBe(100001); + expect(stopPayload.transactionId).toBe(100001); + }); + + it('has chronological timestamps', () => { + const timestamps = normalSession.events + .map((e) => (typeof e.timestamp === 'string' ? Date.parse(e.timestamp) : null)) + .filter((t): t is number => t !== null); + + for (let i = 1; i < timestamps.length; i++) { + const prev = timestamps[i - 1]; + const curr = timestamps[i]; + if (prev !== undefined && curr !== undefined) { + expect(curr).toBeGreaterThanOrEqual(prev); + } + } + }); + + it('uses synthetic identifiers (no real data)', () => { + const json = JSON.stringify(normalSession); + expect(json).toContain('SYNTHETIC'); + expect(json).not.toMatch(/\b[A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12}\b/i); + }); + }); + + describe('failed-auth fixture', () => { + it('conforms to the Trace shape', () => { + assertTraceShape(failedAuth); + }); + + it('all events conform to the proposed event model', () => { + assertTraceShape(failedAuth); + assertAllEvents(failedAuth); + }); + + it('has matching Call/CallResult pairs', () => { + assertCallResponsePairs(failedAuth); + }); + + it('contains rejected Authorize responses', () => { + const authResps = failedAuth.events.filter( + (e) => e.message[0] === 3 && e.message[1].startsWith('msg-00'), + ); + + const rejected = authResps.filter((e) => { + const payload = e.message[2] as { idTagInfo?: { status?: string } }; + return payload?.idTagInfo?.status === 'Invalid'; + }); + + expect(rejected.length).toBeGreaterThanOrEqual(1); + }); + + it('does not contain StartTransaction (auth failed)', () => { + const actions = failedAuth.events + .filter((e) => e.message[0] === 2) + .map((e) => e.message[2] as string); + + expect(actions).not.toContain('StartTransaction'); + }); + + it('transitions connector to Faulted after auth failure', () => { + const statusNotifs = failedAuth.events.filter( + (e) => e.message[0] === 2 && e.message[2] === 'StatusNotification', + ); + + const lastStatus = statusNotifs.at(-1); + expect(lastStatus).toBeDefined(); + const payload = (lastStatus as TraceEventInput).message[3] as { + status?: string; + }; + expect(payload.status).toBe('Faulted'); + }); + }); + + describe('connector-fault fixture', () => { + it('conforms to the Trace shape', () => { + assertTraceShape(connectorFault); + }); + + it('all events conform to the proposed event model', () => { + assertTraceShape(connectorFault); + assertAllEvents(connectorFault); + }); + + it('has matching Call/CallResult pairs', () => { + assertCallResponsePairs(connectorFault); + }); + + it('contains a Faulted StatusNotification during active session', () => { + const faultStatus = connectorFault.events.find( + (e) => + e.message[0] === 2 && + e.message[2] === 'StatusNotification' && + (e.message[3] as { status?: string }).status === 'Faulted', + ); + + expect(faultStatus).toBeDefined(); + const payload = (faultStatus as TraceEventInput).message[3] as { + errorCode?: string; + }; + expect(payload.errorCode).not.toBe('NoError'); + }); + + it('has StopTransaction with Faulted reason after connector fault', () => { + const stopTx = connectorFault.events.find( + (e) => e.message[0] === 2 && e.message[2] === 'StopTransaction', + ); + + expect(stopTx).toBeDefined(); + const payload = (stopTx as TraceEventInput).message[3] as { + reason?: string; + }; + expect(payload.reason).toBe('Faulted'); + }); + + it('has a StartTransaction before the StopTransaction', () => { + const events = connectorFault.events; + const startIdx = events.findIndex( + (e) => e.message[0] === 2 && e.message[2] === 'StartTransaction', + ); + const stopIdx = events.findIndex( + (e) => e.message[0] === 2 && e.message[2] === 'StopTransaction', + ); + + expect(startIdx).toBeGreaterThanOrEqual(0); + expect(stopIdx).toBeGreaterThan(startIdx); + }); + }); + + describe('all fixtures — synthetic data policy', () => { + it.each([ + ['normal-session', normalSession], + ['failed-auth', failedAuth], + ['connector-fault', connectorFault], + ])('%s contains only synthetic identifiers', (_name, trace) => { + const json = JSON.stringify(trace); + // Must contain SYNTHETIC marker in identifiers + expect(json).toContain('SYNTHETIC'); + // Must not contain UUID-like patterns (real station serials often look like UUIDs) + expect(json).not.toMatch(/\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/i); + }); + + it.each([ + ['normal-session', normalSession], + ['failed-auth', failedAuth], + ['connector-fault', connectorFault], + ])('%s declares ocppVersion 1.6', (_name, trace) => { + expect(trace.metadata?.ocppVersion).toBe('1.6'); + }); + }); +}); diff --git a/packages/core/src/fixtures/index.ts b/packages/core/src/fixtures/index.ts new file mode 100644 index 0000000..3819975 --- /dev/null +++ b/packages/core/src/fixtures/index.ts @@ -0,0 +1,22 @@ +/** + * Synthetic trace fixtures for testing and development. + * + * All fixtures are fully synthetic — no real station identifiers, transaction + * IDs, idTag values, or personal data. + * + * @see docs/trace-format-spec.md + */ + +import normalSession from '../__fixtures__/normal-session.json'; +import failedAuth from '../__fixtures__/failed-auth.json'; +import connectorFault from '../__fixtures__/connector-fault.json'; + +export { normalSession, failedAuth, connectorFault }; + +export const fixtures = { + normalSession, + failedAuth, + connectorFault, +} as const; + +export const fixtureNames = ['normal-session', 'failed-auth', 'connector-fault'] as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..a433fd0 --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,6 @@ +/** + * Barrel export for the @ocpp-debugkit/core package. + */ + +export * from './types.js'; +export * from './fixtures/index.js'; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..65ec6a2 --- /dev/null +++ b/packages/core/src/types.ts @@ -0,0 +1,143 @@ +/** + * Core type definitions for OCPP DebugKit. + * + * These are the proposed canonical types from the M0.5 design phase + * (ADR-0003 through ADR-0006). They will be fully implemented with Zod + * schemas in v0.1.0 (Issue #13). + */ + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +/** + * Direction of an OCPP message. + * @see ADR-0004 + */ +export type Direction = 'CS_TO_CSMS' | 'CSMS_TO_CS' | 'UNKNOWN'; + +/** + * OCPP 1.6 JSON message type. + * - Call (2): request from one side to the other. + * - CallResult (3): successful response. + * - CallError (4): error response. + */ +export type MessageType = 'Call' | 'CallResult' | 'CallError'; + +// --------------------------------------------------------------------------- +// Event Model (ADR-0003) +// --------------------------------------------------------------------------- + +/** + * A raw OCPP 1.6 JSON message as it appears on the wire. + * The shape depends on the message type: + * - Call: [2, UniqueId, Action, Payload] + * - CallResult: [3, UniqueId, Payload] + * - CallError: [4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails] + */ +export type RawOcppMessage = [number, string, ...unknown[]]; + +/** + * A trace event entry as it appears in a trace file (JSON Object or JSONL). + * This is the input shape before normalization. + */ +export interface TraceEventInput { + /** ISO 8601 string or Unix epoch (ms or s). Optional. */ + timestamp?: string | number | null; + /** Direction of the message. Inferred if absent. */ + direction?: Direction; + /** Raw OCPP 1.6 JSON message array. */ + message: RawOcppMessage; +} + +/** + * The canonical normalized event used internally by DebugKit. + * @see ADR-0003 + */ +export interface Event { + /** Generated unique event ID (sequential, stable within a parse). */ + id: string; + /** OCPP UniqueId from the message array. */ + messageId: string; + /** Normalized timestamp in epoch milliseconds. null if missing. */ + timestamp: number | null; + /** Direction of the message. */ + direction: Direction; + /** OCPP message type. */ + messageType: MessageType; + /** OCPP action name (e.g., "BootNotification"). Present only for Call messages. */ + action: string | null; + /** OCPP payload object. */ + payload: unknown; + /** Error code, present only for CallError messages. */ + errorCode: string | null; + /** Error description, present only for CallError messages. */ + errorDescription: string | null; + /** The original raw OCPP message array, unmodified. */ + rawMessage: RawOcppMessage; +} + +// --------------------------------------------------------------------------- +// Trace Model (ADR-0002) +// --------------------------------------------------------------------------- + +/** + * Metadata for a trace file. + */ +export interface TraceMetadata { + stationId?: string; + ocppVersion?: string; + source?: string; + description?: string; +} + +/** + * The JSON Object trace format. + * @see ADR-0002, docs/trace-format-spec.md + */ +export interface Trace { + traceId?: string; + metadata?: TraceMetadata; + events: TraceEventInput[]; +} + +// --------------------------------------------------------------------------- +// Session Model (ADR-0006) +// --------------------------------------------------------------------------- + +/** + * A logical charging session derived from trace events. + * @see ADR-0006 + */ +export interface Session { + sessionId: string; + stationId: string; + connectorId: number | null; + transactionId: number | null; + startTime: number | null; + endTime: number | null; + events: Event[]; + status: 'active' | 'completed' | 'aborted'; +} + +// --------------------------------------------------------------------------- +// Parse Result (ADR-0007) +// --------------------------------------------------------------------------- + +/** + * A warning produced during parsing when an individual event is malformed. + * @see ADR-0007 + */ +export interface ParseWarning { + index: number; + message: string; + rawInput?: string; +} + +/** + * Result of parsing a trace. + */ +export interface ParseResult { + events: Event[]; + warnings: ParseWarning[]; +} diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json new file mode 100644 index 0000000..dd1cdcb --- /dev/null +++ b/packages/core/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c30597f..148143e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -36,6 +36,15 @@ importers: specifier: ^3.0.0 version: 3.2.7(@types/node@22.20.0) + packages/core: + devDependencies: + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.0) + packages: '@babel/runtime@7.29.7':