From 062918f06c4fa9170a00d8bef7665b9f77f9c6c4 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sat, 11 Jul 2026 12:48:34 +0300 Subject: [PATCH] feat(engine): canonical types & value boundary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bring up the pure-Zig OCPP engine's foundational data model under `src/ocpp/` — headless and UI-free, discovered by `native test`. - `Direction` / `MessageType` / `Status` enums with wire-string and type-id conversions. - `Event`, `TraceEventInput`, `Trace` (+ metadata), `ParseWarning`, `ParseResult`, and `Session`, mirroring the toolkit's conformance contract model (the shared behavior, not its source). - A `std.json.Value` payload / raw-message boundary over a per-parse arena, behind a version-tagged decode seam (ADR-0005) so OCPP 2.0.1 lands additively. - Engine test discovery wired into the root test block. Closes #11 --- AGENTS.md | 1 + CURRENT_STATE.md | 20 +- docs/adr/0005-engine-value-representation.md | 64 +++++ docs/adr/README.md | 1 + src/main.zig | 1 + src/ocpp/ocpp.zig | 11 + src/ocpp/types.zig | 267 +++++++++++++++++++ 7 files changed, 358 insertions(+), 7 deletions(-) create mode 100644 docs/adr/0005-engine-value-representation.md create mode 100644 src/ocpp/ocpp.zig create mode 100644 src/ocpp/types.zig diff --git a/AGENTS.md b/AGENTS.md index b589a41..02b41c1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,6 +20,7 @@ studio/ ├── src/ │ ├── main.zig # Model, Msg, update, app wiring │ ├── app.native # the view (declarative markup) +│ ├── ocpp/ # pure, headless OCPP engine (types, parser, …) │ └── tests.zig # headless view/model tests ├── scripts/smoke.sh # portable automation smoke test (Xvfb in CI) ├── docs/adr/ # architecture decision records diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 4c49eee..307bf92 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,7 +8,7 @@ ## Active milestone -**S0 — Foundation: complete.** Next up: **S1 — Engine core** (see +**S1 — Engine core (0.1.0): in progress.** S0 — Foundation is complete (see [ROADMAP.md](ROADMAP.md)). ## What's done @@ -34,14 +34,20 @@ ## What's in progress -- Nothing in flight — S0 is closed; S1 is next. +**S1 — Engine core (0.1.0).** Building the pure-Zig engine module by module: + +- **Engine types & value boundary** (#11) — the `src/ocpp/` foundation: canonical + `Event` / `Session` / trace / parse-result types, the `Direction` / + `MessageType` / `Status` enums, and the `std.json.Value` payload boundary + (ADR-0005). Headless, imports no UI/runtime modules, tested via + `native test -Dplatform=null`. +- Next: normalizer (#12), trace parser (#13), session timeline (#14). ## What's next -**S1 — Engine core (0.1.0):** the pure-Zig OCPP engine — canonical `Event` / -`Session` / `Failure` types, the parser (JSON object, JSONL, bare array), the -normalizer (direction inference + timestamp normalization), and session -correlation by transaction id. Headless and testable via `native test`. +**S2 — Detection + conformance (0.1.0):** the full OCPP 1.6J failure taxonomy in +Zig, plus the conformance harness that runs the shared scenario fixtures and +checks Studio's detected failures against locked goldens. ## Known blockers / decisions pending @@ -53,7 +59,7 @@ correlation by transaction id. Headless and testable via `native test`. | --- | --- | | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | -| `ocpp` (engine) | ⬜ not started (S1) | +| `ocpp` (engine) | 🚧 in progress (S1) | | `ui` (native views) | ⬜ placeholder (S3) | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | diff --git a/docs/adr/0005-engine-value-representation.md b/docs/adr/0005-engine-value-representation.md new file mode 100644 index 0000000..5927755 --- /dev/null +++ b/docs/adr/0005-engine-value-representation.md @@ -0,0 +1,64 @@ +# ADR-0005 — Engine value representation & version-tagged decoder boundary + +- **Status:** Accepted +- **Date:** 2026-07-11 + +## Context + +The engine must carry arbitrary OCPP payloads. Every action has a different +payload shape — `BootNotification`, `MeterValues`, `StartTransaction`, +`StatusNotification`, and dozens more — and raw messages are heterogeneous +JSON arrays: `[2, uniqueId, action, payload]`, `[3, uniqueId, payload]`, +`[4, uniqueId, errorCode, errorDescription, errorDetails]`. Studio needs a +representation for these values that the parser, normalizer, timeline, and +(later) detection and inspector can all read. + +Three options were considered: + +1. **`std.json.Value`** — keep the parsed JSON tree and read fields by key or + index. Dynamically typed. +2. **Per-action typed structs** — a hand-written (or comptime-generated) struct + per OCPP action, populated via `std.json.parseFromValue`. Statically typed. +3. **Raw bytes with lazy re-parse** — store the message slice and re-parse on + demand. + +Two forces shape the choice. First, the engine mirrors the toolkit's +conformance contract, which validates a message's **structure** and otherwise +treats the payload as opaque `unknown` — it does not model every action as a +type. Second, OCPP 2.0.1 is a planned future target (contract-v2); the decode +path must not bake 1.6J assumptions so deep that 2.0.1 becomes a rewrite. + +## Decision + +**Represent raw messages and payloads as `std.json.Value` at the engine +boundary, over a single per-parse arena, behind a version-tagged decode seam.** + +- The parser validates only structural shape — MessageTypeId ∈ {2, 3, 4}, + a string UniqueId, per-type arity, and the string positions for action / + error code / error description — then hands back canonical `Event`s whose + `payload` and `raw_message` are `std.json.Value`. Consumers read fields by + key or index (`payload.object.get("transactionId")`). +- All parsed data — the JSON tree, event IDs, warning strings, session slices — + borrows from **one arena per parse**. The caller owns the arena's lifetime and + frees everything at once. There is no per-field heap ownership. +- Decoding is keyed off an **OCPP version tag** (1.6J today). The canonical + `Event` / `Session` shape is version-neutral; anything that differs by version + lives behind the tag. OCPP 2.0.1 arrives as new decode / normalize / detection + modules selected by the tag, not as edits to the 1.6 path. + +## Consequences + +- The parser and normalizer stay small: no struct-per-action to write or + maintain, and payloads of any action — including vendor `DataTransfer` — flow + through unmodified. This matches the contract's "validate shape, treat payload + as data" stance, which is what conformance parity requires. +- The arena model makes lifetimes trivial and bulk-frees fast, fitting the + million-event capacity goal. Retaining the whole JSON tree in the arena is + acceptable and is bounded by the parser's input-size and event-count limits. +- Field access is dynamically typed, so a wrong key or type surfaces at runtime + rather than compile time. This is mitigated by focused accessor helpers and, + from S2, the conformance harness that pins detected output against goldens. +- OCPP 2.0.1 support is additive — a new version tag and its modules — rather + than surgery on the 1.6 code. +- If a hot path later needs typed decode, `std.json.parseFromValue` can layer + concrete structs onto specific actions **without** changing this boundary. diff --git a/docs/adr/README.md b/docs/adr/README.md index f3de26c..2d4c1ea 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -14,3 +14,4 @@ supersedes the old one rather than editing history. | [0002](0002-macos-first-platforms.md) | macOS-first, Linux in CI, Windows post-1.0 | Accepted | | [0003](0003-native-rendered-ui.md) | Native-rendered UI, no WebView | Accepted | | [0004](0004-zig-native-sdk-zero-config.md) | Zig + Native SDK on the zero-config build | Accepted | +| [0005](0005-engine-value-representation.md) | Engine value representation & version-tagged decoder boundary | Accepted | diff --git a/src/main.zig b/src/main.zig index 96bd8e3..6702cc2 100644 --- a/src/main.zig +++ b/src/main.zig @@ -94,4 +94,5 @@ pub fn main(init: std.process.Init) !void { test { _ = @import("tests.zig"); + _ = @import("ocpp/ocpp.zig"); } diff --git a/src/ocpp/ocpp.zig b/src/ocpp/ocpp.zig new file mode 100644 index 0000000..dfdccf2 --- /dev/null +++ b/src/ocpp/ocpp.zig @@ -0,0 +1,11 @@ +//! Aggregate root for the pure-Zig OCPP engine. +//! +//! Everything under `src/ocpp/` is headless and UI-free — it imports no +//! `native_sdk` or `runner` modules, so it builds and tests on every platform +//! (including `-Dplatform=null`). The UI consumes the engine through the Model. + +pub const types = @import("types.zig"); + +test { + _ = types; +} diff --git a/src/ocpp/types.zig b/src/ocpp/types.zig new file mode 100644 index 0000000..042826d --- /dev/null +++ b/src/ocpp/types.zig @@ -0,0 +1,267 @@ +//! Canonical, headless data model for the OCPP engine. +//! +//! Mirrors the toolkit's `core/types.ts` model — the shared conformance +//! contract — adapted to idiomatic Zig. Arbitrary OCPP payloads and raw message +//! arrays are carried as `std.json.Value` behind a version-tagged decode seam +//! (ADR-0005), so OCPP 2.0.1 lands additively rather than as a rewrite. +//! +//! Lifetime: parsed events borrow from a single parse arena (see parser.zig). +//! The `[]const u8` fields and `std.json.Value` payloads point into arena-owned +//! memory and stay valid until that arena is freed. + +const std = @import("std"); + +// --------------------------------------------------------------------------- +// Primitives +// --------------------------------------------------------------------------- + +/// Direction of an OCPP message. Inferred when a trace omits it (normalizer.zig). +pub const Direction = enum { + cs_to_csms, + csms_to_cs, + unknown, + + pub fn toWire(self: Direction) []const u8 { + return switch (self) { + .cs_to_csms => "CS_TO_CSMS", + .csms_to_cs => "CSMS_TO_CS", + .unknown => "UNKNOWN", + }; + } + + /// Parse an explicit `direction` field from trace input. Null if unrecognized. + pub fn fromWire(s: []const u8) ?Direction { + if (std.mem.eql(u8, s, "CS_TO_CSMS")) return .cs_to_csms; + if (std.mem.eql(u8, s, "CSMS_TO_CS")) return .csms_to_cs; + if (std.mem.eql(u8, s, "UNKNOWN")) return .unknown; + return null; + } +}; + +/// OCPP 1.6 JSON message type, keyed by the leading MessageTypeId. +pub const MessageType = enum { + /// `[2, UniqueId, Action, Payload]` + call, + /// `[3, UniqueId, Payload]` + call_result, + /// `[4, UniqueId, ErrorCode, ErrorDescription, ErrorDetails]` + call_error, + + pub fn toWire(self: MessageType) []const u8 { + return switch (self) { + .call => "Call", + .call_result => "CallResult", + .call_error => "CallError", + }; + } + + /// The wire MessageTypeId (2/3/4). + pub fn typeId(self: MessageType) u8 { + return switch (self) { + .call => 2, + .call_result => 3, + .call_error => 4, + }; + } + + /// Classify a raw MessageTypeId. Null for anything outside {2, 3, 4}. + /// Takes `i64` because it comes straight off a parsed JSON integer. + pub fn fromTypeId(id: i64) ?MessageType { + return switch (id) { + 2 => .call, + 3 => .call_result, + 4 => .call_error, + else => null, + }; + } +}; + +/// Lifecycle status of a correlated charging session. +pub const Status = enum { + active, + completed, + aborted, + + pub fn toWire(self: Status) []const u8 { + return switch (self) { + .active => "active", + .completed => "completed", + .aborted => "aborted", + }; + } +}; + +// --------------------------------------------------------------------------- +// Value boundary (ADR-0005) +// --------------------------------------------------------------------------- + +/// A raw OCPP 1.6 JSON message, carried as a JSON array value: +/// `[MessageTypeId, UniqueId, ...]`. Message-field extraction lives in +/// normalizer.zig so this stays a plain value at the boundary. +pub const RawMessage = std.json.Value; + +// --------------------------------------------------------------------------- +// Event model +// --------------------------------------------------------------------------- + +/// A trace event entry as it appears in a trace file, before normalization. +pub const TraceEventInput = struct { + /// Raw timestamp: a JSON string (ISO 8601) or number (epoch s/ms). + /// `.null` when the trace omits it. + timestamp: std.json.Value = .null, + /// Explicit direction if the trace provided one; inferred when null. + direction: ?Direction = null, + /// Raw OCPP message array. + message: RawMessage, +}; + +/// The canonical normalized event used throughout the engine. +pub const Event = struct { + /// Generated event ID (`evt-0001`), sequential and stable within a parse. + id: []const u8, + /// OCPP UniqueId from the message array. + message_id: []const u8, + /// Normalized timestamp in epoch milliseconds; null if missing/invalid. + timestamp: ?i64, + direction: Direction, + message_type: MessageType, + /// OCPP action name; present only for Call messages. + action: ?[]const u8, + /// OCPP payload object (`.null` when none). + payload: std.json.Value, + /// Error code; present only for CallError messages. + error_code: ?[]const u8, + /// Error description; present only for CallError messages. + error_description: ?[]const u8, + /// The original raw OCPP message array, unmodified. + raw_message: RawMessage, +}; + +// --------------------------------------------------------------------------- +// Trace model +// --------------------------------------------------------------------------- + +/// Optional metadata block of the JSON object trace format. +pub const TraceMetadata = struct { + station_id: ?[]const u8 = null, + ocpp_version: ?[]const u8 = null, + source: ?[]const u8 = null, + description: ?[]const u8 = null, +}; + +/// The JSON object trace format: `{ traceId?, metadata?, events[] }`. +pub const Trace = struct { + trace_id: ?[]const u8 = null, + metadata: ?TraceMetadata = null, + events: []TraceEventInput, +}; + +// --------------------------------------------------------------------------- +// Parse result +// --------------------------------------------------------------------------- + +/// A warning produced when an individual event is malformed. Parsing continues; +/// only structural failures abort (see parser.zig). +pub const ParseWarning = struct { + index: usize, + message: []const u8, + raw_input: ?[]const u8 = null, +}; + +/// Result of parsing a trace. The slices borrow from the arena passed to +/// `parseTrace`; the caller owns that arena's lifetime. +pub const ParseResult = struct { + events: []Event, + warnings: []ParseWarning, +}; + +// --------------------------------------------------------------------------- +// Session model +// --------------------------------------------------------------------------- + +/// A logical charging session derived from trace events (see timeline.zig). +pub const Session = struct { + session_id: []const u8, + station_id: []const u8, + connector_id: ?i64, + transaction_id: ?i64, + start_time: ?i64, + end_time: ?i64, + events: []Event, + status: Status, +}; + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +const testing = std.testing; + +test "Direction wire encoding round-trips" { + try testing.expectEqualStrings("CS_TO_CSMS", Direction.cs_to_csms.toWire()); + try testing.expectEqualStrings("CSMS_TO_CS", Direction.csms_to_cs.toWire()); + try testing.expectEqualStrings("UNKNOWN", Direction.unknown.toWire()); + + try testing.expectEqual(Direction.cs_to_csms, Direction.fromWire("CS_TO_CSMS").?); + try testing.expectEqual(Direction.csms_to_cs, Direction.fromWire("CSMS_TO_CS").?); + try testing.expectEqual(Direction.unknown, Direction.fromWire("UNKNOWN").?); + try testing.expectEqual(@as(?Direction, null), Direction.fromWire("sideways")); +} + +test "MessageType maps to wire strings and type ids" { + try testing.expectEqualStrings("Call", MessageType.call.toWire()); + try testing.expectEqualStrings("CallResult", MessageType.call_result.toWire()); + try testing.expectEqualStrings("CallError", MessageType.call_error.toWire()); + + try testing.expectEqual(@as(u8, 2), MessageType.call.typeId()); + try testing.expectEqual(@as(u8, 3), MessageType.call_result.typeId()); + try testing.expectEqual(@as(u8, 4), MessageType.call_error.typeId()); + + try testing.expectEqual(MessageType.call, MessageType.fromTypeId(2).?); + try testing.expectEqual(MessageType.call_result, MessageType.fromTypeId(3).?); + try testing.expectEqual(MessageType.call_error, MessageType.fromTypeId(4).?); + try testing.expectEqual(@as(?MessageType, null), MessageType.fromTypeId(1)); + try testing.expectEqual(@as(?MessageType, null), MessageType.fromTypeId(99)); +} + +test "Status maps to wire strings" { + try testing.expectEqualStrings("active", Status.active.toWire()); + try testing.expectEqualStrings("completed", Status.completed.toWire()); + try testing.expectEqualStrings("aborted", Status.aborted.toWire()); +} + +test "the model holds a parsed OCPP message end to end" { + // A Call carrying a JSON payload, parsed into an arena, threaded through the + // canonical Event shape — the boundary the whole engine is built on. + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + + const raw = try std.json.parseFromSliceLeaky( + std.json.Value, + arena.allocator(), + \\[2,"msg-001","BootNotification",{"chargePointVendor":"Synthetic"}] + , + .{}, + ); + + const event = Event{ + .id = "evt-0001", + .message_id = raw.array.items[1].string, + .timestamp = 1705312200000, + .direction = .cs_to_csms, + .message_type = .call, + .action = raw.array.items[2].string, + .payload = raw.array.items[3], + .error_code = null, + .error_description = null, + .raw_message = raw, + }; + + try testing.expectEqualStrings("msg-001", event.message_id); + try testing.expectEqualStrings("BootNotification", event.action.?); + try testing.expectEqual(MessageType.call, event.message_type); + try testing.expectEqualStrings( + "Synthetic", + event.payload.object.get("chargePointVendor").?.string, + ); +}