Codetics · Rust et OCPP
OCPP en Rust : sept bibliothèques open source
Une borne de recharge et son serveur se parlent dans une langue commune, OCPP. Nous avons écrit trois versions de cette langue en Rust, un langage de programmation conçu pour arrêter toute une famille de plantages avant que le logiciel tourne, et nous sommes allés jusqu'au serveur complet. Si vous êtes développeur, tout se télécharge et s'essaie sur votre machine. Si vous cherchez à qui confier un projet de recharge, vous pouvez lire notre travail avant même de nous parler.
Code public, licence Apache-2.0, version 0.1.0. Tutoriels exécutés le 5 novembre 2026 sur les versions publiées.
- 7bibliothèques publiées sur crates.io
- 3versions d'OCPP : 1.6, 2.0.1 et 2.1
- Apache-2.0une licence open source, code public sur GitHub
- 3profils de sécurité OCPP, de 1 à 3, pris en charge par le serveur
Savoir parler aux bornes, cela se prouve en l'écrivant
OCPP, c'est ce qu'une borne et son serveur utilisent pour se dire qu'une voiture est branchée, qu'une charge démarre, qu'elle vient de s'arrêter. Sur le papier, c'est une spécification. Dans le code, chaque version arrive avec ses messages et ses règles. La 1.6 traîne encore des codes d'erreur à l'orthographe historique qu'il faut savoir reconnaître, et la 2.1 ajoute le transfert d'énergie dans les deux sens et l'échange de batteries.
Kevin Vavelin a écrit ces sept bibliothèques pour Codetics, message par message, sans autre bibliothèque OCPP et sans génération de code, jusqu'à la couche WebSocket qui transporte les échanges. Cela prend du temps, et c'est justement la partie que nous aimons : à la fin, nous savons pourquoi chaque ligne est là.
Si vous n'êtes pas développeur, retenez une chose. Quand nous vous parlons de recharge, vous n'avez pas à nous croire sur parole. Le code est public, ses limites sont écrites, et une personne de confiance peut tout relire avant que vous décidiez.
Sept briques, à prendre ensemble ou une par une
Une crate, c'est le nom d'une bibliothèque en Rust, un paquet de code prêt à réutiliser. Toutes sont en version 0.1.0 sur crates.io, publiées le 21 octobre 2026, et compilent à partir de Rust 1.88. Prenez le serveur si vous voulez un système qui tourne, ou seulement la brique qui vous manque.
- codetics-ocpp-service 0.1.0
Le serveur de gestion complet
Un CSMS, c'est-à-dire le serveur auquel les bornes se connectent, pour OCPP 1.6J, 2.0.1 et 2.1. Avec TLS et les profils de sécurité 1 à 3, la recharge intelligente, les réservations, les tarifs et le calcul des coûts, une API HTTP et le stockage dans PostgreSQL.
- codetics-ocpp16 0.1.0
OCPP 1.6
Chaque message de la version 1.6 sous forme de types Rust simples, avec validation et aiguillage typé. Aucune autre bibliothèque OCPP, aucune génération de code.
- codetics-ocpp201 0.1.0
OCPP 2.0.1
La même approche pour la version 2.0.1, édition 4 : chaque message en types Rust simples, validé et aiguillé par type.
- codetics-ocpp21 0.1.0
OCPP 2.1
La version 2.1, édition 2, avec ce qu'elle apporte : les messages SEND, le transfert d'énergie dans les deux sens, l'échange de batteries et les flux d'événements périodiques.
- codetics-ocpp-core 0.1.0
Le socle commun
Ce qui ne dépend d'aucune version d'OCPP-J : le découpage des messages RPC, les codes d'erreur avec les orthographes historiques de la 1.6, les dates RFC 3339, la validation, la négociation du sous-protocole et les identifiants de message.
- codetics-ocpp-ws 0.1.0
La couche WebSocket
Notre propre pile WebSocket, RFC 6455 et RFC 7692 (permessage-deflate, obligatoire pour un CSMS en 2.0.1 et 2.1), pensée pour de nombreuses connexions de longue durée. Un petit client l'accompagne pour les simulateurs et les tests.
- codetics-evse-id 0.1.0
Les identifiants de mobilité électrique
Les identifiants EVSE, pool et station au format IDRR / eMI3, avec le caractère de contrôle de l'annexe 1 de l'IDRR, qui détecte toute erreur sur un seul caractère et toute inversion de deux caractères voisins.
De rien du tout à une borne simulée qui parle à votre serveur
Cinq étapes sur votre machine, sans aucun matériel, puisque la borne est un petit programme. À la fin, votre serveur répond Accepted à une borne qu'il vient de reconnaître, et vous avez vu passer chaque échange.
- Rust 1.88 ou plus récent
- PostgreSQL, avec les extensions pg_trgm et unaccent
- OpenSSL, sauf si vous installez avec la fonction vendored-openssl
- curl, pour parler au serveur depuis le terminal
- 01
Installer le serveur
cargo install télécharge le code source depuis crates.io et le compile sur votre machine, ce qui prend quelques minutes. Le serveur a besoin d'OpenSSL. S'il n'est pas là, la fonction vendored-openssl le compile avec le reste.
Terminal cargo install codetics-ocpp-service # No OpenSSL on this machine? Build it from source instead: cargo install codetics-ocpp-service --features vendored-openssl - 02
Créer la base de données
Le serveur garde tout dans PostgreSQL. createdb crée une base vide, et DATABASE_URL dit au serveur où la trouver. Vous n'avez aucune table à créer : le premier démarrage s'en charge, et c'est là qu'il a besoin des extensions pg_trgm et unaccent.
Terminal createdb ocpp export DATABASE_URL=postgres://localhost/ocpp - 03
Démarrer le serveur
Le serveur écoute maintenant à deux endroits : le port 9000 pour les bornes, et 127.0.0.1:8080 pour l'API HTTP, celle que vos propres outils appellent. Laissez-le tourner et ouvrez un second terminal. Si /health répond status ok et database true, tout est branché.
Terminal codetics-ocpp-service # In another terminal: curl http://127.0.0.1:8080/healthRésultat attendu {"connections":0,"database":true,"node":"my-machine","status":"ok"} - 04
Déclarer la station
Une station inconnue est refusée avec une erreur HTTP 404, c'est pourquoi on la déclare d'abord. DEMO-01 est l'identité que la borne simulée présentera en se connectant.
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"}' - 05
Connecter une borne simulée
Ce petit programme joue le rôle de la borne. Mettez-le dans src/main.rs et lancez cargo run. Il se connecte au port 9000 sur /ocpp/DEMO-01, propose le sous-protocole ocpp1.6, puis envoie un BootNotification, le message par lequel une borne se présente. La première réponse peut surprendre, car c'est un TriggerMessage(BootNotification) : cette station n'a encore jamais démarré, alors le serveur lui demande de se présenter. Une vraie borne y répondrait, notre simulateur se contente de l'afficher. La ligne suivante répond à votre message avec Accepted, et votre serveur vient d'accepter sa première station.
Terminal cargo new station && cd station cargo add codetics-ocpp-ws cargo add tokio -F rt-multi-thread,macrossrc/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; } } } }Résultat attendu 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"}]
Ce tutoriel sert à un essai local, sur une connexion non chiffrée. Pour un usage réel, mettez en place TLS (OCPP_TLS_LISTEN, certificats, profils de sécurité) et API_TOKEN, et donnez un mot de passe à chaque borne.
Six bibliothèques, six petits exemples à lancer
Chaque exemple est un src/main.rs complet, compilé et lancé sur les versions publiées. Créez un projet avec cargo new, ajoutez la dépendance avec la commande indiquée, collez le code, puis lancez cargo run.
- codetics-ocpp16
Lire un message de borne en 1.6
StationCall::decode prend le nom de l'action et son contenu JSON, et vous rend un type Rust. L'exemple lit un BootNotification, puis tente un Reset. Ce message va du serveur vers la borne, jamais l'inverse, alors le décodage le refuse avec WrongDirection. L'erreur de sens s'arrête là, avant d'atteindre votre code métier.
Ajouter à votre projet cargo add codetics-ocpp16src/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
Le même message, une autre forme en 2.0.1
Toujours un BootNotification, mais le fabricant se trouve maintenant dans un objet chargingStation, avec une raison de démarrage en plus. Comparez avec l'exemple précédent : même action, autre structure, et c'est pour cela que chaque version a sa propre bibliothèque.
Ajouter à votre projet cargo add codetics-ocpp201src/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
Les nouveautés de la 2.1
Action connaît le sens de chaque message : BatterySwap va de la station vers le CSMS. is_send repère les messages SEND, ceux qui n'attendent pas de réponse, comme NotifyPeriodicEventStream, un flux périodique de mesures que l'exemple décode ensuite avec StationSend.
Ajouter à votre projet cargo add codetics-ocpp21src/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
Se mettre d'accord, puis lire une trame
Le client annonce les versions qu'il parle, par ordre de préférence, et le serveur retient la première qu'il accepte. Ici, il n'accepte que la 1.6, donc c'est elle. Frame::parse lit ensuite une trame [2, identifiant, action, contenu], la forme de tout appel OCPP-J, et encode_result prépare la réponse avec le même identifiant.
Ajouter à votre projet cargo add codetics-ocpp-core serde_jsonsrc/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
Une session WebSocket qui renvoie ce qu'elle reçoit
server reçoit un flux que votre serveur HTTP (hyper, axum...) a déjà fait passer en WebSocket. La boucle renvoie les textes, répond aux pings et ferme proprement. Pour que l'exemple tourne tel quel, un tuyau en mémoire remplace la vraie connexion : on ferme l'autre bout, la session se termine et le programme affiche session closed.
Ajouter à votre projet cargo add codetics-ocpp-ws cargo add tokio -F rt-multi-thread,macrossrc/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
Construire un identifiant de point de charge
EvseId::station crée l'identifiant d'une station, evse_of celui d'un point de charge qui lui appartient, et kind confirme le type obtenu. with_operator ajoute le pays et l'opérateur. Tout l'intérêt du format tient dans son caractère de contrôle : une faute de frappe sur un caractère, ou deux caractères voisins inversés, se détecte.
Ajouter à votre projet cargo add codetics-evse-idsrc/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")); }
Où s'arrête la version 0.1.0
Mieux vaut le savoir avant de construire dessus.
Plusieurs instances du serveur
Le routage des commandes entre nœuds n'est pas encore écrit. Avec plusieurs instances, une commande destinée à une station connectée ailleurs retourne une erreur 409.
La gestion des certificats
Il n'y a pas encore d'intégration avec une autorité de certification ni avec OCSP, donc SignCertificate est rejeté.
Les messages de sécurité de la 1.6
Les messages du livre blanc de sécurité d'OCPP 1.6, comme SecurityEventNotification et SignedUpdateFirmware, ne sont pas encore implémentés.
Une première version
Tout est en version 0.1.0, et nos tutoriels utilisent une borne simulée. Avant un usage réel, faites vos propres essais avec vos bornes.
Un projet indépendant
Ces bibliothèques ne sont ni affiliées à l'Open Charge Alliance, ni approuvées par elle, et nous ne revendiquons aucune certification.
EV Supervision Lite, c'est un autre sujet
Les deux viennent de Codetics et les deux parlent aux bornes. Ce sont pourtant deux travaux distincts, et mieux vaut ne pas les confondre, surtout au moment d'acheter.
Deux logiciels séparés
EV Supervision Lite tourne sur Kotlin, parle OCPP 1.6 JSON et n'utilise pas ces bibliothèques.
Deux licences différentes
Ces bibliothèques sont open source, sous Apache-2.0. Le code d'EV Supervision Lite est public, sous la licence d'attribution Codetics 1.0, qui n'est pas une licence open source.
Hors de l'offre à 3 000 € HT
Cette offre couvre la configuration et le déploiement d'EV Supervision Lite. Ces bibliothèques n'en font pas partie.
Avec Codetics, sur devis
Elles sont libres, vous n'avez pas besoin de nous pour vous en servir. Si vous voulez les intégrer à votre système ou construire dessus avec nous, c'est un travail sur devis.
Open source sous Apache-2.0, avec le crédit conservé
Vous pouvez utiliser, modifier et redistribuer ces bibliothèques, y compris dans un produit commercial, en respectant la licence Apache-2.0. Si vous les redistribuez, gardez le fichier NOTICE, qui crédite Kevin Vavelin et Codetics.
Voir le dépôt sur GitHubQuestions fréquentes sur les bibliothèques OCPP en Rust de Codetics
Un problème qui demande de l'ingénierie ?
Parlons-en. Décrivez la situation avec vos mots, même si vous ne savez pas encore comment la résoudre. Votre nom, votre e-mail et quelques lignes suffisent.
- 01Vous décrivez le problème
- 02Nous en parlons
- 03Vous recevez un périmètre et un prix écrits
Le problème est encore flou ?
390 €
Prix fixe, payé en ligne · TVA non applicable, art. 293 B du CGI.
L'évaluation technique vous dit ce qu'il cache et ce que coûterait la suite.
Voir l'évaluation technique