Skip to content

feat(ocpp): OCPP 1.6J + 2.0.1 support — chargers connect with no driver - #732

Draft
HuggeK wants to merge 10 commits into
srcfl:masterfrom
HuggeK:worktree-ocpp-restore
Draft

feat(ocpp): OCPP 1.6J + 2.0.1 support — chargers connect with no driver#732
HuggeK wants to merge 10 commits into
srcfl:masterfrom
HuggeK:worktree-ocpp-restore

Conversation

@HuggeK

@HuggeK HuggeK commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

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-go v0.19.0, MIT. It is an ordinary Go module dependency — pinned in go/go.mod, checksum-verified in go/go.sum:

github.com/lorenzodonini/ocpp-go v0.19.0 h1:THNriVV3bUWkGzaIkDeytcgoeBRjoN9ezPHsddIDfgc=

Nothing in this PR is copied or forked from it. The split:

Layer Owner Where
WebSocket transport, OCPP-J framing, message types, schema validation ocpp-go module cache
Handlers, telemetry mapping, control semantics, safety clamps FTW 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 build resolves imports through go.mod and the module cache, so a submodule checkout would sit inert unless paired with a replace directive pointing at it — and that would additionally break go install and 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 under vendor/, 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-go fork plus replace 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

Version Status Port
1.6J supported port, default 8887
2.0.1 supported port_v201, off unless set
2.1 not supported — see below

Each version needs its own port. A charger picks its dialect in the
WebSocket handshake before any message is sent, and ws.Server keeps a single
message-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:

Repo Stars Versions Notes
lorenzodonini/ocpp-go 367 1.6, 2.0.1 what FTW uses; no 2.1; last release Aug 2025
ChargePi/ocpp-manager 6 1.6J, 2.0.1J, 2.1 variable management only, not a server
shiv3/gocpp 5 1.6, 2.0.1, 2.1 generics-first, typed CSMS — closest fit, but very early
ChargePi/chargeflow 3 1.6, 2.0.1, 2.1 CLI message validator
ruslan-hut/ocpp-emu 1 1.6, 2.0.1, 2.1 charger emulator, useful for testing

Everything 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/gocpp is worth re-checking as it matures, and
ruslan-hut/ocpp-emu is 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.

Charger Where the OCPP URL is set Cloud needed?
Charge Amps Halo / Aura WiFi hotspot → 192.168.250.1 → Settings → OCPP no
Charge Amps Dawn / Luna Installer app over Bluetooth → CPMS settings no
Easee Easee commissioning API, once one-time
Zaptec Zaptec Portal, needs Allow OCPP 1.6J one-time

Easee 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 on
its first BootNotification, keyed by the last segment of the URL it dialled:

ws://<ftw-host>:8887/garage-left
                     └── becomes the device key

Control: current limits, never remote stop

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 an OCPP charger is not a Lua driver.

Every command is a current limit. RemoteStopTransaction is unreliable on
Charge 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:

  • Below 6 A, FTW sends 0 A. IEC 61851 has no duty cycle under the minimum.
    Rounding up would draw current the site fuse was never asked to carry.
  • A pause preserves the previous rate. TestResumeRestoresLastLimit caught
    the 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 + StopTransaction collapse into one TransactionEvent
    with a Started/Updated/Ended trigger.
  • Transaction ids become strings rather than ints.
  • StatusNotification reports per-EVSE connector status and loses its
    "charging" meaning — that now comes from the transaction event.
  • Meter samples arrive inside transaction events as well as standalone.
  • Charging profiles carry a list of schedules, each with its own id.

handlers_v201.go normalises all of it back to the same charger state.

Why charger state needed an extra flag

connected already meant "a connector has a vehicle on it" — set by
StatusNotification, never by OnConnect. It could not gate control, because a
default charging profile is exactly what you set on an idle charger. online
now 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/config while surviving a settings round-trip.

This is a mitigation, not a fix. ocpp-go builds its listen address from the
port alone —

addr := fmt.Sprintf(":%v", port)   // ws/websocket.go:397

— so the socket is reachable on every interface and cannot be pinned to one,
with no TLS on this path yet. docs/ocpp.md says so plainly and tells operators
to keep the port closed at the router.

Verification

ok  github.com/srcfl/ftw/go/internal/ocpp       22 tests
ok  github.com/srcfl/ftw/go/internal/config
ok  github.com/srcfl/ftw/go/internal/loadpoint
go build ./...   OK
go vet           OK

Both dialects are covered end-to-end against real ocpp16.ChargePoint and
ocpp201.ChargingStation clients 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/config path-separator tests and the internal/api MyUplink OAuth,
backup-lifecycle and version-update tests fail on Windows. Confirmed identical
on a clean master worktree.

Not yet

  • No UI. The ocpp: section is YAML-only, chargers do not appear in
    /api/devices (that registry is driver-only), and Handler.Snapshot() is not
    exposed by any endpoint.
  • Bind address and TLS, per the security note.
  • Hardware acceptance — verified against simulated charge points; the Charge
    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

claude and others added 3 commits July 31, 2026 09:52
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>
@HuggeK HuggeK changed the title feat(ocpp): restore the OCPP 1.6J Central System feat(ocpp): OCPP 1.6J support — chargers connect with no driver Jul 31, 2026
… 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>
@HuggeK HuggeK changed the title feat(ocpp): OCPP 1.6J support — chargers connect with no driver feat(ocpp): OCPP 1.6J support with control — chargers connect with no driver Jul 31, 2026
claude and others added 2 commits July 31, 2026 14:35
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>
@HuggeK HuggeK changed the title feat(ocpp): OCPP 1.6J support with control — chargers connect with no driver feat(ocpp): OCPP 1.6J + 2.0.1 support — chargers connect with no driver Jul 31, 2026
claude and others added 4 commits July 31, 2026 14:58
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>
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