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