Codetics OCPP
A complete implementation of the Open Charge Point Protocol (OCPP-J 1.6, 2.0.1 and 2.1) in Rust, and a Charging Station Management System (CSMS) service built on it, written by Kevin Vavelin for Codetics.
- Every message of the three versions as plain Rust types, with validation and typed dispatch; no OCPP library, no code generation.
- Its own WebSocket stack (RFC 6455 + RFC 7692 compression), built for tens of thousands of concurrent stations.
- TLS with OCPP Security Profiles 1–3, smart charging, reservations, tariffs and cost calculation, an HTTP API, PostgreSQL storage.
It can be used as is, as a whole service or crate by crate. Contributions are welcome (see CONTRIBUTING.md).
Crates
| Crate | Directory | Role |
|---|---|---|
codetics-ocpp-core |
crates/ocpp-core |
Version-independent OCPP-J: RPC framing (CALL / CALLRESULT / CALLERROR / CALLRESULTERROR / SEND), error codes and their 1.6 spellings, RFC 3339 DateTime, constraint validation, subprotocol negotiation, message ids |
codetics-ocpp16 |
crates/ocpp16 |
OCPP 1.6 messages |
codetics-ocpp201 |
crates/ocpp201 |
OCPP 2.0.1 messages (Edition 4) |
codetics-ocpp21 |
crates/ocpp21 |
OCPP 2.1 messages (Edition 2), including SEND |
codetics-ocpp-ws |
crates/ocpp-ws |
WebSocket: RFC 6455 framing, RFC 7692 permessage-deflate, handshake, minimal client |
codetics-evse-id |
crates/evse-id |
E-mobility IDs (E/P/S + id) with the IDRR Annex 1 check character |
codetics-ocpp-service |
. |
The CSMS service: handshake, TLS, sessions, per-version handlers, smart charging, reservations, tariffs, PostgreSQL, HTTP API |
Each version crate is independent, and so are the version handlers of the service (src/ocpp/v16.rs, v201.rs, v21.rs).
They share only version-neutral logic.
External crates: tokio, axum/hyper (HTTP), serde/serde_json, sqlx (PostgreSQL), flate2 (raw DEFLATE),
openssl (TLS), argon2 (password hashing), tracing.
Version negotiation
The Sec-WebSocket-Protocol header is the source of truth:
- OCPP-J 1.6 §3.1.2: "The exact OCPP version MUST be specified in the Sec-Websocket-Protocol field."
- 2.0.1 and 2.1 Part 4 §3.1.2: "If the OCPP version is part of the OCPP-J endpoint URL it SHALL NOT determine the OCPP version to use."
The server picks the first version in the client's preference order that is both enabled (OCPP_VERSIONS) and allowed
for that station (allowed_versions). If none matches, the handshake completes without Sec-WebSocket-Protocol and the
connection is closed (Part 4 §3.2). Stations sold as "OCPP 1.8" negotiate ocpp1.6.
Connections and security
/<any prefix>/<identity>: the identity is the last path segment, percent-decoded (≤ 48 characters, no:). Unknown stations get HTTP 404: only stations registered through the API can connect.- Security Profiles (Part 2 §A00, same as the 1.6 security whitepaper), configured per station (
security_profile): profile 1 = Basic auth over plain WebSocket, 2 = TLS + Basic auth, 3 = TLS + client certificate. A station connecting with another profile is refused (A00.FR.002). Without a profile, a password is required only if one is set. - TLS (OpenSSL): TLS 1.2+, the four cipher suites of A00.FR.318 (including
TLS_RSA_WITH_AES_*_GCM, which rustls does not implement) plus ECDHE-RSA, an RSA and an ECDSA certificate side by side, no compression or renegotiation. Profile 3 checks the certificate chain againstOCPP_TLS_CLIENT_CA, the O RDN againstOCPP_CSO_NAME(A00.FR.404) and the CN against the station's identity or serial number (A00.FR.405); refusals are logged asInvalidChargingStationCertificatesecurity events. Certificates are reloaded onSIGHUP. - Passwords: raw bytes (1.6 binary
AuthorizationKey), its 40-character hex form, or a UTF-8 string (2.x); stored as Argon2id hashes, verified with bounded concurrency so reconnect storms cannot exhaust memory. - One task per connection multiplexes reads, the command queue, call timeouts, keep-alive pings and shutdown. At most one CSMS-initiated CALL is in flight per connection (§4.1.1).
- permessage-deflate (required for a CSMS in 2.0.1/2.1) with pooled compressors: idle connections hold no compression state.
- Before acceptance, transaction-related CALLs get
SecurityError; never-booted stations are sentTriggerMessage(BootNotification). A newer connection for the same identity replaces the older one. - Per-version RPC errors: 1.6 spellings, 2.0.1
MessageTypeNotSupported/RpcFrameworkErrorwith id"-1", 2.1 CALLRESULTERROR and SEND.
Measured on a laptop (client and server in one process): 10,000 stations connected and exchanged a heartbeat in about 6 s.
Raise the file-descriptor limit (ulimit -n) for production loads.
Smart charging (1.6 §3.13, 2.x block K)
- Registry of the charging profiles sent to (or reported by) each station, with the CSMS-side rules checked before
sending: station max profile on connector/EVSE 0 only, TxProfile only during a transaction with its
transactionIdfilled in (§5.16, K01.FR.03), no external-constraints profile from the CSMS (K01.FR.22), no conflicting stack levels (K01.FR.06/39),phaseToUserules, 2.1 PriorityCharging and Dynamic profile rules. - Composite schedule computed by the CSMS (stacking, TxProfile over TxDefault, PriorityCharging, external limits, LocalGeneration, A↔W conversion), or requested from the station.
- External limits (
NotifyChargingLimit/ClearedChargingLimit),ReportChargingProfilessynchronisation, TxProfiles cleared when their transaction ends. - ISO 15118 charging needs (K15–K19): the CSMS answers and sends a TxProfile within the station limits.
- OCPP 2.1 dynamic profiles (K28):
UpdateDynamicSchedulepushes andPullDynamicScheduleUpdateanswers.
Reservations (1.6 §5.13, 2.x H01–H04)
Reservation ids assigned by the CSMS; ReserveNow / CancelReservation; status followed from the station's answers,
StartTransaction / TransactionEvent (used), ReservationStatusUpdate, Faulted/Unavailable connectors (1.6) and an
expiry sweeper. Reservations by EVSE, any EVSE, or connector type (2.x), with group tokens.
Tariffs and cost (2.x block I)
OCPP 1.6 has no tariff mechanism; this applies to OCPP 2.0.1 and 2.1 stations, per cost_mode:
Central: the CSMS calculates. Tariff text inIdTokenInfo.personalMessage(I01), running cost onUpdatedevents (I02) and final cost onEnded(I03,0.00for free sessions).Local(2.1):SetDefaultTariff,GetTariffs,ClearTariffs, driver tariffs inAuthorizeResponse(I07–I10),ChangeTransactionTariff(I11); the station'scostDetailsare stored and recalculated by the CSMS for verification (I12).- Tariffs use the OCPP 2.1
TariffTypeand are immutable per id (I08.FR.08). The cost engine follows I12: conditions on local time of day, weekday, dates, energy, power, current and durations; a new charging period whenever the applicable prices change; stacked taxes; min/max cost; tariff changes without retroactivity. Local time comes from the system zoneinfo database, daylight saving time included.
EVSE IDs
IDRR format without the COUNTRY*OPERATOR* prefix: <E|P|S><id>. A station gets S + 6 base-36 characters + check
character (e.g. S000001D); each EVSE gets E + station body + 2-digit EVSE number + check character. The check
character uses the IDRR Annex 1 algorithm, which Annex 1 defines for contract IDs; here it covers the local part.
Running
Migrations run at startup (PostgreSQL with the pg_trgm and unaccent extensions). OpenSSL is linked from the
system; build with --features vendored-openssl to compile it from source.
| Variable | Default | |
|---|---|---|
OCPP_LISTEN |
0.0.0.0:9000 |
Plain WebSocket listener; none disables it |
OCPP_TLS_LISTEN |
unset | TLS listener (Security Profiles 2/3) |
OCPP_TLS_CERT / OCPP_TLS_KEY |
Server certificate chain and key (PEM) | |
OCPP_TLS_CERT2 / OCPP_TLS_KEY2 |
Second pair, e.g. RSA next to ECDSA | |
OCPP_TLS_CLIENT_CA |
CA bundle for station certificates (profile 3) | |
OCPP_TLS_CIPHERS |
A00.FR.318 suites | OpenSSL cipher list for TLS 1.2 |
OCPP_CSO_NAME |
unset | Expected O RDN of station certificates |
OCPP_MAX_CONNECTIONS |
200000 |
Per listener |
API_LISTEN |
127.0.0.1:8080 |
Internal HTTP API |
API_TOKEN |
unset | Requires Authorization: Bearer … on /api/v1 |
DATABASE_URL |
required | |
DATABASE_MAX_CONNECTIONS |
20 |
|
NODE_ID |
$HOSTNAME |
Stored as connected_node |
OCPP_VERSIONS |
1.6,2.0.1,2.1 |
|
HEARTBEAT_INTERVAL_SECS |
300 |
Returned in BootNotification |
CALL_TIMEOUT_SECS |
30 |
CSMS → station calls |
PING_INTERVAL_SECS |
60 |
Server ping when idle; closes after 3× idle; 0 disables |
MAX_MESSAGE_SIZE |
4194304 |
|
WS_DEFLATE |
true |
|
WS_FORCE_CLIENT_NO_CONTEXT_TAKEOVER |
false |
Saves memory per connection |
OCPP_REQUIRE_PASSWORD |
false |
|
AUTHORIZE_UNKNOWN_TOKENS |
false |
|
COMMAND_QUEUE_SIZE |
16 |
Per connection |
AUTH_CONCURRENCY |
CPU count | Parallel Argon2 verifications |
NOMINAL_VOLTAGE |
230 |
A ↔ W conversion in composite schedules |
SMART_CHARGING_EV_NEEDS |
true |
Answer ISO 15118 charging needs with a TxProfile |
ALLOW_BIDIRECTIONAL |
false |
Accept bidirectional transfer requests (2.1) |
RESERVATION_SWEEP_SECS |
30 |
|
DEFAULT_TIMEZONE |
UTC |
For stations without timezone |
TZDIR |
/usr/share/zoneinfo |
|
LOG_FORMAT / RUST_LOG |
text / info |
LOG_FORMAT=json for JSON logs |
HTTP API (/api/v1)
POST /stations |
Register: ocpp_identity, name, latitude, longitude, address_line1/2, postal_code, city, region, country_code, timezone, allowed_versions, boot_policy, security_profile, cost_mode, evse_id_body, password or password_hex |
GET /stations?q=&lat=&lon=&radius_km=&city=&postal_code=&country_code=&connected= |
Address search (accent- and case-insensitive), radius search, filters |
GET/PATCH/DELETE /stations/{identity} |
Details include EVSEs, connector status and the live connection |
PUT /stations/{identity}/password |
Set or remove credentials |
GET /stations/{identity}/evses, /transactions?active=, /events?action= |
|
POST /stations/{identity}/ocpp/{Action} |
Any CSMS→station action of the negotiated version, validated both ways |
POST /stations/{identity}/commands/{reset, remote-start, remote-stop, unlock-connector, change-availability, trigger-message} |
Version-independent commands |
GET/POST /stations/{identity}/charging-profiles, POST …/clear, POST …/sync, POST …/{profile_id}/dynamic-update |
Smart charging |
GET /stations/{identity}/composite-schedule?evse_id=&duration=&unit=&source=csms|station |
Composite schedule |
GET /stations/{identity}/charging-limits |
External limits |
GET/POST /stations/{identity}/reservations, GET/DELETE …/reservations/{id} |
Reservations |
GET/POST /stations/{identity}/tariffs, DELETE …/tariffs/{tariff_id} |
Default tariffs |
GET /tariffs, GET/PUT/DELETE /tariffs/{tariff_id} |
Tariff registry |
GET /transactions/{id}, GET …/cost, POST …/tariff |
Transactions, cost, tariff change (2.1) |
PUT /id-tokens, GET /id-tokens, DELETE /id-tokens/{type}/{token} |
Authorization tokens and driver tariffs |
GET /connections, GET /health |
Command results: 200 with the station response, 400 invalid payload or rule violation, 409 conflict or station
not connected, 502 CALLERROR or invalid response, 504 timeout.
Tests
TEST_DATABASE_URL=postgres://localhost/postgres E2E_STATIONS=10000 TEST_DATABASE_URL=…
Not implemented yet
- Cross-node command routing: with several instances, a command for a station connected elsewhere returns
409withconnected_node. - Certificate management: no CA or OCSP integration (
SignCertificate→ Rejected, certificate status requests → Failed). - The OCPP 1.6 security whitepaper messages (
SecurityEventNotification,SignedUpdateFirmware, …).
Author, license and credit
Codetics OCPP is written by Kevin Vavelin — codetics.fr.
Licensed under the Apache License, Version 2.0. Copyright 2026 Kevin Vavelin, Codetics.
You may use, modify and redistribute this work, including commercially, provided that every redistribution of it or of a derived work — forks included — keeps the copyright notices and includes the NOTICE file, which credits Kevin Vavelin / Codetics as the origin of the work.
OCPP is a specification of the Open Charge Alliance; this project is independent and not endorsed by it. See NOTICE for third-party attributions.