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 codec and schema layers are callable from Java — the session layer is Kotlin-only. 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.1.0") // codec · schema validation · session
implementation("io.github.zannabi-lab:swap-domain:0.1.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.
All you need is JDK 17 and git. The Gradle wrapper fetches the rest.
git clone https://github.com/ZANNABI-LAB/swapve.git && cd swapve
./gradlew build # full test suite + module boundary checksTerminal A — the CSMS. You are ready when Started CsmsApplicationKt appears.
./gradlew :csms:bootRun --args="--csms.security.profile=NONE --csms.api.security.enabled=false"Terminal B — a simulated station connects and completes one battery swap, then exits.
./gradlew :station-sim:run --args="--csms-url ws://localhost:8080/ocpp --station-id CS001 --request-id 1001"station-sim → ws://localhost:8080/ocpp/CS001 (순서 In-Out, 배터리 2 개)
교환 완주: requestId=1001, 오간 메시지 42 건
The simulator's console output is currently Korean: "swap complete: requestId=1001, 42 messages exchanged." Log message localization is still open, together with the docs.
Terminal C — query 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)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:
./gradlew :station-sim:run --args="--csms-url ws://localhost:8080/ocpp --station-id CS001 --swap-order Out-In --request-id 1002"To start a swap from the CSMS side, the way an app would (standard use case S02), run the
simulator in standby and trigger it over REST. The correlation number (requestId) is issued by
the CSMS here, and the station adopts it verbatim (S02.FR.02).
# Terminal B — do not self-start; wait for RequestBatterySwap
./gradlew :station-sim:run --args="--csms-url ws://localhost:8080/ocpp --station-id CS001 --remote-start"
# Terminal C — as if the driver scanned a QR code
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 — 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
failure scenarios — "reproduce an empty-inventory rejection" is one click.
- The page is a single static HTML file with no CDN, font, or framework links. The HTTP server
is the JDK's own
com.sun.net.httpserver— it comes up with no network at all - 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 | 교환 완주: requestId=1001, 오간 메시지 42 건 (swap complete, 42 messages) |
| Confirmed on the CSMS side | — | BatteryIn → HalfIn, BatteryOut → Completed |
Measured with Gradle dependencies already cached. A first ./gradlew build on a fresh clone
also downloads the Gradle distribution and dependencies; that part depends on your connection.
Excluding it, the whole thing is under three minutes.
The three curl calls were verified in a separate environment as well:
/api/swaps/CS001:1001→status: COMPLETED, withbatteriesIn(2, SoC 12/13%) andbatteriesOut(2, SoC 95%) both present, which is what keeps billing computable later/api/metrics/swaps→successRate: 1.0, duration percentiles, andfailures.byScenariosplit across F1·F2·F3·F5 (F4 and F6 are idempotent, so they do not count as failures)/api/stations/CS001/charging-transactions→ charging transactions per slot
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 that log 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.
- No dashboard or UI. Reading stops at REST.
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. The claim here is narrower: no open-source project was found that actually handles Block S — neither on the server side, nor as tooling you can exercise Block S with.
| 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 |
| 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 three 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.
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
| Gate | Command | What it guarantees |
|---|---|---|
| L1 unit | ./gradlew build |
Frame round-trips · validation against the 181 official schemas · state machine invariants · REST contracts. Five module boundary checks and 13 Java-interop tests run alongside |
| L2 conformance | ./gradlew conformanceTest |
Official cases TC_S_102_CSMS · TC_S_103_CSMS · TC_S_104_CS plus failure scenarios F1–F6 |
| L3 load + audit | ./gradlew auditTest |
20 stations connected concurrently, then an invariant audit that rebuilds state from the event log and compares it against the live registries |
auditTest does not just print pass/fail — it 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:
deliberately corrupted logs must turn each item red.
Sample output · success criteria S1–S7 · full conformance case list → docs/CONFORMANCE.md Official OCTT certification has not been obtained — it is paid and must go through an OCA-approved test lab. 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 — calls the codec/schema layers from Java (tests only)
ocpp-core and swap-domain know nothing about frameworks. That boundary is a build check, not a
comment — five 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) |
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 — the codec is I/O-agnostic, the session layer is coroutine-only. What you take on when you use this as a library |
| docs/VIRTUAL-STATION.md | Simulator operations, layered — which calls are physical acts, which are protocol reports, which are just scripts (module boundaries are LAYERS.md) |
| 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 |
| 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.
Development runs through zannabi-code, a verification-first external runner: every claim of completion must come with machine-checkable evidence.
zannabi run "<task>" --cwd. \
--gate "unit:./gradlew test" \
--gate "conformance:./gradlew conformanceTest" \
--gate "audit:./gradlew auditTest" --budget 3The same gates run in GitHub Actions.
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.
