Skip to content

feat(ocpp): charging needs, TLS, per-charger credentials and a device row - #999

Open
HuggeK wants to merge 4 commits into
srcfl:masterfrom
HuggeK:ocpp-improvements
Open

feat(ocpp): charging needs, TLS, per-charger credentials and a device row#999
HuggeK wants to merge 4 commits into
srcfl:masterfrom
HuggeK:ocpp-improvements

Conversation

@HuggeK

@HuggeK HuggeK commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #979, which merged the OCPP central system into master on 29 August (bf2c142f, shipped in v2.4.0-beta.1).

This is the list #979's own Boundaries and safety section left open:

Unchanged from #732: shared basic auth, listener on 0.0.0.0 without TLS, pending quarantine, no per-charger credentials, no /api/devices row. Hardware acceptance is still simulator + field reports.
#979

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_wh and a vehicle profile are an operator's estimate of the car that usually parks here; this is the car actually plugged in.

The car says FTW does
battery capacity (DC) replaces the session capacity, over a profile's too
present state of charge (DC) re-anchors the session SoC estimate
energy requested with the two above, derives the target the planner fills to
departure time becomes the loadpoint's target time

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.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 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.tls serves wss://, ending the plaintext basic auth anyone on the LAN can sniff. client_ca_file additionally 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.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 — 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.Start unconditionally calls SetCheckClientHandler with its own handler, so a gate registered on the raw ws.Server is silently discarded at startup: the listener comes up, the logs say the gates are configured, and every impersonation attempt is accepted. guardedServer chains 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 from BootNotification rather 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 chargeBoxSerialNumber is 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.yaml snippet 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 Absolute with no startSchedule. 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 as Relative — 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 TxDefaultProfile there — 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 as ChargePointMaxProfile-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 with driver "garage" not found while automatic dispatch steered the same charger correctly.

What else the run covered
Checked Result
Unauthenticated charger refused at the handshake
Per-charger credential connects as garage with its own password; the shared one does not buy that name
Capability probe Core,SmartCharging,RemoteTrigger,LocalAuthListManagementsteerable
Adopted charger telemetry flows, /api/devices row keyed charge amps:garage
Session plug-in → transaction → 7.7 kW → session energy accumulating
Pause / Resume 7700 W → 0 W → 7700 W, last rate restored at 16 A
Quarantine an unadopted driveway charger is visible in the Chargers panel and absent from both telemetry and /api/devices

The 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 lifting ws://user:pass@host/ into an Authorization header. Worth reporting upstream; nothing here depends on it.

Verification

go build ./...
go test ./internal/ocpp ./internal/config ./internal/loadpoint ./internal/api -count=1
node --test web/settings/tabs/loadpoints.test.mjs

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

  • Hardware acceptance. Verified against a simulated charge point, not real hardware. The Charge Amps quirks come from field reports.
  • A human still needs to look at the Chargers tab in a browser Done (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.
  • TLS certificate paths and per-charger credentials stay in config.yaml, not Settings: host filesystem paths and one secret per charger, set once at commissioning by someone who already has a shell.
  • OCPP 2.1 is not supported; no production-grade Go implementation exists yet. Tracked in Track OCPP 2.1: upstream ocpp-go PR #371, and what 2.0.1/2.1 unlock over 1.6J #835.

🤖 Generated with Claude Code

claude and others added 4 commits August 29, 2026 16:19
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
HuggeK marked this pull request as ready for review August 29, 2026 15:00
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