feat(ocpp): OCPP 1.6J + 2.0.1 support — chargers connect with no driver - #732
Draft
HuggeK wants to merge 10 commits into
Draft
feat(ocpp): OCPP 1.6J + 2.0.1 support — chargers connect with no driver#732HuggeK wants to merge 10 commits into
HuggeK wants to merge 10 commits into
Conversation
Brings back go/internal/ocpp, retired as unused in srcfl#578, so EV chargers can connect to FTW directly instead of through a vendor cloud. The package is restored unchanged and still builds, vets and passes its own tests against the current tree. github.com/lorenzodonini/ocpp-go resolves to v0.19.0 at @latest — the same version that was removed, since upstream has not cut a release since August 2025. Nothing is wired into main.go yet, so this changes no runtime behaviour. Two known gaps are carried over from the original and must be closed before the server is enabled: - Config.Bind is advisory only. ocpp-go does not expose a bind address, so the listener takes 0.0.0.0 regardless, and Phase 1 has no TLS. - Handlers are read-only. Charge Amps needs SetChargingProfile-based control, because its RemoteStopTransaction is unreliable in the field. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
…argers Wires the restored OCPP 1.6J Central System into the process behind a new opt-in ocpp config section, and documents the part that is easy to miss: an OCPP charger needs no driver at all. OCPP is vendor-neutral, so one server in core covers every charger that speaks it. Chargers dial FTW rather than being polled, so there is nothing to add under drivers: — a charge point becomes a device on its first BootNotification, keyed by the last segment of the URL it connected to. Enabling the server requires a username and password, and config validation rejects an enabled section without them. This is deliberate. ocpp-go builds its listen address from the port alone, so the socket is reachable on every interface and cannot be pinned to one; basic auth is the only gate in front of it. That is a mitigation, not a fix, and the docs say so. Documented in docs/ocpp.md, with pointers from the README, config.example.yaml and writing-a-driver.md so nobody starts a Lua driver for a charger that does not need one. Tests cover the fail-closed credential rule and assert the shipped example config still parses, ships disabled and validates. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
… stop FTW can now throttle, pause and resume an OCPP charger. Command has the same signature as drivers.Registry.Send and speaks the vocabulary every EV driver already implements, so main.go routes by name and loadpoints never learn that an OCPP charger is not a Lua driver. Every command is a current limit, never a remote start or stop. RemoteStopTransaction is unreliable on Charge Amps hardware — units acknowledge the stop and then resume charging on their own — while a 0 A charging profile is honoured consistently. It also leaves the transaction open, so the session meter keeps counting across a pause instead of splitting into two sessions. Two safety decisions worth calling out: - Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than rounded up. When the allocator has less headroom than that to give, rounding up draws current the site fuse was never asked to carry, so refusing to charge is the safe direction of error. - A pause records no limit, so resuming returns to the last non-zero rate rather than the fallback ceiling. Caught by TestResumeRestoresLastLimit. Also splits charger state in two. connected already meant "a connector has a vehicle on it" and was never set by OnConnect, so it could not gate control: a default charging profile is exactly the thing you set on an idle charger. online now tracks the WebSocket session and is what control gates on. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
MaskSecrets covered every other credential section but not the new ocpp one, so GET /api/config returned the password in plaintext to any UI client. That is the one credential that must not leak: the OCPP listener is reachable on every interface and basic auth is the only thing in front of it. Masking alone would have traded a leak for a wipe, because the settings tab posts the config back and the masked password returns empty. PreserveMaskedSecrets now keeps the stored value when the incoming one is blank, while a genuinely new password still wins. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Newer chargers now connect without a driver too. Each version listens on its own port, because a charger picks its dialect during the WebSocket handshake before any message is sent, and ocpp-go's ws.Server keeps a single message handler per listener — one port cannot serve both. Set ocpp.port_v201 to enable 2.0.1; leaving it unset keeps 1.6J only. Only the encoding differs. Both dialects share one charger map, one telemetry path and one control path, so a 2.0.1 charger is metered, throttled and paused exactly like a 1.6 one and dispatch cannot tell them apart. Which listener a charger reached is recorded on connect, and control encodes the profile to match. 2.0.1 restructures more than the names suggest: Start/StopTransaction collapse into one TransactionEvent, transaction ids become strings, connector status loses its charging meaning, and meter samples arrive inside transaction events as well as alone. The new handler normalises all of it back to the same state. OCPP 2.1 is deliberately absent. No production-grade Go implementation exists — ocpp-go covers 1.6 and 2.0.1 only, and the Go projects claiming 2.1 are early-stage validators and emulators rather than servers. Adding it later is one handler and one listener; the version-neutral core is unaffected. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Records the boundary between the vendored protocol layer and FTW's own code, so a reader knows which side of it a bug is on: transport, OCPP-J framing, message types and schema validation are ocpp-go; handlers, telemetry mapping, control semantics and safety clamps are ours. Also corrects a false claim carried over from the retired package. The doc comment said ocpp-go was "also used by SteVe" — SteVe is a Java project (GPL-3.0), so it cannot depend on a Go library, and ocpp-go's README names no production users at all. Replaced with facts that can be checked: MIT, v0.19.0, no release since August 2025, and upstream's own description of its 2.0.1 support as needing more real-world testing. The package doc was stale in other ways too — it still described a 1.6-only, read-only server whose Phase 2 would add control, all of which has since landed. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
…tore # Conflicts: # config.example.yaml # go/cmd/ftw/main.go # go/go.mod # go/go.sum # go/internal/config/config.go
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
The brand-cleanup workflow inventories "MIT licensed" as classified copy, so the new provenance paragraph tripped it. Same fact, different wording. Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
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.
Adds OCPP 1.6J and 2.0.1 support so EV chargers connect to FTW directly
instead of through a vendor cloud, with full control, and documents the part
that is easy to miss: an OCPP charger needs no driver.
OCPP is vendor-neutral, so one server in core covers every charger that speaks
it. This is the inverse of the rest of FTW, where every device needs a driver
precisely because every vendor invented its own protocol.
Where the Go code comes from
The protocol layer is
github.com/lorenzodonini/ocpp-gov0.19.0, MIT. It is an ordinary Go module dependency — pinned ingo/go.mod, checksum-verified ingo/go.sum:Nothing in this PR is copied or forked from it. The split:
go/internal/ocpp(~1,400 lines + ~860 lines of tests)That boundary is the first thing to check when something misbehaves: a malformed message or dropped connection is upstream, a wrong power figure or current limit is ours.
Upstream health, stated plainly because we now depend on it for a control path: 367 stars, MIT, no release since August 2025, and it describes its own 2.0.1 support as "examples working, but will need more real-world testing". So treat the 2.0.1 path here as less proven than 1.6J regardless of the tests below.
On making it a git submodule
Considered, and it does not work for a Go dependency.
go buildresolves imports throughgo.modand the module cache, so a submodule checkout would sit inert unless paired with areplacedirective pointing at it — and that would additionally breakgo installand any clone made without--recurse-submodules, while forcing submodule-aware checkout into CI,make build-arm64, Docker and the release flow. FTW uses no submodules today.If the goal is the dependency source living in this repository, auditable and pinned, the Go-native answer is
go mod vendor: it commits the dependency tree undervendor/, the toolchain uses it automatically, and no build path changes. Happy to do that if you want it — say the word.If the goal is carrying patches upstream will not take, the answer is a
srcfl/ocpp-gofork plusreplace github.com/lorenzodonini/ocpp-go => github.com/srcfl/ocpp-go. Still an ordinary module. Given upstream's release cadence, that is the likely destination anyway.A provenance claim in the retired code was false, and is corrected here
The package doc carried over from #578 said ocpp-go was "also used by SteVe". SteVe is a Java project (GPL-3.0, 1,090 stars) — it cannot depend on a Go library — and ocpp-go's README names no production users at all.
I repeated that line unverified when restoring the package. It is now replaced with facts that can be checked.
Protocol versions
port, default 8887port_v201, off unless setEach version needs its own port. A charger picks its dialect in the
WebSocket handshake before any message is sent, and
ws.Serverkeeps a singlemessage-handler slot, so one listener cannot dispatch both.
Only the encoding differs. Both dialects share one charger map, one telemetry
path and one control path — dispatch cannot tell them apart.
Why 2.1 is absent, and what it would take
No production-grade Go implementation of OCPP 2.1 exists. Survey of every Go
OCPP project on GitHub:
lorenzodonini/ocpp-goChargePi/ocpp-managershiv3/gocppChargePi/chargeflowruslan-hut/ocpp-emuEverything claiming 2.1 is single-digit stars and mostly validators or
emulators rather than central systems. Depending on one for the safety path
would be a downgrade from a 367-star library.
Not a blocker in practice: every charger on the bench speaks 1.6J only, and
2.0.1 is what current hardware is migrating to. OCA published 2.1 in Jan 2025
and field adoption is still thin.
Adding it later is one handler file and one listener — the version-neutral
core does not change.
shiv3/gocppis worth re-checking as it matures, andruslan-hut/ocpp-emuis worth keeping in mind for HIL testing.Why this exists
Bench testing six chargers turned up the gap: Charge Amps has no FTW driver at
all, and three of six units are Charge Amps. Every current model speaks OCPP
1.6J since Charge Amps migrated off its proprietary CAPI protocol.
192.168.250.1→ Settings → OCPPAllow OCPP 1.6JEasee and Zaptec need a one-time vendor-portal step; after that the cloud is out
of the runtime path. Charge Amps needs no cloud at all.
How a charger becomes a device
Drivers poll outward. OCPP runs the other way — the charger dials FTW and
pushes. So there is nothing to add under
drivers:; a charge point appears onits first
BootNotification, keyed by the last segment of the URL it dialled:Control: current limits, never remote stop
Commandhas the same signature asdrivers.Registry.Sendand speaks thevocabulary every EV driver already implements, so
main.goroutes by name andloadpoints never learn an OCPP charger is not a Lua driver.
Every command is a current limit.
RemoteStopTransactionis unreliable onCharge Amps hardware — units acknowledge the stop and resume on their own — while
a 0 A charging profile is honoured consistently, and it keeps the transaction
open so the session meter is not split in two.
Two safety decisions worth review:
Rounding up would draw current the site fuse was never asked to carry.
TestResumeRestoresLastLimitcaughtthe first version recording the pause's zero and resuming at the ceiling.
On loss of contact the charger holds its last granted limit, matching every EV
driver in FTW.
2.0.1 restructures more than the names suggest
StartTransaction+StopTransactioncollapse into oneTransactionEventwith a Started/Updated/Ended trigger.
StatusNotificationreports per-EVSE connector status and loses its"charging" meaning — that now comes from the transaction event.
handlers_v201.gonormalises all of it back to the same charger state.Why charger state needed an extra flag
connectedalready meant "a connector has a vehicle on it" — set byStatusNotification, never byOnConnect. It could not gate control, because adefault charging profile is exactly what you set on an idle charger.
onlinenow tracks the WebSocket session and is what control gates on.
Security: fail closed
Enabling the server requires a username and password; validation rejects an
enabled section without them, and the password is masked out of
GET /api/configwhile surviving a settings round-trip.This is a mitigation, not a fix.
ocpp-gobuilds its listen address from theport alone —
— so the socket is reachable on every interface and cannot be pinned to one,
with no TLS on this path yet.
docs/ocpp.mdsays so plainly and tells operatorsto keep the port closed at the router.
Verification
Both dialects are covered end-to-end against real
ocpp16.ChargePointandocpp201.ChargingStationclients that record the profiles they receive,including a test that runs both listeners at once and steers one charger of
each kind without confusing them.
Pre-existing test failures, unrelated to this change
internal/configpath-separator tests and theinternal/apiMyUplink OAuth,backup-lifecycle and version-update tests fail on Windows. Confirmed identical
on a clean
masterworktree.Not yet
ocpp:section is YAML-only, chargers do not appear in/api/devices(that registry is driver-only), andHandler.Snapshot()is notexposed by any endpoint.
Amps quirks come from field reports.
Bench commissioning and factory-reset detail per model: discussion #747 (moved here from a draft PR on
srcfl/device-drivers, which is closed — it is operator knowledge about hardware, not driver source, and Discussions are disabled on that repo).🤖 Generated with Claude Code