feat(ocpp): charging needs, TLS, per-charger credentials and a device row - #999
Open
HuggeK wants to merge 4 commits into
Open
feat(ocpp): charging needs, TLS, per-charger credentials and a device row#999HuggeK wants to merge 4 commits into
HuggeK wants to merge 4 commits into
Conversation
NotifyEVChargingNeeds is the vehicle speaking, not the charger or the operator: on an ISO 15118 session the EV states the energy it wants, when it expects to leave, and on DC its own battery capacity and present state of charge. That outranks configuration — vehicle_capacity_wh and a vehicle profile are both an estimate of the car that usually parks here, this is the car actually plugged in. Taken for the session and reverted on plug-out, sharing the profile's machinery: a reported capacity replaces the configured one (and outranks a profile's, in either order), a reported SoC re-anchors the session estimate, and the two with the requested energy derive the target the planner fills to. A departure time the car states becomes the target time; one it does not state never erases the operator's. AC states energy without a battery size, so no fraction is derived there — guessing one would feed the planner a number the car never claimed. Units convert at the boundary: the wire carries Wh and whole percent, core stores Wh and 0-1 fractions. Quarantine applies as everywhere else: a pending charge point's needs are recorded and visible in the API so an operator can see what asked, and the callback that reaches a loadpoint never fires for it. The rest of the smart-charging profile is acknowledged and dropped, matching the house rule that refusing a message we ignore only makes the charger retry forever. Closes the NotifyEVChargingNeeds item under "Not yet". Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
…their own credential Three items from the PR's own "Not yet" list, all of them about the same weakness: identity here is client-chosen and the credential is shared, so authenticating has never proved which device is on the other end. ocpp.bind stops being advisory. The library builds its listen address from the port alone, and v0.19.0 is its latest release, so the socket cannot be pinned. The handshake is refused instead, for any connection that arrived on another local address. That is an access control, not a smaller attack surface — the port still answers a scan — and the docs and the changeset say so rather than implying a bind. ocpp.tls serves wss://, ending the plaintext basic auth anyone on the LAN could sniff. client_ca_file additionally demands a certificate signed by that CA, which is OCPP 2.0.1 security profile 3 and the only identity here that cannot be copied between devices. Half a TLS section is refused at startup: an operator who asked for wss:// and silently got ws:// would have no way to tell the link was never encrypted. ocpp.chargers gives a charge point a password of its own. On OCPP the basic-auth username is the charge point identity, so a listed charger must present both and the shared password stops buying its name. Opt-in per charger, so adding one entry does not lock the others out. Both gates live in checkClient because it is the only callback that sees the identity and the request together. That turned out to matter more than expected: ocppj.Server.Start unconditionally calls SetCheckClientHandler with its own handler, so a gate registered on the raw ws.Server is silently discarded — the listener comes up, the logs say the gates are configured, and every impersonation attempt is accepted. guardedServer chains instead, and TestPerChargerCredentialOverTheWire is what caught it. Per-charger passwords are masked out of GET /api/config and preserved across a settings save, matched by charger id rather than position: the UI may reorder or drop entries, and restoring by index would hand one charger another's credential. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
… server in Settings The last "Not yet" item, in two halves. /api/devices was driver-only. An adopted charge point now earns a row like any other hardware, keyed on the vendor and serial from BootNotification — not on the name it dialled with. That name is the last segment of a URL an installer typed and the charger's own web page can change, which makes it a YAML name by another route; persistent state keyed on it would not survive a re-commissioning. Rename a charger and the row follows the hardware. A charger that reports no serial falls back to the dialled name, recorded as an endpoint so it reads as what it is: stable only until someone changes it. Quarantine covers device rows too. A row is a statement that this hardware is part of the site, and an unadopted charge point is not; it gets one on the save that adopts it, which is why adoption also runs the catch-up pass — the charger booted long ago and will not boot again because we changed our mind about it. 1.6 has two serial fields and shipped firmware disagrees about which to fill, so chargeBoxSerialNumber is read when the current one is empty rather than losing the only stable identity some chargers ever report. Serial and firmware are exposed on GET /api/ocpp/chargers alongside vendor and model. The other half: the Chargers panel used to print a config.yaml snippet and tell the operator to go and edit it. The server's own settings — on/off, bind, both ports, path, username, password — are now fields on the tab. TLS paths and per-charger credentials deliberately stay in the file: they are host filesystem paths and one secret per charger, set once at commissioning by someone who already has a shell. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Running the branch against srcfl/device-simulator turned up three ways FTW reported control it did not have. None was visible from the specification or from the tests in this package, which is why each now has one. Charging profiles go out as Relative rather than Absolute. FTW's schedule is one period at second zero with no end — hold this until I send another. Absolute states that only with a startSchedule timestamp, and while the spec says an absolute schedule without one is relative to the start of charging anyway, a charger that parses the missing timestamp strictly finds no valid start, treats the profile as not yet active, and answers Accepted while charging on at full rate. That is the worst failure this layer has: the log says the limit was applied and the planner counts energy the site is not saving. Relative carries no timestamp to misread, and does not depend on the charger's clock agreeing with ours. A charger that refuses a charge-point-wide profile is retried on connector 1. OCPP 1.6 permits a TxDefaultProfile on connector 0 — it is how a profile reaches every connector — but some chargers read the connector-0 rule as ChargePointMaxProfile-only and reject it. Refusing means no limit at all, so one retry is the difference between a charger FTW steers and one it can only meter. Refusing both is still an error. Manual EV controls reach a charger that has no driver. Pause, Resume, Force start and set-current posted to /api/ev/command went straight to the Lua driver registry, which an OCPP charge point is not in — it dialled us rather than being dialled — so they failed with driver not found while automatic dispatch steered the same charger correctly. The OCPP-aware sender the loadpoint controller already used is hoisted out and given to the API too, and an absent registry alone no longer refuses the request: an OCPP-only site has chargers and no registry to fall back to. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK
marked this pull request as ready for review
August 29, 2026 15:00
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #979, which merged the OCPP central system into master on 29 August (
bf2c142f, shipped inv2.4.0-beta.1).This is the list #979's own Boundaries and safety section left open:
All of it, plus three control bugs found by running the merged code against a real charge point simulator. Four commits on top of master; #732 is untouched and stays open.
The car's own charging needs (
NotifyEVChargingNeeds)On an ISO 15118 session the EV states what it wants and the charger forwards it. That outranks configuration:
vehicle_capacity_whand a vehicle profile are an operator's estimate of the car that usually parks here; this is the car actually plugged in.Session-scoped and reverted on plug-out, like an identified vehicle profile. A departure time the car does not state never erases one the operator set. An AC session states energy without a battery size, so no target fraction is derived — guessing one would feed the planner a number the car never claimed.
Bind address, TLS, per-charger credentials
ocpp.bindstops being advisory. The library builds its listen address from the port alone and v0.19.0 is its latest release, so the socket cannot be pinned; the WebSocket handshake is refused instead for a connection that arrived on any other local address. That is an access control, not a smaller attack surface — the port still answers a scan — and the docs say so rather than implying a bind.ocpp.tlsserveswss://, ending the plaintext basic auth anyone on the LAN can sniff.client_ca_fileadditionally requires a certificate signed by that CA (OCPP 2.0.1 security profile 3). Half a TLS section is refused at startup rather than quietly serving plaintext.ocpp.chargersgives a charge point a password of its own. On OCPP the basic-auth username is the charge point identity, so a listed charger must present both and the shared password stops buying its name — the impersonation hole the quarantine could not close. Opt-in per charger, so adding one entry does not lock the others out.A library detail that made this fragile, and a hardening carried across
ocppj.Server.Startunconditionally callsSetCheckClientHandlerwith its own handler, so a gate registered on the rawws.Serveris silently discarded at startup: the listener comes up, the logs say the gates are configured, and every impersonation attempt is accepted.guardedServerchains instead of replacing. Only the over-the-wire test caught it — every unit test passed.Master's merged version had gained
basicAuthCheck, doing the credential compare in constant time. The authorizer supersedes that function; the property is kept and extended, so the username is now compared in constant time too rather than with==.A durable device row, and the server settings in the UI
An adopted charger gets a row in
/api/devices, keyed on the vendor and serial fromBootNotificationrather than the name it dialled with — that name is one an installer typed and the charger's own web page can change, so state keyed on it would not survive a re-commissioning. Rename a charger and the row follows the hardware. Pending chargers get no row: a device row says this hardware is part of the site, and quarantine says an unadopted charge point is not.OCPP 1.6's deprecated
chargeBoxSerialNumberis read when the current field is empty — shipped firmware disagrees about which to fill, and losing it loses the only stable identity some chargers report.The Chargers panel used to print a
config.yamlsnippet and tell the operator to go and edit it. The server's own switches — on/off, bind, both ports, path, username, password — are now fields on the tab.Three faults found against the device simulator
Run end to end against srcfl/device-simulator — a real OCPP 1.6 charge point dialling the central system — rather than only against the fixtures in this package. Each fault has the same shape: FTW reporting control it did not have.
The three faults
Charging profiles were
Absolutewith nostartSchedule. The specification says an absolute schedule without one is relative to the start of charging, so the message was defensible — but a charger that parses the missing timestamp strictly finds no valid start, treats the profile as not yet active, and answers Accepted while charging on at full rate. Observed directly: FTW logged "charging limit applied" while the charger sat at 7700 W. Now sent asRelative— no timestamp to misread, and no dependence on the charger's clock agreeing with ours. After the fix, pause drives it to 0 W.A charge-point-wide profile (connector 0) was rejected outright, which means no limit at all. OCPP 1.6 permits a
TxDefaultProfilethere — it is how a profile reaches every connector, and it avoids depending on per-connector ids that are unreliable on dual-socket units — but some chargers read the connector-0 rule asChargePointMaxProfile-only. A refusal is now retried once on connector 1; refusing both is still an error.Manual EV controls could not reach an OCPP charger. Pause, Resume, Force start and set-current post to
/api/ev/command, which sent them straight to the Lua driver registry — which a charge point is not in, because it dialled us rather than being dialled. They failed withdriver "garage" not foundwhile automatic dispatch steered the same charger correctly.What else the run covered
garagewith its own password; the shared one does not buy that nameCore,SmartCharging,RemoteTrigger,LocalAuthListManagement→ steerable/api/devicesrow keyedcharge amps:garagedrivewaycharger is visible in the Chargers panel and absent from both telemetry and/api/devicesThe simulator speaks 1.6 only, so the 2.0.1 path — including
NotifyEVChargingNeeds— is covered by the tests in this package over a real 2.0.1 connection, not by a third-party client.Its OCPP client also sends no credentials and cannot be configured to (it dials with
dialer.Dial(wsURL, nil), and gorilla rejects userinfo in a WebSocket URL), so it cannot reach any central system that requires one. The happy path above used a one-line local patch liftingws://user:pass@host/into anAuthorizationheader. Worth reporting upstream; nothing here depends on it.Verification
Local runs are clean; the only failures on this machine are the known Windows-only ones (nova key metadata, unix sockets, OAuth loopback) in packages this branch does not touch.
Not yet
A human still needs to look at the Chargers tab in a browserDone (2026-08-29): the author ran this branch locally with two simulated chargers — one adopted under its per-charger credential, one held pending in quarantine — and reviewed the rendered Chargers tab and server settings form in a browser.config.yaml, not Settings: host filesystem paths and one secret per charger, set once at commissioning by someone who already has a shell.🤖 Generated with Claude Code