Skip to content

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

Draft
HuggeK wants to merge 22 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 22 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

Appearing is not joining. A charge point that no charger entry (loadpoint)
names connects as pending: listed on Settings → Chargers with vendor,
dialect and live state so it can be adopted, but its telemetry is withheld
from the site — no DerEV reading, no driver health, no metrics — and it is
never commanded. Adopting it = picking its id as the charger driver in a
charger entry and saving. Charger entries hot-reload, so the config applier
re-derives the approved set on the same save — adoption admits the very next
reading, and removing the entry returns the charger to pending, with a zero
DerEV pushed so its last power figure cannot linger in the sum.

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.

Behind the password sits a second gate: unadopted charge points are
quarantined
. Every charger shares one basic-auth secret and picks its own
identity (the URL segment), so "it authenticated" proves the password, not the
device. Before this gate, anything on the LAN holding the password could invent
EV load — and the dispatch clamp would obligingly stop the home battery
discharging into a charge that does not exist. Now a stolen password gets an
attacker a pending row in the Chargers table, not influence over the site.
TestPendingChargerIsQuarantinedFromTelemetry proves a booted, "charging"
charge point at 7.2 kW leaves no trace in telemetry.Store.

What the quarantine deliberately does not stop

A device that knows the password and an adopted charger's id can still
impersonate it — identity is client-chosen and the credential is shared. Fixing
that needs per-charger credentials (1.6 allows per-connection basic auth) or
TLS with client certificates (2.0.1 security profiles), both future work. The
quarantine closes the injection hole that needed no knowledge beyond the
password; impersonation additionally requires knowing a real charger's id.

A third gate arrived while this branch was open: #744 gave every route an
access tier.
GET /api/ocpp/chargers is registered Local, not Read.

Why a charger list is a secret-bearing read

The response carries vehicle_id. On 1.6 that is the RFID idTag the card
presented
— the token that authorizes a charge at the charger itself. The
tier doc warns that the method was wrong twice in one review by judging a
route from its verb, and its cautionary example is a GET that hands out a
credential. A card number belongs in that company more than it belongs
alongside a power reading.

Nothing is lost by the stricter tier: the endpoint's only caller is the
settings page, which already needs Local for GET /api/config. The route
is listed in TestNoSecretBearingReadCrossesTheSession, so a viewer reaching
through the app passthrough is refused with E_LOCAL_ONLY — the tier is
enforced, not merely declared. Downgrade it to Read if you read the idTag
differently; the test is the one line to change.

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.

The UI: a Chargers tab

The Loadpoints settings tab and dashboard section are renamed Chargers,
and the tab gains an OCPP panel. There is deliberately no "add OCPP charger"
button — the charger adds itself when it dials in — so the panel shows the
exact backend URL to enter on the charger, warns to give the FTW host a DHCP
reservation first (chargers store the URL at commissioning, some whitelist
addresses), and lists every connected charge point live — unadopted ones
dimmed and marked · pending, with a note explaining they are ignored until
adopted. Connected charge points also appear in the charger-driver dropdown as
<name> (OCPP), so binding one to the planner is a dropdown pick instead of a
YAML edit — and that same pick is what adopts a pending charger.

Backed by GET /api/ocpp/chargers: effective ports plus a per-charger view
extended with online state, dialect, vendor/model and the last accepted limit.

Settings → Chargers with the OCPP panel and a live Charge Amps Dawn

The charger on the dashboard, and how the screenshots were taken

The same charge point is a first-class element of the dashboard power flow:

Dashboard flow with the OCPP charger charging at 7.4 kW

Both screenshots are a real session: a simulated Charge Amps Dawn connected
over OCPP 1.6J to a Raspberry Pi running this branch, charging at 7.4 kW,
captured with headless Edge over the DevTools protocol against the live host.

Can this charger be steered? Ask it, record it, show it

Not every OCPP charger accepts control, and finding out by watching a planner
do nothing is a bad first experience. FTW now probes each charger once shortly
after it connects — GetConfiguration(SupportedFeatureProfiles) on 1.6, the
SmartChargingCtrlr.Available variable on 2.0.1 — records the raw answer, and
reports a tri-state verdict in a Control column and on
GET /api/ocpp/chargers (steerable, feature_profiles):

Column Meaning
smart charging Advertises SmartCharging; FTW can throttle and pause it.
telemetry only Answered without it — metering, no planning. Warned in the panel.
not reported Never answered. Unknown, not incapable — re-probed on each reconnect.

Advisory, never a gate. Commands are still attempted: vendors under-report,
and firmware updates change the answer without changing the advertisement. What
actually decides remains the response to a real SetChargingProfile, handled by
the existing actuation tracker. The probe only buys honesty up front.

Chargers table showing smart charging, telemetry only and not reported side by side

All three states above are one live screenshot from the bench Pi, with three
simulated chargers connected at once. The probe fires exactly once per charger
(an in-flight marker collapses the connect and boot triggers, verified on the
wire: dawn asks: 1, meter asks: 1), and an unanswered probe deliberately
does not stick — that is how a firmware update adding SmartCharging is ever
noticed.

Vehicle profiles: which car is plugged in

A vehicles: config list (also edited on the Chargers tab) gives each car a
battery capacity, identifiers and a charging policy — PV-surplus-only
and/or a target SoC the planner fills toward in the cheapest tariff hours.
When a charging session identifies the car — the RFID idTag on 1.6, a
MacAddress (autocharge) or eMAID (ISO 15118) idToken on 2.0.1 — the
loadpoint switches to that car's capacity and policy for the session. Capacity
reverts on plug-out and survives config hot-reloads. A session matching no
profile changes nothing (the visitor default), and the identity it
presented is shown in the Chargers table's new Vehicle column for copy-paste
into a profile. Quarantine still means no influence: pending chargers store
identity for display but never fire the profile hook.

Chargers table resolving the session identity to the Bench Car profile

Proven on the bench Pi: the simulator's StartTransaction tag BENCHTAG01
matched a bench-car profile —

OCPP transaction started       charger=bench-dawn txid=1 tag=BENCHTAG01
ocpp: vehicle profile applied  charger=bench-dawn lp=bench vehicle=bench-car
                               source=rfid capacity_wh=40000 target_soc_pct=80

— after which /api/loadpoints reported vehicle_name: "Bench Car" with the
80 % target set.

An honest caveat about the plug-out revert

The capacity revert runs in the loadpoint controller's Observe path. That
controller only exists on planner-enabled sites — main.go gates its
construction on mpcSvc != nil (pre-existing, not introduced here) — so the
bench (which runs no planner) can apply a profile but never observes the
plug-out. The revert is pinned by TestApplyVehicleProfileSessionScoped
instead, including surviving a mid-session config hot-reload. On any site
with a planner, loadpoint observation runs every control tick and the revert
is live. Without a planner the capacity is only cosmetic anyway — there is no
DP for it to mislead.

Not yet

  • No durable device entry. Chargers appear in /api/status,
    GET /api/ocpp/chargers and the Chargers panel, but /api/devices (the
    hardware-identity registry) is driver-only, and the ocpp: server section
    itself is still configured in YAML.
  • Bind address and TLS, per the security note.
  • Per-charger credentials, so an adopted charger's identity cannot be
    impersonated with the shared password alone.
  • Requested energy (NotifyEVChargingNeeds, ISO 15118) is not consumed
    yet — with it, the planner could size a session on what the car actually
    asked for instead of a configured capacity. Tracked with OCPP 2.1 in Track OCPP 2.1: upstream ocpp-go PR #371, and what 2.0.1/2.1 unlock over 1.6J #835.
  • 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>
…tore

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Rename the Loadpoints settings tab and dashboard section to Chargers —
the thing an operator recognises is the charger, not the internal
loadpoint binding (config keys keep their spelling).

The tab gains an OCPP panel: the exact backend URL to enter on a
charger, how to enable the server when it is off, live state for every
connected charge point, and a warning to reserve the FTW host's IP in
the router before commissioning — chargers store the URL and some
whitelist addresses, so a DHCP move silently orphans them.

Charge points seen by the OCPP server now appear in the charger-driver
dropdown, so binding an OCPP charger to the planner is a dropdown pick
instead of a YAML edit. Backed by GET /api/ocpp/chargers, which reports
the effective ports plus a per-charger view extended with online state,
dialect, vendor/model and the last accepted limit.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit to HuggeK/ftw that referenced this pull request Aug 5, 2026
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

The UI now answers "where do I add an OCPP charger?"

The Loadpoints tab and dashboard section are renamed Chargers, and the tab gains an OCPP panel. There is deliberately no "add OCPP charger" button — the charger adds itself the moment it dials in — so what the panel does is tell you the exact URL to enter on the charger, then show every charge point that has connected, live:

Settings → Chargers with the OCPP panel and a live Charge Amps Dawn

Everything in that screenshot is a real session: a simulated Charge Amps Dawn connected over OCPP 1.6J to a Raspberry Pi running this branch, charging at 7.4 kW. The charge point also appears in the Charger driver dropdown as bench-dawn (OCPP), so binding it to the planner is a dropdown pick instead of a YAML edit — backed by the new GET /api/ocpp/chargers.

On the dashboard the same charger is a first-class element of the power flow:

Dashboard flow with the OCPP charger charging at 7.4 kW

How this was tested (hardware-free, reproducible)
  1. Build this branch for arm64, run it on a Pi with ocpp.enabled: true (1.6J on 8887, 2.0.1 on 8888, basic auth on).
  2. Connect a charge-point simulator built on lorenzodonini/ocpp-go's client side: BootNotification → StatusNotification(Preparing) → StartTransaction → MeterValues at 7.4 kW every 10 s.
  3. Verify GET /api/ocpp/chargers reports the charger with vendor/model/dialect/state, the Chargers tab renders it, and the dropdown offers it.
  4. Screenshots taken with headless Edge over CDP against the live Pi.

Force-started charging through POST /api/loadpoints/{id}/force_start earlier in the bench run: FTW pushes SetChargingProfile (current limits, never RemoteStop) and the charge point acks each one.

Why the DHCP-reservation advice sits in the panel

Chargers store the backend URL at commissioning time, and some hardware additionally whitelists which addresses may open a connection to it. An FTW host that later gets a different DHCP lease silently orphans every charger pointed at it — nothing errors, the chargers just never reconnect. The panel and docs/ocpp.md now both say: reserve the IP first. Plain DNS hostnames work on most charger firmware; mDNS .local names usually do not.

Also in this push: dashboard "loadpoint" strings read "charger", README install section recommends the DHCP reservation, and a changeset covers the rename + panel.

A charge point that authenticates but is not named by any charger entry
(loadpoint) now connects as "pending": visible in Settings -> Chargers and
GET /api/ocpp/chargers so it can be adopted, but its telemetry never
reaches the site - no DerEV reading, no driver health, no metrics - and it
is never commanded. Without this, any device holding the shared basic-auth
secret could fabricate EV load and suppress home-battery discharge through
the dispatch clamp.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit to HuggeK/ftw that referenced this pull request Aug 5, 2026
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
@HuggeK

HuggeK commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Update: unadopted charge points are now quarantined as pending

A gap flagged during bench review: any device holding the shared OCPP password could connect under an invented identity, and its MeterValues fed straight into the DerEV sum — fabricated EV load steering the dispatch clamp into suppressing home-battery discharge. As of 4dc8b70 that is closed. A charge point no charger entry (loadpoint) names connects as pending: visible in the UI and GET /api/ocpp/chargers so it can be adopted, but no DerEV reading, no driver health, no metrics, and never commanded. Adoption = picking its id as the charger driver in a charger entry and saving — charger entries hot-reload, so the applier re-derives the approved set on the same save (64ddbfc); revoking an entry pushes a zero DerEV so stale power cannot linger.

Proven live on the bench Pi with two simulated charge points connected simultaneously, with the same password:

Charge point Named by a loadpoint? Reports Effect on ev_w
bench-dawn yes (bench) 7 400 W charging ev_w ≈ 7 362 W
intruder-demo ("EvilCo FakeCharger") no 6 000 W "charging" none — excluded

Settings → Chargers: adopted bench-dawn charging at 7.4 kW, intruder-demo dimmed and marked pending

TestPendingChargerIsQuarantinedFromTelemetry pins the behavior — a booted, "charging" charge point at 7.2 kW leaves no trace in telemetry.Store. The Chargers tab marks pending rows and explains the adopt flow; docs/ocpp.md documents the gate and its rationale.

What the quarantine deliberately does not stop

Impersonation of an adopted id. Identity is client-chosen and the credential is shared, so password plus a real charger's id still gets through. Per-charger credentials (1.6 allows per-connection basic auth) or TLS client certificates (2.0.1 security profiles) would close that; both are listed as future work in the description.

🤖 Generated with Claude Code

Loadpoints hot-reload on config save, so the quarantine's approved set must
follow in the same applier — otherwise adopting a pending charger would arm
the loadpoint controller immediately while telemetry stayed blocked until a
restart. Revoking an adoption now also pushes a zero DerEV reading, for the
same reason OnDisconnect does: the last power figure must not linger in a
sum the charger is no longer part of.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
… 2.0.1/15118

Note on the config field, the settings tooltip and docs/ocpp.md: a charger
entry's vehicle_capacity_wh models the one car the charger usually serves,
and a wrong value costs planning accuracy, never safety. Detecting which car
is plugged in - to switch capacity automatically - needs vehicle identity
from the protocol: OCPP 1.6's idTag names the RFID card, not the car; real
identity (MacAddress autocharge / eMAID) and NotifyEVChargingNeeds are OCPP
2.0.1 + ISO 15118 territory, listed as future work.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
…policy

A vehicles: config list (and a Vehicles section on Settings -> Chargers)
gives each car a capacity, identifiers and a charging policy: PV-surplus-only
and/or a target SoC the planner fills toward in the cheapest hours. When a
charging session identifies the car - the RFID idTag on 1.6, a MacAddress
(autocharge) or eMAID (ISO 15118) idToken on 2.0.1 - the loadpoint switches
to that car's capacity and policy for the session. Capacity reverts on
plug-out and survives config hot-reloads. A session matching no profile
changes nothing (the visitor default), and its identity shows in the
Chargers table so the operator can paste it into a profile. Quarantine
applies: pending chargers store identity for display but never fire the
profile hook.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 2 commits August 9, 2026 18:57
Shortly after a charger connects, core reads SupportedFeatureProfiles (1.6)
or SmartChargingCtrlr.Available (2.0.1), stores the raw answer and derives a
tri-state verdict: smart charging, telemetry only, or not reported. Surfaced
as steerable/feature_profiles on GET /api/ocpp/chargers and as a Control
column on Settings -> Chargers, with a warning before an operator binds a
metering-only charger to a charger entry and waits for a planner that will
never move it.

Advisory, never a gate: commands are still attempted, because vendors
under-report and firmware changes the answer. What decides remains the
response to a real SetChargingProfile, handled by the actuation tracker.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Connect and boot both trigger the probe milliseconds apart, long before any
answer can arrive, so every charger was asked twice. An in-flight marker
collapses that into one request, cleared on every callback path (and on a
send that never left) so an unanswered probe still retries on the next
connect - which is how a firmware update that adds SmartCharging is noticed.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
HuggeK added a commit to HuggeK/ftw that referenced this pull request Aug 9, 2026
Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
claude and others added 2 commits August 9, 2026 19:17
A probe in flight when the socket dies may never get its callback, which
would leave the in-flight marker set and block every later probe for that
charger. The reconnect is exactly when we want to ask again, so OnDisconnect
clears it.

Co-authored-by: HuggeK <48095810+HuggeK@users.noreply.github.com>
Two conflicts, both from work that landed while this branch was open.

api.go: srcfl#744 gave every route an access tier. GET /api/ocpp/chargers is
Local rather than Read — the list carries vehicle_id, which on 1.6 is the
RFID idTag the card presented, and that token authorizes a charge at the
charger. Its only caller is the settings page, which is Local already, so
nothing is lost. Added to TestNoSecretBearingReadCrossesTheSession so the
tier is enforced rather than merely declared.

config.go: Validate() gained FleetPing alongside this branch's OCPP and
vehicle checks. Both kept.

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