Skip to content

Testing and interop

Two kinds of OT Fabric repositories share the testing of OCPP. ocpp-interop owns the reference implementations: adapters around independent OCPP stacks, one container contract, deterministic fixtures and the reference-to-reference self-tests. It knows nothing about any library under test. go-ocpp and ocppctl are consumers: they pull the images, pinned by digest, and own their own scenarios and compatibility claims.

Adapter Upstream Language OCPP Roles
mobilityhouse The Mobility House ocpp 2.1.0 Python 1.6J, 2.0.1, 2.1 station and CSMS
chargetime ChargeTimeEU Java-OCA-OCPP v2.0 Java 1.6J, 2.0.1 station and CSMS
lorenzodonini lorenzodonini/ocpp-go v0.19.0 Go 1.6J, 2.0.1 station and CSMS
everest EVerest libocpp, everest-core 2026.02.1 C++ 1.6J, 2.0.1, 2.1 station only
citrineos CitrineOS core v2.0.1 TypeScript 1.6J, 2.0.1, 2.1 CSMS only

Every adapter speaks security profiles 0 to 3, and compression where its upstream can. The pins, with the exact commit and a lock file of every dependency, are in versions.yaml; a weekly workflow keeps an issue open while a pin is behind its upstream. Each image is multi-arch (amd64 and arm64) and carries the licences of what it contains.

A version or feature becomes a capability of an image only when a self-test exercises it. The Mobility House’s README names 1.6 and 2.0.1, for example, but its 2.1 package is a capability because the self-tests verify it.

CitrineOS is a complete CSMS, an application rather than a library: it answers every CALL from its own database and logic, with PostgreSQL and RabbitMQ inside its image. Its adapter declares which actions it answers as the fixture says; the others are checked against the OCA schema only.

A fixture describes, for one OCPP version, the stations a CSMS accepts, how either end answers the CALLs it receives, and named sample requests. The baseline fixtures have a sample of every action of their version and an answer to every action that is not a SEND. Fixtures contain no assertions: consumers read them from the image (print-fixture) and decide what to check.

  1. Start a reference CSMS and connect your station to it:

    Terminal window
    IMAGE=ghcr.io/otfabric/ocpp-interop-mobilityhouse@sha256:... # pin the digest of a release
    docker run -d --name csms -p 127.0.0.1:9000:9000 "$IMAGE" server --version 2.0.1
    # your station connects to ws://127.0.0.1:9000/ocpp/OT-FABRIC-TEST-001
  2. Have the CSMS send your station a CALL:

    Terminal window
    docker exec csms ocpp-interop call --station OT-FABRIC-TEST-001 Reset --payload '{"type":"Immediate"}'
  3. Or test your CSMS with a reference station, with CALLs from both ends:

    Terminal window
    docker run -d --name station "$IMAGE" \
    client run --version 1.6 --url ws://host.docker.internal:9000/ocpp
    docker exec station ocpp-interop call BootNotification \
    --payload "$(docker run --rm "$IMAGE" print-fixture --version 1.6 | jq -c .samples.boot.payload)"
    docker logs station | jq -c 'select(.event == "call-received")'

Security profiles 2 and 3 run with a CA and certificates you generate and mount read-only (--tls-dir), and a station offers compression with --compression. docker compose up -d --wait in a checkout runs the four reference CSMSs on ports 9000 to 9003. The images are unauthenticated test fixtures by default: run them on private networks and publish ports on 127.0.0.1 only.

ocpp-interop tests its references against each other: every station against every CSMS, for each version, with CALLs from both ends, plus TLS (profiles 2 and 3) and compression wherever both ends declare them. The v0.4.1 run (2026-10-19) had 2444 PASS, 31 KNOWN_ISSUE, 308 SKIP_UNSUPPORTED and 0 FAIL. Every action was exchanged with a pass in at least one pairing: 39 of 39 in 1.6, 64 of 64 in 2.0.1 and 90 of 91 in 2.1. The missing one, NotifyPeriodicEventStream, is a SEND, and no released upstream implements SEND or CALLRESULTERROR yet.

A known upstream defect is recorded with the line of upstream code that causes it, and reported as KNOWN_ISSUE instead of FAIL. An entry that no longer reproduces fails the run, so the list cannot go stale. The compatibility matrix has every pairing.

go-ocpp runs its own suite (make interop, Docker needed) in both directions: go-ocpp as the station against each reference CSMS, and each reference station against go-ocpp as the CSMS. On each connection it sends every sample of the fixture from its own end, answers every sample the reference sends, checks that an action that does not exist is answered NotImplemented or NotSupported, and checks that a close is noticed. Every request and response a reference sends must also pass go-ocpp’s typed validation.

Results against ocpp-interop v0.4.1 (2026-10-19): 1458 scenarios pass and none fails, across all five references and both roles. 163 are skipped, each with its reason: a role or version a reference does not have, compression a reference cannot do, actions CitrineOS’s REST API cannot send, and known upstream defects.

OCPP Pass Skipped
1.6J 393 40
2.0.1 632 34
2.1 432 88
  • TLS: profiles 2 and 3 pass with every reference CSMS and every reference station. go-ocpp as the station connects both by address and by host name (SNI); CitrineOS passes by host name only, a known upstream defect.
  • Compression: passes with The Mobility House in both roles and with CitrineOS as the CSMS.
  • OCPP 2.1: against three independent implementations: The Mobility House and CitrineOS as CSMSs, The Mobility House and EVerest libocpp as stations.

The per-version action matrices are in docs/compatibility.

ocppctl runs the same references with scripts/interop.sh, every fixture sample in both directions: 1218 exchanges, 1154 passed, 29 answered with a CALLERROR (9 of them ocppctl’s own NotSupported for services it does not have), 32 skipped because the reference did not send the message, and 3 failed on ocpp-go’s schema-invalid 2.0.1 responses, which ocppctl refuses.

go-ocpp’s own tests go beyond interop:

Target What it does
make chaos Stations of every version through a proxy that fragments, delays and cuts their connections while both sides call. Every call gets its own answer or an error a broken connection explains, every station reconnects once the proxy heals, and nothing leaks. The seed is printed so a failure can be replayed
make soak Chaos for ten minutes, then the parallel and exhaustion tests twenty times
make fuzz The fuzz targets of the frame codec, the RPC engine, the server and the three version packages; a nightly workflow runs each for ten minutes
make test Unit and end-to-end tests with the race detector, in every version, over plain WebSocket and TLS

The OCA schemas are vendored with their SHA-256 sums, and the generated packages are checked against them in CI. Generated validation is cross-checked against an independent JSON Schema validator.

Validated: every action of 1.6J and 2.0.1, and 90 of 91 of 2.1, exchanged with an independent implementation; security profiles 1 to 3 and compression in both roles; OCPP 2.1 against three independent stacks; every payload the references send passes go-ocpp’s validation.

Not covered yet:

  • SEND and CALLRESULTERROR against any independent implementation, because no released reference implements them. go-ocpp’s are verified against go-ocpp only.
  • Compression with go-ocpp as the CSMS against a second independent station.
  • Real charging stations and production CSMSs. The references are open-source stacks in containers.

Running five stacks against each other and against go-ocpp turned up defects in the references themselves, each recorded with its upstream source line:

Reference Finding
The Mobility House ocpp 2.1.0 A 2.1 CALL without a handler is never answered (fixed on master, not released). A 1.6 SecurityEventNotification cannot be built without the optional techInfo. Its validator uses draft-04 rules for the draft-06 2.0.1 schemas, so it refuses 20000.0 for an integer
lorenzodonini/ocpp-go v0.19.0 The 2.0.1 station answers Reset and SetNetworkProfile with "statusInfo": null, which the schema does not allow. GetCompositeScheduleResponse does not have the OCA structure. NotifyReport drops "tbc": false
ChargeTimeEU Java-OCA-OCPP v2.0 2.0.1 compression keeps its context while announcing that it does not (Java-WebSocket 1.6.0)
CitrineOS core v2.0.1 TLS works only for stations that send SNI: a station that connects by IP address gets a handshake failure
EVerest libocpp Its message queue cannot send ReportDERControl, GetCertificateChainStatus or NotifyWebPaymentStarted

Building go-ocpp and ocppctl also found three bugs in go-ocpp itself, all fixed in v0.1.2 and v0.1.3: frames lost on close, integral numbers such as 20000.0 refused for integer fields, and a SEND dropped when the peer closes right after it.