Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
508d17e
feat(ocpp): restore the OCPP 1.6J Central System package
claude Jul 31, 2026
fed6bfb
chore: add changeset for the OCPP central system restore
claude Jul 31, 2026
1adf75d
feat(ocpp): enable the central system and document driverless OCPP ch…
claude Jul 31, 2026
600d68b
feat(ocpp): control chargers with charging profiles instead of remote…
claude Jul 31, 2026
29314d9
fix(ocpp): stop serving the OCPP password over /api/config
claude Jul 31, 2026
945a063
feat(ocpp): serve OCPP 2.0.1 alongside 1.6J
claude Jul 31, 2026
c369aa8
docs(ocpp): document where the protocol code comes from
claude Jul 31, 2026
e2870ef
docs: note 2.0.1 in the driver-writing OCPP callout
claude Jul 31, 2026
d7332e5
docs(ocpp): reword the licence line for the brand-copy check
claude Jul 31, 2026
65f048f
feat(ui): Chargers tab with an OCPP panel and charge-point bindings
claude Aug 5, 2026
fbc0261
feat(ocpp): quarantine unadopted charge points as pending
claude Aug 5, 2026
76441ae
feat(ocpp): hot-apply charger adoption with the loadpoint reload
claude Aug 5, 2026
be552b4
docs(ocpp): vehicle capacity describes one car; per-car profiles need…
claude Aug 5, 2026
a22432b
feat(ocpp): vehicle profiles — identify the car, switch capacity and …
claude Aug 5, 2026
abae8b0
feat(ocpp): probe, record and present whether a charger can be steered
claude Aug 9, 2026
f409362
fix(ocpp): ask each charger for its capabilities once, not twice
claude Aug 9, 2026
5305570
fix(ocpp): clear the capability marker when a charger drops mid-probe
claude Aug 9, 2026
5aa02ef
fix(units): store OCPP vehicle target SoC as a 0-1 fraction
frahlg Aug 19, 2026
8b56058
feat(ocpp): size the session on what the car asked for
claude Aug 29, 2026
5e89864
feat(ocpp): enforce the bind address, serve TLS, and pin chargers to …
claude Aug 29, 2026
8b4abec
feat(ocpp): give an adopted charger a durable device row and edit the…
claude Aug 29, 2026
e3ef622
fix(ocpp): three interop faults found against the device simulator
claude Aug 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .changeset/chargers-tab-ocpp-panel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"ftw": minor
---

Rename the Loadpoints tab and dashboard section to Chargers, and give it an
OCPP panel: the exact backend URL to enter on a charger, live state for every
connected charge point (vendor, dialect, vehicle, power, session energy), and
the connected charge points offered in the charger-driver dropdown so an OCPP
charger can be bound to the planner without editing YAML. Backed by a new
GET /api/ocpp/chargers endpoint. Docs now recommend a DHCP reservation for the
FTW host before commissioning chargers.
13 changes: 13 additions & 0 deletions .changeset/ocpp-capability-probe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": minor
---

FTW now asks each OCPP charger whether it can be steered, and says so. Shortly
after a charger connects, core reads its `SupportedFeatureProfiles` (1.6) or
`SmartChargingCtrlr.Available` (2.0.1), records the raw answer, and shows a
Control column on Settings → Chargers: "smart charging", "telemetry only" (with
a warning explaining the charger will meter but never plan), or "not reported"
for a charger that stayed silent. Also exposed as `steerable` and
`feature_profiles` on `GET /api/ocpp/chargers`. The verdict is advisory —
commands are still attempted, so a charger that under-reports its own
capabilities is never locked out of control.
31 changes: 31 additions & 0 deletions .changeset/ocpp-central-system-restore.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"ftw": minor
---

Add OCPP 1.6J support so EV chargers connect to FTW directly instead of through
a vendor cloud. An OCPP charger needs no driver: the protocol is vendor-neutral,
so one server in core handles every charger that speaks it.

Chargers dial FTW rather than the other way round, 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, and dispatch treats it
like any other EV reading.

This reinstates `go/internal/ocpp`, retired as unused in #578, and wires it into
the process behind a new `ocpp` config section. It matters because Charge Amps
has no FTW driver at all and every current model speaks OCPP, while Easee and
Zaptec can be commissioned once through their vendor portal and then run with no
cloud in the runtime path.

FTW throttles, pauses and resumes an OCPP charger like any other EV charger.
Every command is a current limit rather than a remote start or stop, because
`RemoteStopTransaction` is unreliable on Charge Amps hardware — units
acknowledge the stop and resume charging on their own, while a 0 A charging
profile is honoured consistently and keeps the session meter intact across a
pause. Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than
being rounded up to current the site fuse was not asked to carry.

The server is off by default. Enabling it requires a username and password, and
FTW refuses to start without them: the OCPP library builds its listen address
from the port alone, so the socket is reachable on every interface and basic
auth is the only gate. Keep the port closed at your router.
30 changes: 30 additions & 0 deletions .changeset/ocpp-charger-interop.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
"ftw": patch
---

Three fixes from running the OCPP central system against Sourceful's device
simulator. Each one let FTW report a limit it had not actually imposed, or
refuse a control that should have worked.

**Charging profiles are sent as Relative, not Absolute.** FTW's schedule is a
single period at second 0 with no end — "hold this limit until I say
otherwise". Absolute expresses that only with a `startSchedule` timestamp, and
while the specification 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. Relative carries no
timestamp, so there is nothing to misparse — and nothing that depends 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
applies to every connector — but some chargers read the connector-0 rule as
`ChargePointMaxProfile`-only and reject it. Rejecting means no limit at all, so
one retry on the first connector is the difference between a charger FTW steers
and one it can only meter.

**Manual EV controls reach an OCPP charger.** 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. They failed with `driver "<id>" not found` while automatic
dispatch steered the same charger correctly.
21 changes: 21 additions & 0 deletions .changeset/ocpp-charging-needs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
---
"ftw": minor
---

FTW now listens to what the car itself asks for. On an ISO 15118 session an
OCPP 2.0.1 charger forwards the vehicle's own `NotifyEVChargingNeeds` —
the energy it wants, when it expects to leave, and on DC its battery capacity
and present state of charge. Core takes that as the session's truth: the
reported capacity replaces the configured `vehicle_capacity_wh` (measured beats
an operator's estimate of the car that usually parks here), the reported SoC
re-anchors the session estimate, and the two together with the requested energy
derive the target the planner sizes on. A departure time the car states becomes
the loadpoint's target time, and one it does not state never erases the
operator's own. Everything is session-scoped and reverts on plug-out, like an
identified vehicle profile. The report is visible on `GET /api/ocpp/chargers`
as `charging_needs`, and quarantine still applies — a pending charge point's
needs are shown but never reach a loadpoint.

An AC session states energy without a battery size, so no target fraction is
derived from it; guessing one would feed the planner a number the car never
claimed.
24 changes: 24 additions & 0 deletions .changeset/ocpp-durable-device.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"ftw": minor
---

An adopted OCPP charger is now a device like any other. It gets a row in
`/api/devices` and under Settings → Devices, keyed on the vendor and serial
from its `BootNotification` rather than on the name it dialled with — that
name is one an installer typed and the charger's own web page can change, so
persistent state keyed on it would not survive a re-commissioning. Rename a
charger and the row follows it. A charger that reports no serial falls back to
the dialled name, recorded as an endpoint so it reads as stable-until-changed.
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.

`GET /api/ocpp/chargers` now also reports each charger's `serial` and
`firmware`, and 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 ever report.

The OCPP server's own settings — on/off, bind address, both ports, path,
username and password — are editable under Settings → Chargers instead of
only in `config.yaml`. TLS paths and per-charger credentials stay in the file:
they are host filesystem paths and one secret per charger, set once at
commissioning.
26 changes: 26 additions & 0 deletions .changeset/ocpp-listener-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"ftw": minor
---

The OCPP listener can now be pinned to one interface, served over TLS, and
given a credential per charger.

`ocpp.bind` finally does something. The library builds its listen address from
the port alone, so the socket is unavoidably open on every interface; FTW now
refuses the WebSocket handshake for a connection that arrived on any other
address. That is an access control rather than a smaller attack surface — the
port still answers a scan — and the docs say so.

`ocpp.tls` serves `wss://` instead of `ws://`, ending the plaintext basic auth
anyone on the LAN could sniff. `client_ca_file` additionally requires every
charge point to present 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 named 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 being enough to connect under its
name — the impersonation hole the pending-charger quarantine could not close.
It is opt-in per charger; anything unlisted keeps using the shared credential.
Per-charger passwords are masked out of `GET /api/config` and survive a
settings save, matched by charger id rather than position.
13 changes: 13 additions & 0 deletions .changeset/ocpp-pending-quarantine.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": minor
---

OCPP charge points now start quarantined. A charge point that no charger entry
(loadpoint) names connects as "pending": it shows on Settings → Chargers with
its 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. This stops any device that merely knows the shared OCPP
password from fabricating EV load and steering dispatch (the DerEV sum
suppresses home-battery discharge). Adopting a charger = adding a charger
entry with its id as the charger driver and saving — charger entries
hot-reload, so adoption and un-adoption take effect on the save.
26 changes: 26 additions & 0 deletions .changeset/ocpp-v201-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
"ftw": minor
---

Serve OCPP 2.0.1 alongside 1.6J, so newer chargers connect without a driver too.

Each version listens on its own port. A charger picks its dialect during the
WebSocket handshake, before any message is sent, and the underlying library
keeps one message handler per listener — so a single port cannot serve both.
Set `ocpp.port_v201` to enable 2.0.1; leaving it unset keeps 1.6J only.

Only the message 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.

2.0.1 restructures the messages more than the names suggest: StartTransaction
and StopTransaction collapse into a single TransactionEvent, transaction ids
become strings, connector status loses its charging meaning, and meter samples
arrive inside transaction events as well as on their own. The new handler
normalises all of that back to the same charger state.

OCPP 2.1 is not supported. No production-grade Go implementation of it exists:
the library FTW uses covers 1.6 and 2.0.1 and has no 2.1 support, and the Go
projects that do claim 2.1 are early-stage validators and emulators rather than
servers. Adding it later is one more handler and one more listener; the
version-neutral core does not change.
13 changes: 13 additions & 0 deletions .changeset/vehicle-profiles.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"ftw": minor
---

Vehicle profiles for chargers shared by several cars. A new `vehicles:` config
list (also editable under Settings → Chargers → Vehicles) holds each car's
battery capacity, identifiers and charging policy — PV-surplus-only and/or a
target SoC the planner fills toward in the cheapest tariff hours. When an OCPP
charging session identifies the car (the RFID idTag on 1.6, a MacAddress or
eMAID idToken on 2.0.1), the charger switches to that car's capacity and
policy for the session; capacity reverts on plug-out. A session matching no
profile changes nothing — the visitor default — and the identity it presented
is shown in the Chargers tab so it can be pasted into a profile.
13 changes: 12 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ rule. See [docs/architecture.md](docs/architecture.md).
- local web UI, SQLite history and Parquet rolloff;
- Home Assistant MQTT discovery;
- CalDAV planning intents and published schedules;
- hot-reloadable, independently released Lua drivers.
- hot-reloadable, independently released Lua drivers;
- a built-in OCPP 1.6J + 2.0.1 server, so OCPP chargers connect with no driver.

The local catalog is generated from `DRIVER` metadata. The public
[`srcfl/device-drivers`](https://github.com/srcfl/device-drivers) repo is the
Expand Down Expand Up @@ -85,6 +86,11 @@ It installs Docker when needed, creates `~/ftw`, downloads the Compose file
and starts core, optimizer, updater and the local MQTT broker. Open
`http://<host>:8080/setup` on the LAN.

Give the FTW machine a DHCP reservation (a fixed IP) in your router. Devices
that dial in to FTW — OCPP chargers store their backend URL at commissioning,
and some hardware whitelists which addresses may talk to it — silently lose
the connection if DHCP later hands the host a different address.

Existing Forty Two Watts or older FTW deployments must use the
[legacy upgrade guide](docs/upgrade-from-legacy.md) so configuration and state
are preserved. Raspberry Pi image installation is covered by
Expand Down Expand Up @@ -177,6 +183,10 @@ driver, which can update or roll back without a new FTW core release. Device
Support may consume the same public source later for other products or a higher
support level.

EV chargers that speak OCPP are the exception: they need no driver. FTW runs an
OCPP Central System (1.6J and 2.0.1), so the charger connects and registers itself.
See [docs/ocpp.md](docs/ocpp.md).

## Releases

There are two channels:
Expand All @@ -201,6 +211,7 @@ metadata are the detailed reference.
- [Full backup and safe restore](docs/backup-and-restore.md)
- [Writing a driver](docs/writing-a-driver.md)
- [Device driver catalog](https://srcfl.github.io/device-drivers/) — every supported device and the evidence behind it
- [OCPP chargers (no driver needed)](docs/ocpp.md)
- [Self-update and release channels](docs/self-update.md)
- [Home Assistant](docs/ha-integration.md)
- [CalDAV](docs/caldav-integration.md)
Expand Down
38 changes: 38 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,44 @@ caldav:
plan_path: /ftw/plan/
# plan_publish_interval_s: 900 # how often the plan calendar is reconciled

# Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak
# OCPP connect to FTW directly — there is no driver to write and nothing to add
# under `drivers:`.
# A charger appears as a device on its first BootNotification, keyed by the last
# segment of the URL it dialled: ws://<ftw-host>:8887/garage-left
#
# Credentials are required when enabled, and FTW refuses to start without them:
# the OCPP library builds its listen address from the port alone, so the socket
# is open on every interface whatever bind says — bind refuses the handshake for
# a connection that arrived elsewhere, which controls access without closing the
# port. Keep it closed at your router. See docs/ocpp.md.
# Each version needs its own port: a charger picks its dialect in the WebSocket
# handshake, so one listener cannot serve both. 2.1 is not supported — no
# production-grade Go implementation of it exists yet.
ocpp:
enabled: false
# bind: 192.168.1.10 # refuse chargers reaching the box any other
# way; omit for every interface
port: 8887 # OCPP 1.6J
# port_v201: 8888 # OCPP 2.0.1; omit to disable
path: /
username: ftw
password: "" # required when enabled; use a long random string
heartbeat_interval_s: 60
# Serve wss:// instead of ws://. client_ca_file additionally requires each
# charger to present a certificate signed by that CA — OCPP 2.0.1 security
# profile 3, and the only identity here that cannot be copied between devices.
# tls:
# cert_file: /etc/ftw/ocpp/server.crt
# key_file: /etc/ftw/ocpp/server.key
# client_ca_file: /etc/ftw/ocpp/charger-ca.crt
# Give a charger a credential of its own, so the shared password above stops
# being enough to connect under its name. The id is what the charger dials
# with, and the same string a loadpoint's driver_name adopts.
# chargers:
# - id: garage
# password: "" # a long random string, different per charger

# Persistent state (SQLite)
state:
path: state.db
Expand Down
Loading