# ocpp

Type-safe [OCPP](https://openchargealliance.org/) (Open Charge Point Protocol)
message models and OCPP-J codecs for Gleam, generated from the OCA JSON
schemas with spec-derived documentation.

One package, every protocol version — each under its own namespace, so you
import only the version(s) you speak:

| Namespace | Contents |
|---|---|
| `ocpp`, `ocpp/rpc`, `ocpp/transport/json/*` | `Version` and WebSocket subprotocol negotiation, RPC error codes, OCPP-J frame layer, `DateTime`/`CustomData`/`JsonValue`, codec helpers |
| `ocpp/protocol/*` | Sans-IO protocol machines: the OCPP-J RPC endpoint, the charging-station and CSMS roles, and their per-version bindings |
| `ocpp/v1_6/*` | OCPP 1.6 (39 actions, Security Whitepaper extension included) |
| `ocpp/v2_0_1/*` | OCPP 2.0.1 (64 actions) |
| `ocpp/v2_1/*` | OCPP 2.1 (91 actions, including the send-only `NotifyPeriodicEventStream`) |

Each version namespaces **one module per spec entity** (`message/<action>`,
`datatype/<type>`, `enum/<enum>`), so the module is the namespace and no name
carries a type prefix:

```gleam
import ocpp/v2_0_1/enum/boot_reason
import ocpp/v2_0_1/message/boot_notification

// Build a request from its required fields (optionals default to None):
let request =
  boot_notification.new_request(
    charging_station: station,
    reason: boot_reason.PowerUp,
  )

// Encode / decode the exact OCPP-J JSON (schema constraints enforced):
let wire = boot_notification.request_to_json(request)
json.parse(body, boot_notification.response_decoder())
```

Dispatch by action string (`ocpp/v2_0_1/dispatch`) maps a wire frame's action
to the right decoder and back; the frame layer lives in
`ocpp/transport/json/frame`.

## Worked example: a BootNotification round-trip

The protocol layer (`ocpp/protocol/*`) is **sans-IO**: the machines are pure
functions over plain data, with no processes, sockets, or clocks. Every
transition returns an `Effect` — an ordered list of outputs (frames to write,
responses to deliver, violations to report) that your adapter interprets — and
time enters only as the `now` argument (absolute monotonic milliseconds). The
snippets below are compiled: they live verbatim in
`test/readme_examples_test.gleam` and run with the suite.

### Negotiating the version

OCPP-J carries the protocol version in the WebSocket handshake's
`Sec-WebSocket-Protocol` header. The root `ocpp` module maps versions to
their IANA-registered tokens and picks the winner by *server* preference (per
RFC 6455 the server chooses; `Error(Nil)` means "complete the handshake
without a subprotocol and close"):

```gleam
pub fn subprotocol_negotiation_test() {
  // The station listed these in `Sec-WebSocket-Protocol`; this server
  // prefers 2.0.1. Echo the winner's token back in the handshake response.
  let assert Ok(version) =
    ocpp.negotiate(offered: ["ocpp2.1", "ocpp2.0.1"], supported: [
      ocpp.v2_0_1,
      ocpp.v1_6,
    ])
  ocpp.subprotocol(version) |> should.equal("ocpp2.0.1")
}
```

### Charge-point side: boot over the station machine

`ocpp/protocol/station` wraps the generic RPC endpoint and drives the boot /
heartbeat lifecycle. `ocpp/protocol/v2_0_1` (imported as `protocol` here)
binds it to the 2.0.1 codecs; `ocpp/protocol/v1_6` and `ocpp/protocol/v2_1`
do the same for the other versions:

```gleam
pub fn station_boot_round_trip_test() {
  // Bind the generic station machine to OCPP 2.0.1: the boot payload it
  // introduces itself with, a 30 s call timeout, a 5 s boot retry.
  let config =
    protocol.station_config(
      call_timeout_ms: 30_000,
      boot_request: boot_notification.new_request(
        charging_station: charging_station.new(
          model: "Wallbox One",
          vendor_name: "Acme",
        ),
        reason: boot_reason.PowerUp,
      ),
      boot_retry_ms: 5000,
    )

  // `init` starts the provisioning lifecycle: the machine immediately asks
  // the adapter to write the BootNotification CALL to the socket.
  let #(machine, fx) = station.init(config, 0)
  let assert [station.WireOut(call)] = effect.to_list(fx)
  let assert Ok(frame.Call(boot_id, "BootNotification", _)) = frame.decode(call)

  // The CSMS accepts with a 300 s heartbeat interval. (Forged by hand here;
  // a real CALLRESULT arrives on the WebSocket.)
  let assert Ok(csms_time) = datetime.parse_rfc3339("2026-01-01T00:00:00Z")
  let reply =
    frame.encode_call_result(
      boot_id,
      boot_notification.response_to_json(boot_notification.new_response(
        current_time: csms_time,
        interval: 300,
        status: registration_status.Accepted,
      )),
    )

  // Feeding the CALLRESULT registers the station. The machine consumes the
  // boot reply itself — no output for the application — and schedules the
  // first heartbeat one interval after acceptance.
  let #(machine, fx) =
    station.update(machine, station.WireIn(json.to_string(reply)), 100)
  effect.to_list(fx) |> should.equal([])
  station.registration(machine)
  |> should.equal(station.Registered(
    interval_ms: 300_000,
    heartbeat_due: 300_100,
  ))
  station.next_deadline(machine) |> should.equal(Some(300_100))
}
```

From here the adapter keeps one timer aimed at `next_deadline` and feeds
`station.Tick` when it fires (heartbeats, boot retries, and call timeouts all
run off it), `station.WireIn` for every inbound frame, and
`station.CallRequested`/`station.ReplyProvided` for the application's own
traffic.

### CSMS side: answering inbound calls

`ocpp/protocol/csms` is the per-connection CSMS machine. Inbound CALLs
surface as `CallReceived(id, request)` with the request already decoded to
the version's `dispatch.Request` union; the application answers with the
endpoint's typed reply, `Result(res, #(rpc.ErrorCode, String))`:

```gleam
/// The CSMS application's request handler: pattern-match the typed request
/// union and return the endpoint's typed reply — `Ok(response)` goes out as
/// a CALLRESULT, `Error(#(code, description))` as a CALLERROR.
fn handle_request(
  request: dispatch.Request,
) -> Result(dispatch.Response, #(rpc.ErrorCode, String)) {
  case request {
    dispatch.BootNotificationRequest(_) -> {
      let assert Ok(now) = datetime.parse_rfc3339("2026-01-01T00:00:00Z")
      Ok(
        dispatch.BootNotificationResponse(boot_notification.new_response(
          current_time: now,
          interval: 300,
          status: registration_status.Accepted,
        )),
      )
    }
    _ -> Error(#(rpc.NotImplemented, "This CSMS only handles boots"))
  }
}

pub fn csms_inbound_call_handling_test() {
  // One machine per station connection. `RejectUnregistered` answers
  // non-boot CALLs from an unaccepted station with a CALLERROR
  // `SecurityError` (B02.FR.09) instead of delivering them.
  let #(machine, _fx) =
    csms.init(
      protocol.csms_config(
        call_timeout_ms: 30_000,
        enforcement: csms.RejectUnregistered,
      ),
      0,
    )

  // A BootNotification CALL arrives on the socket. (Forged here with the
  // dispatch codec: `request_to_json` recovers the action string and the
  // JSON payload from the typed union.)
  let #(action, payload) =
    dispatch.request_to_json(
      dispatch.BootNotificationRequest(boot_notification.new_request(
        charging_station: charging_station.new(
          model: "Wallbox One",
          vendor_name: "Acme",
        ),
        reason: boot_reason.PowerUp,
      )),
    )
  let call = json.to_string(frame.encode_call("19223201", action, payload))

  // The machine decodes the frame and hands the application a typed
  // request, keyed by the CALL's message id.
  let #(machine, fx) = csms.update(machine, csms.WireIn(call), 50)
  let assert [csms.CallReceived(id, request)] = effect.to_list(fx)

  // The application replies through its handler; the machine writes the
  // CALLRESULT and, because the reply accepted a boot request, now tracks
  // the peer station as registered.
  let #(machine, fx) =
    csms.update(machine, csms.ReplyProvided(id, handle_request(request)), 60)
  let assert [csms.WireOut(result)] = effect.to_list(fx)
  let assert Ok(frame.CallResult(_, _)) = frame.decode(result)
  csms.peer_registration(machine) |> should.equal(csms.PeerRegistered)
}
```

For a full station ↔ CSMS conversation — both machines wired back to back,
covering heartbeats, CSMS-initiated calls, timeouts, and pre-acceptance
queueing — see `test/ocpp_protocol_conversation_test.gleam` (and its 1.6 and
2.1 siblings).

## Development

The per-version modules are **generated** — do not edit them by hand. The
generator lives in `dev/` (so it ships with neither the package nor its
dependencies). To regenerate from the vendored schemas, from the repo root:

```sh
gleam dev                 # writes the staging tree under generated/
gleam format generated
for v in v1_6 v2_0_1 v2_1; do
  rsync -a --delete generated/ocpp/$v/ src/ocpp/$v/
done
```

Run the tests (codec round-trips, protocol-machine conversations, and the
codegen suites together):

```sh
gleam test
```
