codetics-ocpp-service 0.1.0

Codetics OCPP: Charging Station Management System (CSMS) service for OCPP 1.6J, 2.0.1 and 2.1, with TLS security profiles, smart charging, reservations and tariffs
Documentation
# 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](https://codetics.fr).

- 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](CONTRIBUTING.md)).

## Crates

| Crate | Directory | Role |
|---|---|---|
| [`codetics-ocpp-core`]crates/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 | `crates/ocpp16` | OCPP 1.6 messages |
| [`codetics-ocpp201`]crates/ocpp201 | `crates/ocpp201` | OCPP 2.0.1 messages (Edition 4) |
| [`codetics-ocpp21`]crates/ocpp21 | `crates/ocpp21` | OCPP 2.1 messages (Edition 2), including SEND |
| [`codetics-ocpp-ws`]crates/ocpp-ws | `crates/ocpp-ws` | WebSocket: RFC 6455 framing, RFC 7692 permessage-deflate, handshake, minimal client |
| [`codetics-evse-id`]crates/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 against `OCPP_TLS_CLIENT_CA`, the O RDN against `OCPP_CSO_NAME` (A00.FR.404)
  and the CN against the station's identity or serial number (A00.FR.405); refusals are logged as
  `InvalidChargingStationCertificate` security events. Certificates are reloaded on `SIGHUP`.
- 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 sent
  `TriggerMessage(BootNotification)`. A newer connection for the same identity replaces the older one.
- Per-version RPC errors: 1.6 spellings, 2.0.1 `MessageTypeNotSupported` / `RpcFrameworkError` with 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 `transactionId`
  filled in (§5.16, K01.FR.03), no external-constraints profile from the CSMS (K01.FR.22), no conflicting stack levels
  (K01.FR.06/39), `phaseToUse` rules, 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`), `ReportChargingProfiles` synchronisation,
  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): `UpdateDynamicSchedule` pushes and `PullDynamicScheduleUpdate` answers.

## 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 in `IdTokenInfo.personalMessage` (I01), running cost on `Updated` events
  (I02) and final cost on `Ended` (I03, `0.00` for free sessions).
- `Local` (2.1): `SetDefaultTariff`, `GetTariffs`, `ClearTariffs`, driver tariffs in `AuthorizeResponse` (I07–I10),
  `ChangeTransactionTariff` (I11); the station's `costDetails` are stored and recalculated by the CSMS for verification (I12).
- Tariffs use the OCPP 2.1 `TariffType` and 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

```sh
export DATABASE_URL=postgres://localhost/ocpp
cargo run --release
```

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

```sh
cargo test --workspace                                                    # unit tests and README examples
TEST_DATABASE_URL=postgres://localhost/postgres cargo test --workspace    # + end-to-end tests (temporary databases)
E2E_STATIONS=10000 TEST_DATABASE_URL=… cargo test --release --test e2e many_concurrent -- --nocapture
```

## Not implemented yet

- Cross-node command routing: with several instances, a command for a station connected elsewhere returns `409` with
  `connected_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](https://codetics.fr).

Licensed under the [Apache License, Version 2.0](LICENSE). 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](NOTICE) file, which credits
Kevin Vavelin / Codetics as the origin of the work.

OCPP is a specification of the [Open Charge Alliance](https://openchargealliance.org); this project is independent and
not endorsed by it. See [NOTICE](NOTICE) for third-party attributions.