An OCPP 2.1 Battery Swap (Block S) library and test tooling for the JVM.
Codec and session layers, a swap domain model, a station simulator — and a reference CSMS.
English · 한국어
Four things you can take separately. You are not expected to use all of them — that is the design.
| What | Module | Reach for it when |
|---|---|---|
| OCPP 2.1 codec + session | ocpp-core |
You need OCPP-J framing and validation against the 181 official JSON schemas. It knows nothing about frameworks |
| Battery Swap domain | swap-domain |
You need the swap state machine, slot model, and invariants. No I/O at all |
| Simulator + control console | station-sim·sim-console |
You want to exercise your own CSMS against Block S. Failure scenarios F1–F6 are one button each |
| Reference CSMS | csms |
You want to see how the pieces fit. The conformance cases run against this |
This does not promise a finished CSMS.
csmsis a reference implementation, not a product. The whole library is callable from Java — the codec and schema layers directly, the session layer throughOcppSessionsAsync, which takes anExecutorand returnsCompletableFutures. That is measured, not assumed: see LAYERS §4.There are no generated message DTOs. Payloads are Jackson
JsonNode/ObjectNode, and correctness is decided by the official schema rather than by a transcribed class. That is a licensing consequence, not an oversight — the OCA schemas are CC BY-ND 4.0, so putting them into code would make a derivative. You gain a validator that cannot drift from the standard; you pay for it by reading and building fields by hand.
On Maven Central. Take either module on its own.
dependencies {
implementation("io.github.zannabi-lab:ocpp-core:0.3.0") // codec · schema validation · session
implementation("io.github.zannabi-lab:swap-domain:0.3.0") // the swap state machine, zero dependencies
}Changes between versions are in CHANGELOG.md. While the major version is 0
the public API may still change between minor versions — no consumer outside this repository has
tested its shape yet. ocpp-core prints three SLF4J warnings on first use — the schema
validator pulls in slf4j-api transitively and finds no binding. Nothing here calls SLF4J; a
library has no business choosing your logging backend, so none is bundled. Add the one you use
(logback-classic, slf4j-simple, or slf4j-nop to silence it).
What using it looks like. The library owns framing, schema validation, timeouts, and the one-CALL-in-flight rule of Part 4 §4.1.1. You supply a transport function and a handler.
val sessions = OcppSessions(Clock.systemUTC(), eventSink = myEventLog)
// One session per connection. After a reconnect, call open() again for the same station:
// the idempotency ledger carries across, which is what stops a retransmitted swap from
// being counted twice.
val session = sessions.open(
stationId = "CS001",
transmit = { line ->
if (socket.isOpen) { socket.send(line); TransmitOutcome.Delivered }
else TransmitOutcome.Gone("socket closed")
},
onCall = { stationId, call -> InboundResponse.Respond(answerFor(call)) },
)
// Feed inbound lines in arrival order: session.receive(line)
when (val result = session.call(OcppCall("RequestBatterySwap", payload))) {
is OcppResult.Accepted -> result.payload
is OcppResult.Rejected -> result.knownErrorCode
is OcppResult.InvalidResponse,
is OcppResult.TimedOut,
is OcppResult.NotConnected -> null // never thrown — every outcome is a value
}Opening the connection, reconnecting, and calling receive in arrival order stay yours. A Java
caller writes the same thing through OcppSessionsAsync; swap-domain is the one part written
for Kotlin consumers only, because its identifiers are value classes that Java sees as mangled
constructor-impl names. → docs/LAYERS.md §4
Every tagged release carries a swapve-<version>.zip with the CSMS, the station simulator and
the control console already built — JDK 17 is the only requirement.
unzip swapve-0.3.0.zip && cd swapve-0.3.0
java -jar csms/csms.jar --csms.security.profile=NONE # terminal A
./station-sim/bin/station-sim --station-id CS001 # terminal B — one swap, then exits
./sim-console/bin/sim-console # optional — drive it from a browserTerminal B ends with Exchange complete: requestId=1001, 42 messages exchanged. Terminal C then
queries exactly what an app would see:
curl localhost:8080/api/swaps/CS001:1001 # one swap — SoC/SoH of both battery sets
curl localhost:8080/api/metrics/swaps # success rate · duration · failure reasons
curl localhost:8080/api/stations/CS001/charging-transactions # charging of the returned batteries (S04)To build from source instead, JDK 17 and git are all you need — the Gradle wrapper fetches the
rest. The three terminals become bootRun · run · run:
git clone https://github.com/ZANNABI-LAB/swapve.git && cd swapve
./gradlew build # full test suite + module boundary checks
./gradlew :csms:bootRun --args="--csms.security.profile=NONE --csms.api.security.enabled=false"
./gradlew :station-sim:run --args="--csms-url ws://localhost:8080/ocpp --station-id CS001 --request-id 1001"The steps above are a local demo, so both authentication layers are turned down. The operational defaults are Basic over WebSocket and Basic on the REST API → docs/CONFIGURATION.md
Reverse-order swaps · CSMS-initiated swaps (S02) · the control console
The reverse order (Out-In) is equally standard and works as-is — swap order is direction-agnostic
by design: add --swap-order Out-In to the simulator.
To start a swap from the CSMS side, the way an app would (standard use case S02), run the
simulator in standby with --remote-start and trigger it over REST. The correlation number
(requestId) is issued by the CSMS here, and the station adopts it verbatim (S02.FR.02):
curl -X POST localhost:8080/api/swaps -H 'Content-Type: application/json' \
-d '{"stationId":"CS001","idToken":{"idToken":"RFID-0001","type":"ISO14443"}}'To drive it from a screen, start the control console — it adds to the CLI rather than replacing it, and everything above still works:
./gradlew :sim-console:run --args="--port 8090 --csms-url ws://localhost:8080/ocpp"Open localhost:8090, then Connect → Start swap. The F1–F6 buttons fire the failure
scenarios — "reproduce an empty-inventory rejection" is one click.
- The page is written in React and inlined into a single static HTML file at build time —
nothing is fetched at runtime, so it comes up with no network at all. The HTTP server is the
JDK's own
com.sun.net.httpserver, and Gradle downloads the Node it needs to build the page, so JDK 17 and git are still all you need - F1 (no battery available) is a CSMS-initiated scenario (S02.FR.04), so the console goes into
standby and prints the exact
curlline to paste; the rejection reason then lands on screen - There is a control API too —
POST /api/stations·POST /api/stations/{id}/swap(body{"fault":"F3"}injects a fault) ·DELETE /api/stations/{id}·GET /api/state
The console drives the test rig. It is not a CSMS and does not pretend to be one.
Measured run — timings from actually following the steps above (2026-08-21)
| Step | Took | Observed |
|---|---|---|
./gradlew build |
54s – 1m25s | 368 tests + 5 module boundary checks passed |
./gradlew :csms:bootRun |
~20s after the command (server itself boots in 2.8s) | Started CsmsApplicationKt · Tomcat started on port 8080 |
./gradlew :station-sim:run … |
13s | Exchange complete: requestId=1001, 42 messages exchanged |
| Confirmed on the CSMS side | — | BatteryIn → HalfIn, BatteryOut → Completed |
Measured with Gradle dependencies already cached. A first build on a fresh clone also downloads
the Gradle distribution and the dependencies; excluding that, the whole thing is under three
minutes. The three curl calls were verified in a separate environment as well, and
SwapApiTest, SwapMetricsApiTest and ChargingApiTest call the same endpoints inside a real
Spring context on every build.
Not a production system. The absences come first.
- What is closed and what is still open differ. WebSocket defaults to
BASIC, the REST API has its own Basic realm, andsim-consolebinds to loopback. There is no mTLS, no credential rotation, no rate limiting, no operational audit trail. - Single instance. Horizontal scaling is not blocked — serialization and the idempotency ledger
are both keyed by
stationId, which is the partition key a distributed setup would use — but it is not implemented. Restarts are survived: raw OCPP messages persist to an event log (H2) and derived registries are rebuilt from it at startup (EventLogRecovery). Data outside the retention windows (7 days recovery · 30 days audit) is not reconstructable. - Smart charging, tariffs, roaming, and horizontal scaling are out of scope. Not implemented — but not designed out either.
In January 2025, OCPP 2.1 formally absorbed the Battery Swap functional block (Block S), covering battery exchange for two- and three-wheelers (scooters, e-bikes) as well as full-size EVs.
"Supports 2.1" is not this project's differentiator. A re-check in August 2026 found several implementations working on 2.1, and one of them turned out to carry Block S. The claim here is narrower than it once was: one open-source server-side Block S implementation exists, and no station-side implementation or Block S test tooling was found besides this one.
| Project | Role | OCPP 2.1 | Block S |
|---|---|---|---|
| SteVe | CSMS (Java) | ❌ 1.6J only | ❌ |
| CitrineOS | CSMS (TypeScript) | roadmap, under "other topics" | not mentioned |
| MaEVe | CSMS (Go) | ❌ 1.6J + 2.0.1 | ❌ |
| EVerest libocpp | Charger-side library (C++) | "in development" | not mentioned |
| Java-OCA-OCPP | Library (Java) | ✅ 2.1 tree | ✅ server-side, undocumented and not in its published artifacts |
| Solidstudio VCP | CS simulator | ✅ supported | undetermined |
| ocpp-rs | Library + simulator (Rust) | ❌ 1.6J / 2.0.1 | ❌ |
| tzi-OCTT | CSMS verification pytest suite | ❌ 2.0.1 / 1.6J | — |
| OCTT (official) | Conformance test tool — paid subscription | ❌ 2.0.1 / 1.6 | — |
| SwapVe | Library + test tooling + reference CSMS (Kotlin) | ✅ | ✅ |
The markings mean different things. ❌ is confirmed unsupported; "not mentioned" means the
topic was not found in the project's docs or issues; "undetermined" means no basis was found
either way. The latter two are not evidence of absence — the Java row is what that warning looks
like when it comes true. Its Battery Swapping module appears in no README and in no released
artifact; it was found by reading the source tree, then confirmed by compiling it into a server and
running swaps against it. It handles the server side. It is not a station, and it is not tooling
for exercising Block S. Corrections are welcome — if you know an implementation that handles
the Block S messages (RequestBatterySwap, BatterySwap transaction events), please open an issue.
The three gates answer different questions, which is why they were not merged into one. All three run on every push in GitHub Actions, never squashed into a single command, so that "the unit tests passed but conformance broke" cannot hide inside one green check.
| Gate | Command | What it guarantees |
|---|---|---|
| L1 unit | ./gradlew --no-build-cache :csms:cleanTest build |
Frame round-trips · validation against the 181 official schemas · state machine invariants · REST contracts. Module boundary checks and Java-interop tests run alongside |
| L2 conformance | ./gradlew --no-build-cache conformanceTest |
Official cases TC_S_102_CSMS · TC_S_103_CSMS · TC_S_104_CS plus failure scenarios F1–F6 |
| L3 load + audit | ./gradlew --no-build-cache auditTest |
20 stations connected concurrently, then an invariant audit that rebuilds state from the event log and compares it against the live registries |
--no-build-cache is not decoration: clearing the outputs is not enough, because the build cache
restores them, and a gate that did not actually run the tests is green and has checked nothing.
auditTest likewise prints how many items each check examined, because a bare "passed" is
indistinguishable from a check that never ran — and the audit is itself tested against deliberately
corrupted logs.
Tested against someone else's server. The simulator has been pointed at three independent
open-source implementations, one of them OCA-certified for OCPP 2.0.1. Handshake, subprotocol
negotiation, BootNotification, NotifyEvent, TransactionEvent and message correlation all held,
and three defects in this repository were found that way. The third peer carried Block S end
to end under someone else's handler — station-initiated and CSMS-initiated swaps both, requestId
adopted and correlated, our CALL timeout driven by a stalled reply, and a retransmission answered
after a mid-swap reconnect.
Sample output · success criteria S1–S7 · the full conformance case list · what the interoperability runs covered and where they stopped → docs/CONFORMANCE.md
Official OCTT certification has not been obtained, and for OCPP 2.1 there is nothing to apply to yet — the OCA's certification programme and its OCTT tool cover 2.0.1 and 1.6 today. What is here is an independent implementation of the Part 6 cases.
swapve/
├─ ocpp-core/ OCPP-J framing · official schema validation · session layer (framework-agnostic)
├─ swap-domain/ Battery Swap domain · swap state machine · slot model (no I/O)
├─ csms/ the control server — WebSocket · REST API · metrics
├─ station-sim/ station simulator — slots · batteries · fault injection (zero dependencies)
├─ sim-console/ simulator control console — demo screen + control API (JDK's own HTTP)
└─ java-compat/ Java interop gate — the library called from Java (tests only)
ocpp-core and swap-domain know nothing about frameworks. That boundary is a build check, not a
comment — check* tasks run as part of ./gradlew build. Dependencies flow one way,
sim-console → station-sim → (ocpp-core, swap-domain), and csms is not in that chain — the
control server must never gain a dependency that lets it drive a station.
The standard already defines the app scenario. S02: "EV Driver requests CSMS to initiate a
battery swap via a smartphone app, e.g. by scanning a QR code." So
app → CSMS → RequestBatterySwap → station is the standard use case. Building the app is out of
scope, but its contract is designed.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/swaps |
Start a swap — sends RequestBatterySwapRequest (S02) |
GET |
/api/swaps/{id} |
Progress · SoC/SoH of both battery sets · ledger imbalance |
GET |
/api/metrics/swaps |
Success rate · duration distribution · failure reasons by F1–F6 |
GET |
/api/stations/{id}/charging-transactions |
Charging of the returned batteries (S04) |
A read-only operations screen is served at / — the stations this CSMS knows and every frame
exchanged with each one, payloads verbatim, as a static page with no CDN or framework. A default
install denies every /api request until csms.api.security.users is configured, and the
screen says so on its face rather than showing a bare 401.
The full contract and the reasoning behind it (why "no battery available" is a 200 rather than a 5xx; why the metrics avoid Micrometer) is in docs/API.md.
| Document | Contents |
|---|---|
| docs/LAYERS.md | Layer boundaries — and what you take on when you use this as a library, Java included |
| docs/VIRTUAL-STATION.md | Simulator operations, layered — which calls are physical acts, which are protocol reports, which are just scripts |
| docs/API.md | Full REST contract and design record |
| docs/CONFIGURATION.md | Station authentication · REST authentication · TLS |
| docs/CONFORMANCE.md | Conformance cases · success criteria S1–S7 · audit output · limits of self-verification |
| docs/PUBLISHING.md | How releases reach Maven Central — and the rehearsal that must pass first |
The documents under
docs/are English only, deliberately — keeping 1,100 lines in two languages guarantees they drift apart. This README is the one page kept in both, and a build check compares the two files' section count, Maven coordinates and links so they cannot drift quietly.
Apache License 2.0 — except schemas/ (NOTICE).
schemas/contains the official OCA JSON schemas verbatim, unmodified. © Open Charge Alliance, CC BY-ND 4.0.- The specification documents (PDF) are not redistributed here. Download them free of charge
from openchargealliance.org/download-ocpp and
place them in
docs/spec/.
"OCPP" and "Open Charge Point Protocol" are managed by the Open Charge Alliance. This project is not affiliated with, nor endorsed by, the OCA.
