Codetics · Rust and OCPP

OCPP in Rust: seven open source libraries

An EV charger and its server talk in a shared language called OCPP. We wrote three versions of that language in Rust, a programming language designed to stop a whole family of crashes before the software ever runs, and we took it all the way to a complete server. If you're a developer, you can download all of it and try it on your own machine. If you're looking for someone to trust with a charging project, you can read our work before you ever talk to us.

Public code, Apache-2.0 license, version 0.1.0. Tutorials run on November 5, 2026 against the published versions.

  • 7libraries published on crates.io
  • 3OCPP versions: 1.6, 2.0.1 and 2.1
  • Apache-2.0an open source license, with public code on GitHub
  • 3OCPP security profiles, 1 to 3, supported by the server
Why this work exists

The way to prove you know chargers is to write the code

OCPP is how a charger and its server tell each other that a car's plugged in, that a charge has started, that it just stopped. On paper it's a specification. In code, every version shows up with its own messages and rules. Version 1.6 still carries error codes with old spellings you have to recognize, and 2.1 adds power flowing both ways and battery swapping.

Kevin Vavelin wrote these seven libraries for Codetics, a French software engineering company, one message at a time, with no other OCPP library and no code generation, right down to the WebSocket layer that carries the traffic. That takes a while, and it's the part we enjoy: by the end, we know why every line is there.

If you're not a developer, here's the one thing to take away. When we talk to you about EV charging, you don't have to take our word for it. The code is public, its limits are in writing, and someone you trust can read all of it before you decide.

The seven crates

Seven building blocks, together or one at a time

A crate is what Rust calls a library, a package of code ready to reuse. All seven are at version 0.1.0 on crates.io, published on October 21, 2026, and they compile on Rust 1.88 or later. Take the server if you want a running system, or just the piece you're missing.

  • codetics-ocpp-service 0.1.0

    The complete management server

    A CSMS, the server chargers connect to, for OCPP 1.6J, 2.0.1 and 2.1. It comes with TLS and security profiles 1 to 3, smart charging, reservations, tariffs and cost calculation, an HTTP API and PostgreSQL storage.

  • codetics-ocpp16 0.1.0

    OCPP 1.6

    Every 1.6 message as plain Rust types, with validation and typed dispatch. No other OCPP library, no code generation.

  • codetics-ocpp201 0.1.0

    OCPP 2.0.1

    The same approach for 2.0.1, Edition 4: every message as plain Rust types, validated and dispatched by type.

  • codetics-ocpp21 0.1.0

    OCPP 2.1

    Version 2.1, Edition 2, with what it brings: SEND messages, bidirectional power transfer, battery swapping and periodic event streams.

  • codetics-ocpp-core 0.1.0

    The shared foundation

    The parts of OCPP-J that don't depend on a version: RPC framing, error codes including the historic 1.6 spellings, RFC 3339 date-time, validation, subprotocol negotiation and message IDs.

  • codetics-ocpp-ws 0.1.0

    The WebSocket layer

    Our own WebSocket stack, RFC 6455 and RFC 7692 (permessage-deflate, mandatory for a CSMS in 2.0.1 and 2.1), built for many long-lived connections. A minimal client comes along for simulators and tests.

  • codetics-evse-id 0.1.0

    E-mobility identifiers

    EVSE, pool and station IDs in IDRR / eMI3 format, with the IDRR Annex 1 check character, which catches every single-character error and every adjacent transposition.

The tutorial

From nothing to a simulated charger talking to your own server

Five steps on your own machine, with no hardware, since the charger is a small program. By the end, your server answers Accepted to a charger it just recognized, and you've watched every exchange go by.

What you'll need
  • Rust 1.88 or later
  • PostgreSQL, with the pg_trgm and unaccent extensions
  • OpenSSL, unless you install with the vendored-openssl feature
  • curl, for talking to the server from the terminal
  1. 01

    Install the server

    cargo install downloads the source from crates.io and builds it on your machine, which takes a few minutes. The server needs OpenSSL. If you don't have it, the vendored-openssl feature builds it along with everything else.

    Terminal
    cargo install codetics-ocpp-service
    
    # No OpenSSL on this machine? Build it from source instead:
    cargo install codetics-ocpp-service --features vendored-openssl
  2. 02

    Create the database

    The server keeps everything in PostgreSQL. createdb makes an empty database, and DATABASE_URL tells the server where to find it. You don't have any tables to create: the first start takes care of that, and that's when it needs the pg_trgm and unaccent extensions.

    Terminal
    createdb ocpp
    export DATABASE_URL=postgres://localhost/ocpp
  3. 03

    Start the server

    The server is now listening in two places: port 9000 for chargers, and 127.0.0.1:8080 for the HTTP API, the one your own tools call. Leave it running and open a second terminal. If /health comes back with status ok and database true, everything's hooked up.

    Terminal
    codetics-ocpp-service
    
    # In another terminal:
    curl http://127.0.0.1:8080/health
    Expected output
    {"connections":0,"database":true,"node":"my-machine","status":"ok"}
  4. 04

    Register the station

    An unknown station gets refused with an HTTP 404, which is why you register it first. DEMO-01 is the identity the simulated charger will present when it connects.

    Terminal
    curl -X POST http://127.0.0.1:8080/api/v1/stations \
      -H 'content-type: application/json' \
      -d '{"ocpp_identity":"DEMO-01","name":"My first charger"}'
  5. 05

    Connect a simulated charger

    This small program plays the charger. Put it in src/main.rs and run cargo run. It connects to port 9000 at /ocpp/DEMO-01, offers the ocpp1.6 subprotocol, then sends a BootNotification, the message a charger uses to introduce itself. The first reply may catch you off guard, because it's a TriggerMessage(BootNotification): this station has never booted, so the server asks it to introduce itself. A real charger would answer that, and our simulator just prints it. The next line answers your message with Accepted, and your server has just accepted its first station.

    Terminal
    cargo new station && cd station
    cargo add codetics-ocpp-ws
    cargo add tokio -F rt-multi-thread,macros
    src/main.rs
    use codetics_ocpp_ws::Message;
    use codetics_ocpp_ws::client::{ClientOptions, connect};
    
    #[tokio::main]
    async fn main() {
        let options = ClientOptions {
            addr: "127.0.0.1:9000",
            path: "/ocpp/DEMO-01",
            subprotocols: &["ocpp1.6"],
            deflate: false,
            basic_auth: None,
        };
        let mut station = connect(&options).await.expect("connection refused");
        println!("connected, protocol: {:?}", station.subprotocol);
    
        let boot = r#"[2,"1","BootNotification",{"chargePointVendor":"Demo","chargePointModel":"Simulator"}]"#;
        station.writer.send_text(boot).await.unwrap();
    
        while let Ok(Some(message)) = station.reader.recv().await {
            if let Message::Text(reply) = message {
                println!("server replied: {reply}");
                if reply.contains(r#""1""#) {
                    break;
                }
            }
        }
    }
    Expected output
    connected, protocol: Some("ocpp1.6")
    server replied: [2,"64da928f-...","TriggerMessage",{"requestedMessage":"BootNotification"}]
    server replied: [3,"1",{"currentTime":"2026-11-05T16:51:20.306Z","interval":300,"status":"Accepted"}]

This tutorial is for a local trial, over an unencrypted connection. For real use, set up TLS (OCPP_TLS_LISTEN, certificates, security profiles) and API_TOKEN, and give each charger a password.

One library at a time

Six libraries, six small examples to run

Each example is a complete src/main.rs, compiled and run against the published versions. Create a project with cargo new, add the dependency with the command shown, paste in the code, then run cargo run.

  • codetics-ocpp16

    Reading a charger's message in 1.6

    StationCall::decode takes the action's name and its JSON payload and hands you back a Rust type. The example reads a BootNotification, then tries a Reset. That message goes from the server to the charger and never the other way, so decoding refuses it with WrongDirection. A message going the wrong way stops right there, before it reaches your business logic.

    Add to your project
    cargo add codetics-ocpp16
    src/main.rs
    use codetics_ocpp16::{DecodeError, StationCall};
    
    fn main() {
        let payload = r#"{"chargePointVendor":"VendorX","chargePointModel":"X1"}"#;
        let call = StationCall::decode("BootNotification", payload).unwrap();
        let StationCall::BootNotification(boot) = call else { unreachable!() };
        println!("vendor: {}", boot.charge_point_vendor);
    
        // Reset is sent by the central system, never by a charger, so it is refused.
        let wrong = StationCall::decode("Reset", r#"{"type":"Hard"}"#);
        assert!(matches!(wrong, Err(DecodeError::WrongDirection(_))));
    }
  • codetics-ocpp201

    Same message, different shape in 2.0.1

    It's still a BootNotification, but the vendor now sits inside a chargingStation object, and there's a boot reason too. Compare it with the previous example: same action, different structure, and that's why each version gets its own library.

    Add to your project
    cargo add codetics-ocpp201
    src/main.rs
    use codetics_ocpp201::StationCall;
    
    fn main() {
        let payload = r#"{"reason":"PowerUp","chargingStation":{"model":"SingleSocketCharger","vendorName":"VendorX"}}"#;
        let call = StationCall::decode("BootNotification", payload).unwrap();
        let StationCall::BootNotification(boot) = call else { unreachable!() };
        println!("vendor: {}", boot.charging_station.vendor_name);
    }
  • codetics-ocpp21

    What's new in 2.1

    Action knows which way each message travels: BatterySwap goes from the station to the CSMS. is_send spots SEND messages, the ones that don't wait for a reply, like NotifyPeriodicEventStream, a periodic stream of measurements that the example then decodes with StationSend.

    Add to your project
    cargo add codetics-ocpp21
    src/main.rs
    use codetics_ocpp21::{Action, Direction, StationSend};
    
    fn main() {
        assert_eq!(Action::BatterySwap.direction(), Direction::StationToCsms);
        assert!(Action::NotifyPeriodicEventStream.is_send());
    
        let payload = r#"{"id":1,"pending":0,"basetime":"2025-01-01T00:00:00Z","data":[{"t":0,"v":"230.4"}]}"#;
        let StationSend::NotifyPeriodicEventStream(stream) =
            StationSend::decode("NotifyPeriodicEventStream", payload).unwrap();
        println!("points received: {}", stream.data.len());
    }
  • codetics-ocpp-core

    Agreeing on a version, then reading a frame

    The client lists the versions it speaks in order of preference, and the server picks the first one it accepts. Here it only accepts 1.6, so that's the one. Frame::parse then reads a [2, id, action, payload] frame, the shape of every OCPP-J call, and encode_result builds the reply with the same id.

    Add to your project
    cargo add codetics-ocpp-core serde_json
    src/main.rs
    use codetics_ocpp_core::rpc::{self, Frame};
    use codetics_ocpp_core::version::negotiate;
    use codetics_ocpp_core::OcppVersion;
    
    fn main() {
        // The client lists versions by preference, the server picks the first it accepts.
        let (version, token) = negotiate(["ocpp2.1, ocpp1.6"], &[OcppVersion::V16]).unwrap();
        println!("{version:?} over {token}");
    
        if let Frame::Call { id, action, .. } = Frame::parse(r#"[2,"19223201","Heartbeat",{}]"#).unwrap() {
            let body = serde_json::json!({ "currentTime": "2025-01-01T00:00:00Z" });
            println!("{action}: {}", rpc::encode_result(&id, &body).unwrap());
        }
    }
  • codetics-ocpp-ws

    A WebSocket session that echoes what it gets

    server takes a stream your HTTP server (hyper, axum...) has already upgraded to WebSocket. The loop echoes text, answers pings and closes cleanly. So the example runs as is, an in-memory pipe stands in for a real connection: the other end closes, the session ends and the program prints session closed.

    Add to your project
    cargo add codetics-ocpp-ws
    cargo add tokio -F rt-multi-thread,macros
    src/main.rs
    use codetics_ocpp_ws::{Message, WsConfig, close_code};
    use tokio::io::{AsyncRead, AsyncWrite};
    
    // Give it the stream your HTTP server has already upgraded (hyper, axum...).
    async fn session<S: AsyncRead + AsyncWrite>(upgraded: S) {
        let (mut reader, mut writer) = codetics_ocpp_ws::server(upgraded, WsConfig::default(), None);
        while let Ok(Some(message)) = reader.recv().await {
            match message {
                Message::Text(text) => writer.send_text(&text).await.unwrap(),
                Message::Ping(data) => writer.send_pong(&data).await.unwrap(),
                Message::Close(_) => {
                    let _ = writer.send_close(close_code::NORMAL, "").await;
                    break;
                }
                _ => {}
            }
        }
    }
    
    #[tokio::main]
    async fn main() {
        // Two ends of an in-memory pipe stand in for a real connection, so this runs as is.
        let (server_end, client_end) = tokio::io::duplex(4096);
        drop(client_end);
        session(server_end).await;
        println!("session closed");
    }
  • codetics-evse-id

    Building a charge point ID

    EvseId::station creates a station's ID, evse_of creates the ID of a charge point that belongs to it, and kind confirms what you got. with_operator adds the country and the operator. The real value of the format is its check character: a typo in one character, or two neighboring characters swapped, gets caught.

    Add to your project
    cargo add codetics-evse-id
    src/main.rs
    use codetics_evse_id::{EvseId, IdType};
    
    fn main() {
        let station = EvseId::station(1);
        let evse = EvseId::evse_of(&station, 2).unwrap();
        assert_eq!(evse.kind(), IdType::Evse);
    
        println!("{}", station.as_str());
        println!("{}", evse.with_operator("FR", "ABC"));
    }
The limits

Where version 0.1.0 stops

Better to know before you build on it.

  • Several server instances

    Command routing between nodes isn't written yet. With several instances, a command for a station connected elsewhere returns a 409.

  • Certificate management

    There's no CA or OCSP integration yet, so SignCertificate is rejected.

  • The 1.6 security messages

    The messages from the OCPP 1.6 security whitepaper, such as SecurityEventNotification and SignedUpdateFirmware, aren't implemented yet.

  • A first version

    Everything is at version 0.1.0, and our tutorials use a simulated charger. Before real use, run your own tests with your own chargers.

  • An independent project

    These libraries aren't affiliated with or endorsed by the Open Charge Alliance, and we don't claim any certification.

What about EV Supervision Lite?

EV Supervision Lite is a separate subject

Both come from Codetics and both talk to chargers. They're still two distinct pieces of work, and it's worth keeping them apart, especially when you're buying.

  • Two separate programs

    EV Supervision Lite runs on Kotlin, speaks OCPP 1.6 JSON and doesn't use these libraries.

  • Two different licenses

    These libraries are open source, under Apache-2.0. EV Supervision Lite's code is public under the Codetics Attribution License 1.0, which isn't an open source license.

  • Outside the €3,000 excl. VAT offer

    That offer covers configuring and deploying EV Supervision Lite. These libraries aren't part of it.

  • With Codetics, on quote

    They're free to use and you don't need us for that. If you want to integrate them into your system or build on them with us, that's work on quote.

Open source under Apache-2.0, with the credit kept

You can use, modify and redistribute these libraries, including in a commercial product, as long as you follow the Apache-2.0 license. If you redistribute them, keep the NOTICE file, which credits Kevin Vavelin and Codetics.

View the repository on GitHub

FAQ on the Codetics OCPP libraries in Rust

Yes. Codetics publishes codetics-ocpp16, codetics-ocpp201 and codetics-ocpp21 on crates.io: every message of the version as plain Rust types, with validation and typed dispatch, and no other OCPP library or code generation. They're at version 0.1.0, under the Apache-2.0 license, and compile on Rust 1.88 or later.

Install codetics-ocpp-service with cargo install, which builds it in a few minutes, create a PostgreSQL database, point DATABASE_URL at it and start the server. Then register a station through the HTTP API, because an unknown station is refused with a 404, and connect a simulator on port 9000. The server answers its BootNotification with Accepted.

Yes. They're under the Apache-2.0 license and the code is public on GitHub. You can use, modify and redistribute them, and one of the conditions is keeping the NOTICE file that credits Kevin Vavelin and Codetics. EV Supervision Lite, a different Codetics product, uses an attribution license that isn't an open source license.

No. The project is independent, it isn't affiliated with or endorsed by the Open Charge Alliance, and Codetics doesn't claim any certification. This is version 0.1.0 with its gaps in writing: no command routing between instances, no certificate management, no messages from the 1.6 security whitepaper. Run your own tests with your own chargers before real use.

There's no technical link. EV Supervision Lite runs on Kotlin, speaks OCPP 1.6 JSON and doesn't use these libraries. The €3,000 excl. VAT offer covers configuring and deploying EV Supervision Lite, and these libraries aren't part of it. Integrating them into your system or building on them with Codetics is work on quote.
Get started

Have a problem that needs engineering?

Let's discuss it. Describe the situation in your own words, even if you don't know yet how to solve it. Your name, your email and a few lines are enough.

  1. 01You describe the problem
  2. 02We talk it through
  3. 03You get a scope and a price in writing
Tell us about your challenge
Technical Assessment

Is the problem still unclear?

€390

Fixed price, paid online · VAT not applicable, art. 293 B of the French Tax Code.

The Technical Assessment tells you what's behind it and what the next stage would cost.

See the Technical Assessment