EV charging infrastructure

Both ends of the charging protocol

OCPP is how nearly every public EV charger talks to the backend that operates it. This is a complete central system, the chargers that connect to it, and a console that shows the protocol actually happening — built to understand an integration boundary by owning both sides of it.

OCPP 1.6J messages
28/28OCPP 1.6J messages
feature profiles
6/6feature profiles
protocol versions
2protocol versions
tests
175tests
Source on GitHub

How the pieces talk

Charge points

Python fleet simulator and a browser-based charge point. They connect out to the server, the way real hardware does.

WebSocketocpp1.6 · ocpp2.0.1

CSMS

Async server routing each message by negotiated version. SQLite holds sessions and transactions so a restart never loses a charge in flight.

SSE + HTTPevents · state · commands

Console

Live fleet view, session metrics, the raw wire log, and the command surface that drives chargers back down the socket.

Both ends of the boundary

A central system and the chargers that connect to it. Most OCPP demos implement one side and mock the other, which hides exactly the behaviour that goes wrong in the field — who reconnects, who retries, who owns the transaction when a charger reboots mid-charge.

The protocol is the interface

Every frame in and out is shown as literal OCPP-J, with a per-field glossary. You can watch a StartTransaction get an id assigned, a meter value land against it, and a StopTransaction close it — rather than trusting that a green tick meant the right thing.

Restartable by design

Sessions and transactions live in SQLite, not memory. Kill the server mid-charge and it recovers the open transaction on boot. A charger that vanishes without a StopTransaction gets its orphan reaped on a grace window instead of hanging forever.

CSMS dashboard showing four connected chargers, three of them charging
Live fleet

Four simulated chargers connected over WebSocket, three mid-session. Every card is driving a real OCPP conversation — start, stop, reboot, availability and a per-connector power cap.

Three concurrent charging sessions, one capped at 7.4 kW by a charging profile
Smart charging, visibly working

Three concurrent sessions. The left one is pinned at 7.4 kW by a SetChargingProfile while the others run free at 10.3 and 10.8 — the load-balancing knob doing something you can see.

Raw OCPP-J wire log with a frame inspector explaining each field
The wire, not a summary

Every frame both directions, filterable, with the selected one broken down field by field — what messageTypeId means, why uniqueId matters, how transactionId differs between 1.6 and 2.0.1.

Decisions worth defending

How do you add a second protocol version without forking everything?
Route on the negotiated WebSocket subprotocol. `ocpp1.6` and `ocpp2.0.1` pick different handler classes behind one dispatch, so the server never branches on version. Adding 2.0.1 was a dict entry plus a handler.
How do you keep 1.6 and 2.0.1 from becoming two products?
1.6's flat configuration keys are what 2.0.1 replaced with its Component/Variable device model. Storing 1.6 keys as rows in that same model means one dashboard panel, one write path, and one reboot-pending badge serve both.
When a command fails, whose fault is it?
The status code says so: 400 the caller sent something wrong, 404 no such live charger, 501 this charger's version has no such message, 502 the charger was asked and refused. Before that contract existed, a missing parameter was reported as "the charger rejected it" — and sent you debugging a charger that never saw the message.
What stops a hostile charger from owning the dashboard?
A charge point's identity is whatever it puts in the WebSocket URL, and the dashboard renders it in ~40 places. It is validated at the handshake against a strict character set and the connection closed on failure, so the injection is stopped at the door rather than at forty sinks.

How it's built

Server
Python asyncio — websockets for the charge-point socket, aiohttp for the dashboard side-channel, the mobilityhouse ocpp library for message models and schema validation.
Storage
SQLite in WAL mode. Transactions survive a restart; meter values are rolled up and pruned on a retention loop so a long-running server stays bounded.
Console
TypeScript compiled to a no-build static bundle, fed by Server-Sent Events. The wire log reconciles in place rather than re-rendering, so a metering flood never eats a click.
Coverage
All 28 OCPP 1.6J messages across all six feature profiles, plus an OCPP 2.0.1 seam with the device model, monitoring and display messages. 175 tests, no live server needed.