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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 13 additions & 7 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

## Active milestone

**S0Foundation: complete.** Next up: **S1 — Engine core** (see
**S1Engine core (0.1.0): in progress.** S0 — Foundation is complete (see
[ROADMAP.md](ROADMAP.md)).

## What's done
Expand All @@ -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

Expand All @@ -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) |
Expand Down
64 changes: 64 additions & 0 deletions docs/adr/0005-engine-value-representation.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
1 change: 1 addition & 0 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -94,4 +94,5 @@ pub fn main(init: std.process.Init) !void {

test {
_ = @import("tests.zig");
_ = @import("ocpp/ocpp.zig");
}
11 changes: 11 additions & 0 deletions src/ocpp/ocpp.zig
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading