Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
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.
20 changes: 18 additions & 2 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,35 @@ caldav:
#
# 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 reachable on every interface and basic auth is the only gate. Keep the port
# closed at your router. See docs/ocpp.md.
# 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:
Expand Down
167 changes: 143 additions & 24 deletions docs/ocpp.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,20 @@ turns *seen* into *trusted*.

Once adopted it behaves like any other EV reading: `MeterValues` and
`StatusNotification` become telemetry, and dispatch stops the home battery
discharging into an active EV charge.
discharging into an active EV charge. It also gets a row in `/api/devices`
alongside the driver-backed hardware, under **Settings → Devices**.

That row is keyed on the vendor and serial from `BootNotification`, not on the
name above — a name an installer typed and the charger's own web page can
change is not hardware identity, and state keyed on it would not survive a
re-commissioning. Rename a charger and its row follows it; the persistent
state stays attached to the box on the wall. A charger that reports no serial
(plenty do not) falls back to the dialled name, recorded as an endpoint so it
reads as what it is: stable only until someone changes it.

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; it gets one on the
save that adopts it.

## Protocol versions

Expand Down Expand Up @@ -128,32 +141,87 @@ ocpp:
**Credentials are mandatory.** FTW refuses to start with `enabled: true` and an
empty username or password. That is deliberate, and the reason is below.

## Security: the listener is on every interface
## Security: the socket is on every interface

The OCPP library builds its listen address from the port alone, so the socket
binds to every interface the host has. There is no bind-address setting, and
there is no TLS on this path yet.
is open on every interface the host has and nothing FTW can configure changes
that. `bind` therefore works one layer up: a connection that arrived on any
other address is refused at the WebSocket handshake, before it can speak OCPP.

On a Raspberry Pi with one LAN connection that is usually fine. It is not fine
if the host also has a public interface or a permissive port forward.
That is an access control, not a smaller attack surface. A port scan still
finds the port open on every interface; what it cannot do is talk to it.

So:
```yaml
ocpp:
bind: 192.168.1.10 # only chargers reaching the box this way
```

On a Raspberry Pi with one LAN connection the default (every interface) is
usually fine. Set `bind` when the host also has a VPN interface, a second NIC,
or a public one.

Whatever you set:

- Keep the port closed at your router. Never forward it from the internet.
- Treat the password as a real secret; it is the only gate in front of the
server.
- Basic auth over `ws://` sends the credential unencrypted. Anyone who can sniff
your LAN can read it.
- Treat the password as a real secret.

Behind the password sits a second gate: a charge point no charger entry names
stays pending, outside telemetry and dispatch (see above). A stolen password
gets an attacker a row in the Chargers table, not influence over the site.
What it does not stop is impersonation — a device that knows the password *and*
an adopted charger's id can still pose as it, which is what per-charger
credentials and TLS would fix.
### The four gates

Credentials being required is a mitigation, not a fix. Binding to one interface
needs a change to the upstream library.
1. **Basic auth.** Required — an enabled server without a username and
password is refused at startup.
2. **Identity binding.** A charger listed under `ocpp.chargers` has a password
of its own and must present it under its own name. This is what closes
impersonation: without it, identity is client-chosen and shared, so a device
that knows the password *and* an adopted charger's id can pose as it.
3. **Bind.** As above.
4. **Quarantine.** A charge point no charger entry names stays pending, outside
telemetry and dispatch. A stolen password gets an attacker a row in the
Chargers table, not influence over the site.

### Per-charger credentials

```yaml
ocpp:
username: ftw
password: "the-shared-one"
chargers:
- id: garage
password: "a-different-long-random-string"
```

The `id` is what the charger dials with — the last segment of its URL, and the
same string a charger entry adopts. On OCPP the basic-auth username *is* that
identity, so a listed charger presents `garage` / its own password, and the
shared credential no longer buys its name.

This is opt-in per charger: anything not listed keeps using the shared
username and password, so adding one entry does not lock the others out. A
charger you have not listed can still be impersonated by something holding the
shared password — list the ones that matter.

### TLS

```yaml
ocpp:
tls:
cert_file: /etc/ftw/ocpp/server.crt
key_file: /etc/ftw/ocpp/server.key
client_ca_file: /etc/ftw/ocpp/charger-ca.crt # optional
```

Chargers then dial `wss://` instead of `ws://`. Without it, basic auth over
`ws://` sends the credential unencrypted and anyone who can sniff your LAN can
read it.

`client_ca_file` additionally requires every charge point to present a
certificate signed by that CA — OCPP 2.0.1 security profile 3. It is the
strongest identity available here: unlike a password, it cannot be copied out
of one charger's configuration and replayed by another device unless the
private key was copied too.

Half a TLS section is refused at startup rather than quietly serving `ws://`.
An operator who asked for `wss://` and silently got plaintext would have no way
to tell the link was never encrypted.

## Pointing a charger at FTW

Expand Down Expand Up @@ -253,6 +321,28 @@ would strand a driver with an uncharged car because the EMS went down, and the
last limit was already judged safe for the site. This matches what every EV
driver in FTW already does.

### How the profile is shaped, and why

The limit goes out as a `TxDefaultProfile` with one schedule period at second
zero and no end: *hold this until I send another*. Two details of that are
load-bearing, and both come from chargers disagreeing with the specification
in ways that fail silently:

- **The profile kind is `Relative`, not `Absolute`.** An absolute schedule
states when it starts; FTW's has no start, and the specification says an
absolute schedule without one is relative to the start of charging anyway.
A charger that instead reads the missing timestamp as "not valid yet"
answers **Accepted** and charges on at full rate — the worst failure
available here, because FTW logs a limit it never imposed. Relative carries
no timestamp to misread, and does not depend on the charger's clock agreeing
with ours.
- **Connector 0, then connector 1.** A profile on connector 0 applies to every
connector, which avoids depending on per-connector ids — unreliable on
dual-socket units such as the Charge Amps Aura. Some chargers read the
connector-0 rule as `ChargePointMaxProfile`-only and reject it, so a refusal
is retried once on connector 1. Refusing both is reported as an error: a
charger FTW cannot steer must not look like one it can.

## One capacity, several cars: vehicle profiles

`vehicle_capacity_wh` on a charger entry describes the **one** car the charger
Expand Down Expand Up @@ -288,19 +378,48 @@ What "identifies" means depends on the dialect:
card, not the car — profiles work when each card lives permanently in one
car.
- **OCPP 2.0.1**: idTokens can name the actual vehicle — `MacAddress`
(autocharge) or `eMAID` (ISO 15118 Plug & Charge) — no card involved. With
ISO 15118 hardware, `NotifyEVChargingNeeds` can additionally state the
energy the car actually wants; consuming that is future work, tracked with
OCPP 2.1 in issue #835.
(autocharge) or `eMAID` (ISO 15118 Plug & Charge) — no card involved.

A wrong or missing capacity skews planning accuracy only, never safety — the
car's own BMS always protects it. An unprofiled car larger than the configured
capacity makes the SoC estimate rise too fast, so a target charge can stop
early; correct the SoC in the dashboard EV modal, or use Force start.

## When the car speaks for itself

With ISO 15118 hardware on OCPP 2.0.1 the car states its own needs, and the
charger forwards them as `NotifyEVChargingNeeds`. That outranks every figure
above: a profile and `vehicle_capacity_wh` are both an operator's estimate of
the car that usually parks here, this is the car actually plugged in.

What FTW takes from it, for the session only:

| 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 |

All of it reverts on plug-out, exactly like a 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 there is no fraction
to derive and the target is left alone — a guess there would feed the planner a
number the car never claimed. A departure time still applies.

The last report is shown in the Chargers tab and on `GET /api/ocpp/chargers` as
`charging_needs`. Quarantine applies as everywhere else: a pending charge
point's needs are visible so you can see what asked, and reach no loadpoint.

Most chargers never send this. It needs ISO 15118 on both the charger and the
car; without it, profiles and `vehicle_capacity_wh` remain the whole story.

## Current limits

- **No TLS**, and the listener cannot be pinned to one interface.
- **The socket cannot be pinned to one interface.** `bind` refuses the
handshake instead, so the port stays open on every interface even when only
one is served.
- Chargers that also have a native protocol may work better through a driver.
Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is
the option when you want the cloud out of the loop.
Expand Down
Loading