ocpp

Type-safe OCPP (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:

NamespaceContents
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:

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"):

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:

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)):

/// 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:

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):

gleam test