Skip to content

feat: add OCPP 1.2 support over JSON/WebSocket - #107

Merged
pbourseau merged 3 commits into
IZIVIA:devfrom
juherr:juherr/ocpp-1-2-json
Sep 9, 2026
Merged

pbourseau merged 3 commits into
IZIVIA:devfrom
juherr:juherr/ocpp-1-2-json

Conversation

@juherr

@juherr juherr commented Sep 7, 2026 •

Copy link
Copy Markdown
Contributor

Refs #84, which #98 closed by adding OCPP 1.2 over SOAP. This completes 1.2 with the JSON/WebSocket transport.

Summary

OCPP 1.2 was assumed to be SOAP-only, but that is an implementation gap rather than a protocol one. The OCPP-J specification defines the JSON/WebSocket transport independently of message content and lists ocpp1.2 among the registered WebSocket subprotocols, alongside ocpp1.5, ocpp1.6 and ocpp2.0. The toolkit already shipped ocpp-1-5-json; it simply had no 1.2 counterpart, so ApiFactory rejected the combination outright.

This adds ocpp-1-2-json and wires it through the WebSocket transport, so a 1.2 charge point connects end to end, and corrects the README, which claimed 1.2 had no WebSocket binding.

What's included

  • ocpp-1-2-json (com.izivia.ocpp.json12): Ocpp12JsonParser and Ocpp12JsonObjectMapper, mirroring ocpp-1-5-json, plus the 36 JSON schemas for the 18 OCPP 1.2 messages.
  • Wiring: OcppVersion gains OCPP_1_2("ocpp1.2"), getJsonMapper routes it to Ocpp12JsonParser, and ApiFactory.getWampVersion maps it instead of throwing.

The schemas

OCPP 1.2 has no official OCPP-J schema set, so the 36 schemas are derived from the OCPP 1.2 WSDL, with the string lengths taken from the specification (the WSDL declares none). They are not copies of the 1.5 schemas — 1.2 is systematically narrower:

OCPP 1.2 OCPP 1.5
ChargePointStatus 4 values (no Reserved) 5 values
ChargePointErrorCode 8 values 13 values
StatusNotification.req 3 properties 7 properties
MeterValue.value a single integer a SampledValue list
BootNotification.conf only status required status, currentTime, heartbeatInterval required
idTag maxLength 15 maxLength 20

Where the WSDL and the specification PDF disagree — the PDF adds an optional connectorId to RemoteStartTransaction.req — the WSDL wins, which is also what the existing ocpp-1-2-core model does.

Schemas live in an ocpp12 resource folder

OcppJsonValidator resolves schemas by bare file name off the classpath. ocpp-1-5-json ships <Action>.json / <Action>Response.json at the resources root while ocpp-1-6-json and ocpp-2-0-json ship <Action>Request.json / <Action>Response.json, so the *Response.json names already shadow each other today — and toolkit depends on all of them.

Without a namespace, a 1.2 payload could therefore be validated against the 1.5 schema, which is not hypothetical: 1.5's BootNotificationResponse.json requires currentTime and heartbeatInterval, both optional in 1.2.

So OcppJsonValidator gains an optional schemaFolder, and ocpp-1-2-json ships its resources under ocpp12/. The parameter defaults to the resources root, so 1.5, 1.6 and 2.0 are byte-for-byte unchanged, and @JvmOverloads keeps the single-argument constructor in the published artifact. Ocpp12JsonParser.validateJson is then identical to its 1.5 counterpart.

The pre-existing collision between 1.5, 1.6 and 2.0 is left as is — tracked by #111, which confirms it already misvalidates 2.0.1 payloads against the 1.6 schemas on dev. The mechanism to fix it now exists; moving their resources belongs in that change.

Drive-by fix

getJsonMapper's else branch threw "Websocket transport is not supported by the ocpp version 1.5" while 1.5 was supported on the line above. The when is now exhaustive over OcppVersion, so a future version fails to compile here instead of reporting the wrong thing at runtime.

ApiFactory.createServerTransportWebsocket guarded against a version with no WebSocket binding by calling getWampVersion for its side effect. Every version maps now, so that guard can no longer fire; it is replaced by a test asserting that the transport and WAMP OcppVersion enums stay in step — the match WebsocketServer relies on when it bridges them by name, and which nothing else checked.

Tests

  • JsonSchemaTest — a parse/serialise round trip per message, 36 in total.
  • Ocpp12JsonParserErrorTest — the parser error paths (validation on/off, ignored validation codes, forced field types) plus schema-tightness guards: Reserved, GroundFailure, the 1.5-only StatusNotification fields and the 1.5 SampledValue shape are all rejected, while a BootNotification.conf carrying only status is accepted. That last one also guards the resource prefix: it would fail if the 1.5 schema were picked up from the classpath.
  • Ocpp12FactoryTest — the two cases asserting that 1.2 over WebSocket is rejected become tests of the supported behaviour, and a new end-to-end test runs a real WebSocket round trip (authorize charge point → central system, remoteStartTransaction central system → charge point).

Verification

./gradlew :ocpp-1-2-json:test :toolkit:test
./gradlew build

@juherr
juherr force-pushed the juherr/ocpp-1-2-json branch 2 times, most recently from c7dd112 to ef766f2 Compare September 9, 2026 09:36

@pbourseau pbourseau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed the full diff, checked the 36 schemas against the ocpp-1-2-core model, and ran ./gradlew :ocpp-1-2-json:test :toolkit:test locally — green.

The approach is sound and the scoping is right. Things I verified rather than took on trust:

  • The 36 schemas cover exactly the 18 actions in the 1.2 Actions registry — no gap, no extra.
  • For every message, properties and required match the Kotlin model, optionality included (BootNotificationResp.currentTime/heartbeatInterval, MeterValuesReq.values, StopTransactionResp.idTagInfo, GetDiagnosticsResp.fileName).
  • The schema enums match the Kotlin enums: ChargePointStatus (4), ChargePointErrorCode (8), AuthorizationStatus (5).
  • The classpath collision you describe is real, and larger than the description suggests: 1.5 and 1.6 alone share 20+ *Response.json names at the resources root, and toolkit pulls in both. Namespacing 1.2 under ocpp12/ was necessary, and leaving the rest to its own change is the right call.
  • Both OcppVersion enums line up on constants and subprotocols, so the valueOf(name) bridge in WebsocketServer holds. OcppVersionBridgeTest covers a coupling nothing checked before — good addition.
  • Removing the server-side guard does not leave getWampVersion dead: the client path (ApiFactory.kt:92) still calls it, so the exhaustive when keeps doing its job.
  • Dropping the 1.5 EnumMixin from Ocpp12JsonObjectMapper is correct — no 1.2 enum has a value that differs from its constant name.

The schema-tightness tests are the part I liked most: they assert the schemas are narrower than 1.5 rather than merely valid, and the "BootNotification.conf with only status" case really does guard the resource prefix against a classpath regression.

One thing to fix before merge — see the inline comment on StopTransaction.json.

Two non-blocking notes:

  • Ocpp12FactoryTest reserves a port with ServerSocket(0).use { it.localPort } and rebinds it afterwards — the usual TOCTOU window and a possible flake source on a loaded CI. No simpler alternative here, just flagging it.
  • Worth opening a follow-up issue for the 1.5/1.6/2.0 resource collision, so the decision to defer it is tracked somewhere other than this PR description.

Happy to approve once the maxLength is in.

Comment on lines +9 to +11
"idTag": {
"type": "string"
},

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

idTag is the only idTag/parentIdTag in the 1.2 set without a maxLength: Authorize, RemoteStartTransaction, StartTransaction and both IdTagInfo blocks all carry maxLength: 15. The 1.5 counterpart has maxLength: 20 on this very field, so the constraint was dropped here rather than narrowed.

Concretely: a StopTransaction carrying a 40-character idTag validates, while the same idTag is rejected by Authorize and StartTransaction — the two ends of one transaction disagree. It also runs against the "1.2 is systematically narrower than 1.5" invariant the rest of the PR is built on.

Suggested change
"idTag": {
"type": "string"
},
"idTag": {
"type": "string",
"maxLength": 15
},

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 050d000 — you are right, and the inconsistency is in the specification itself rather than in the reading of it.

I had left maxLength off deliberately: §6.32 of the 1.2 specification types this field idTag string 0..1, with no [15], while §6.1 (Authorize.req), §6.22 (RemoteStartTransaction.req), §6.30 (StartTransaction.req) and §7.10 (IdTagInfo.parentIdTag) all say string[15]. So the schema followed the document literally, one field at a time.

What decides it is that the 1.2 WSDL cannot arbitrate — it declares no maxLength anywhere, every field is a bare s:string, which is why the lengths come from the PDF in the first place. And 1.5, which does model it, uses a single named IdToken type (maxLength 20) for this very element:

<s:element name="idTag" type="tns:IdToken" minOccurs="0" maxOccurs="1"/>

One identifier, one constraint, StopTransaction.req included. There is no reading under which 1.2 widens the identifier only when stopping the transaction it just started under 15 characters — the omission in §6.32 is a documentation slip, and the rest of the PR's invariant is the better guide.

Applied your suggestion. The seven idTag/parentIdTag occurrences across the 1.2 set now carry maxLength: 15 uniformly.

Covered red/green by a new test in Ocpp12JsonParserErrorTest, which pins the narrowing rather than merely the presence of a limit:

@Test
fun `should reject a stopTransaction idTag longer than the 1-2 limit`() {
    // 18 characters: within the 1.5 limit of 20, over the 1.2 limit of 15.
    val overLimit = stopTransactionWith(idTag = "012345678901234567")
    val atLimit = stopTransactionWith(idTag = "012345678901234")

    expectRejectedWith(overLimit, ValidatorTypeCode.MAX_LENGTH)
    expectThat(parser.parseAnyFromString(atLimit)).get { action }.isEqualTo("StopTransaction")
}

An 18-character tag is chosen so the test fails if the constraint is ever relaxed back to the 1.5 value of 20, not just if it disappears. Verified red before the schema change, green after.

@pbourseau

Copy link
Copy Markdown
Contributor

Follow-up on my note about the deferred 1.5/1.6/2.0 collision: I opened #111 for it, so no need to file one on your side.

Digging into it while reviewing this PR, it turned out to be worse than "a latent risk". On dev, inside toolkit, a valid 2.0.1 Authorize request is already rejected because AuthorizeRequest.json resolves to the 1.6 jar first — 57 file names collide between 1.6 and 2.0, and the oldest version wins the classpath lookup. The per-module test suites cannot see it: each one runs against a classpath holding only its own schemas, so the collision exists only in the aggregate. Reproduction and the probe output are in the issue.

Which is to say your schemaFolder addition is load-bearing beyond 1.2 — it is the mechanism the fix needs. Keeping it out of scope here was still the right call; #111 tracks the rest.

@juherr
juherr force-pushed the juherr/ocpp-1-2-json branch from ef766f2 to e678180 Compare September 9, 2026 13:27
@juherr

juherr commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for opening #111, and for going further than I did — I had this filed under "latent risk", and the 2.0.1 Authorize case shows it is an active bug on dev.

The detail that makes it nasty is exactly the one you name: the per-module suites cannot see it. ocpp-1-2-json's own tests run against a classpath holding only the 1.2 schemas, so they prove nothing about resolution order. That is why the guard for this PR lives in toolkit, in Ocpp12FactoryTest — the only place where all four schema sets are on one classpath — and why the tightness test asserts that a BootNotification.conf carrying only status is accepted: the 1.5 schema requires currentTime and heartbeatInterval, so that test goes red the moment 1.2 stops resolving to its own schemas.

Agreed that schemaFolder is the mechanism #111 needs; it takes a folder name and defaults to the resources root, so the remaining work is moving each module's resources and passing its own name. @JvmOverloads keeps the single-argument constructor in the published artifact, so no consumer breaks in the meantime.

I have referenced #111 from the PR description in place of the "worth a follow-up" note.

On your two review notes: the maxLength is in (see the inline thread). The ServerSocket(0) TOCTOU is real and I have no better option either — csmsOcppServer needs the port up front and the server exposes no ephemeral-port binding, so the alternative would be widening WebsocketServer. Out of scope here; happy to file it if you think it is worth tracking.

@pbourseau pbourseau left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed at e678180f. The fix is exactly what was asked, and the added test is better than what I suggested.

StopTransaction.json now carries maxLength: 15 on idTag, and all 7 idTag/parentIdTag occurrences across the 1.2 schema set are consistent at 15. should reject a stopTransaction idTag longer than the 1-2 limit pins both edges — 18 characters rejected (valid under the 1.5 limit of 20, so it would catch a silent widening), 15 accepted — which is a stronger guard than a one-sided assertion.

Verified on my side:

  • ./gradlew :ocpp-1-2-json:test :toolkit:test green on the new head; Ocpp12JsonParserErrorTest at 17 tests, 0 failures. CI green too.
  • The force-push is a clean amend: the only delta from the version I reviewed is the maxLength line plus the new test and its helper. Same merge base (a254105d), nothing else moved.

One follow-up from my earlier note, so it does not get filed twice: I opened #111 for the 1.5/1.6/2.0 collision. Investigating it turned up that it is already breaking 2.0.1 payloads on dev — AuthorizeRequest.json resolves to the 1.6 jar ahead of the 2.0 one, so a valid 2.0.1 Authorize comes back as a ProtocolError. That makes the schemaFolder parameter here the mechanism the fix depends on. Still right to have kept it out of this PR.

LGTM.

OCPP-J defines the JSON/WebSocket transport independently of message content and
registers `ocpp1.2` as a WebSocket subprotocol, so OCPP 1.2 is not SOAP-only. The
toolkit shipped `ocpp-1-5-json` but had no 1.2 counterpart.

Mirror `ocpp-1-5-json`: an `Ocpp12JsonParser`, a Jackson mapper and the 36 JSON
schemas for the 18 OCPP 1.2 messages, derived from the OCPP 1.2 WSDL with the
string lengths declared by the specification.

The schemas are deliberately narrower than the 1.5 ones: 4 charge point statuses
instead of 5, 8 error codes instead of 13, a plain integer meter value instead of
a SampledValue list, three StatusNotification properties instead of seven, and
only `status` required in BootNotification.conf.

`OcppJsonValidator` resolves schemas as classpath resources by action name, and
every version module ships its schemas under the same names, so 1.5, 1.6 and 2.0
already shadow each other. Give the validator an optional `schemaFolder` and have
the 1.2 module namespace its own resources under `ocpp12`. The default keeps the
existing modules byte-for-byte unchanged; migrating them is left for a follow-up.
`ApiFactory` rejected OCPP 1.2 over websocket outright, because no JSON parser
existed for that version. Now that `ocpp-1-2-json` provides one, register the
`ocpp1.2` subprotocol and route it to `Ocpp12JsonParser`.

`getJsonMapper` becomes exhaustive: its `else` branch was unreachable and
reported the wrong version, and a future OCPP version should now fail to compile
here rather than throw at runtime.

`createServerTransportWebsocket` guarded against a version with no WebSocket
binding by calling `getWampVersion` for its side effect. Every version maps now,
so the guard can no longer fire; drop it and cover what it really protected —
that the transport and WAMP version enums stay in step, since `WebsocketServer`
bridges them by name — with a test that fails on a divergence.

The two `Ocpp12FactoryTest` cases asserting that the combination is rejected
become tests of the supported behaviour, alongside an end-to-end websocket round
trip covering both directions.
The README stated that OCPP-J covered "1.5 and later" and that OCPP 1.2 had no
WebSocket binding, which stopped being true with `ocpp-1-2-json`.
@juherr
juherr force-pushed the juherr/ocpp-1-2-json branch from e678180 to 8bfd7e6 Compare September 9, 2026 13:35
@sonarqubecloud

sonarqubecloud Bot commented Sep 9, 2026

Copy link
Copy Markdown

@pbourseau
pbourseau merged commit 5a03e61 into IZIVIA:dev Sep 9, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants