diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md new file mode 100644 index 00000000..e1d37ca7 --- /dev/null +++ b/.changeset/ocpp-central-system-restore.md @@ -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. diff --git a/.changeset/ocpp-v201-support.md b/.changeset/ocpp-v201-support.md new file mode 100644 index 00000000..59740198 --- /dev/null +++ b/.changeset/ocpp-v201-support.md @@ -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. diff --git a/README.md b/README.md index bf287419..62fcaf28 100644 --- a/README.md +++ b/README.md @@ -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 @@ -170,6 +171,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: @@ -193,6 +198,7 @@ metadata are the detailed reference. - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) +- [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) diff --git a/config.example.yaml b/config.example.yaml index 0999020f..65f785d3 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -171,6 +171,29 @@ 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://:8887/garage-left +# +# 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. +# +# 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. +ocpp: + enabled: false + 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 + # Persistent state (SQLite) state: path: state.db diff --git a/docs/ocpp.md b/docs/ocpp.md new file mode 100644 index 00000000..13686a8b --- /dev/null +++ b/docs/ocpp.md @@ -0,0 +1,204 @@ +# OCPP chargers + +FTW has a built-in OCPP Central System speaking **1.6J and 2.0.1**. An EV +charger that speaks either connects straight to FTW over your local network. + +**An OCPP charger does not need a driver.** There is no Lua file to write, no +entry in `drivers:`, and nothing to add to the device catalog. OCPP is a vendor- +neutral protocol, so one server in core handles every charger that speaks it — +Charge Amps, Easee, Zaptec, ABB, Alfen and the rest are all the same code path. + +This is the opposite of how the rest of FTW works. Every other device needs a +driver because every vendor invented its own protocol. OCPP is the standard that +makes drivers unnecessary, so the driver boundary does not apply. + +## How a charger becomes a device + +Drivers poll outward: FTW opens the connection and asks for data. OCPP runs the +other way — the charger dials FTW and pushes. + +So there is nothing to configure per charger. You point the charger at FTW, and +it appears as a device the moment it sends its first `BootNotification`. Its +identity is the last segment of the URL it connected to: + +``` +ws://:8887/garage-left + └── this becomes the device key +``` + +Give each charger a distinct identity segment. Reuse one and two chargers will +collapse into a single device. + +From there it behaves like any other EV reading: `MeterValues` and +`StatusNotification` become telemetry, and dispatch stops the home battery +discharging into an active EV charge. + +## Protocol versions + +| Version | Status | Port | +|---|---|---| +| 1.6J | supported | `port`, default 8887 | +| 2.0.1 | supported | `port_v201`, off unless set | +| 2.1 | not yet — see below | — | + +**Each version needs its own port.** A charger picks its dialect in the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so one port cannot serve both. Point +each charger at the port matching what it speaks. + +Everything above the protocol is shared. Both dialects land in the same charger +map, produce the same telemetry, and take the same commands, so a 2.0.1 charger +is throttled and paused exactly like a 1.6 one and nothing downstream knows the +difference. + +### On OCPP 2.1 + +2.1 is not supported, because no production-grade Go implementation of it +exists. `lorenzodonini/ocpp-go`, the library FTW uses and the only mature option +at 367 stars, implements 1.6 and 2.0.1 and has no 2.1 support. The Go projects +that do claim 2.1 are all early — single-digit stars, and mostly message +validators or emulators rather than servers. + +This is 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. + +Adding 2.1 later means writing one more handler file and one more listener. The +version-neutral core — charger state, telemetry mapping, control semantics — +does not change. + +## Where the code comes from + +The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) +v0.19.0, under the MIT license. It is an ordinary Go module dependency, pinned in +`go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied +or forked from it. + +| Layer | Owner | +|---|---| +| WebSocket transport, OCPP-J framing, message types, schema validation | ocpp-go | +| Handlers, telemetry mapping, control semantics, safety clamps | FTW, `go/internal/ocpp` | + +That split is the first thing to check when something misbehaves. A malformed +message or a dropped connection is upstream; a wrong power figure or a wrong +current limit is ours. + +Upstream health, as of this writing: 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". Treat the 2.0.1 path here as less proven than 1.6J +regardless of FTW's own tests. + +If FTW ever needs a fix upstream will not take, the move is a `srcfl/ocpp-go` +fork plus a `replace` directive in `go.mod` — still an ordinary module. A git +submodule is not an option: `go build` resolves dependencies through `go.mod` +and the module cache, so a submodule checkout would be inert unless paired with +that same `replace`, while additionally breaking `go install` and any clone +made without `--recurse-submodules`. To pin the source inside this repository +instead, the Go-native answer is `go mod vendor`, which commits the dependency +tree under `vendor/` and is understood by the toolchain without extra flags. + +## Enabling the server + +OCPP is off by default. Add an `ocpp` section: + +```yaml +ocpp: + enabled: true + port: 8887 # OCPP 1.6J, default 8887 + port_v201: 8888 # OCPP 2.0.1; omit or 0 to disable + path: / # default / + username: ftw + password: + heartbeat_interval_s: 60 +``` + +**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 + +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. + +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. + +So: + +- 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. + +Credentials being required is a mitigation, not a fix. Binding to one interface +needs a change to the upstream library. + +## Pointing a charger at FTW + +The backend URL is always `ws://:`. What differs +is how you reach the charger to set it. + +| Charger | Where you set it | Cloud needed? | +|---|---|---| +| Charge Amps Halo, Aura | WiFi hotspot → `192.168.250.1` → Settings → OCPP | no | +| Charge Amps Dawn, Luna | Charge Amps Installer app over Bluetooth → CPMS settings | no | +| Easee | Easee's commissioning API, once | one-time | +| Zaptec | Zaptec Portal, needs the `Allow OCPP 1.6J` permission | one-time | + +Easee and Zaptec need a one-time commissioning step through the vendor portal. +After that the charger talks only to FTW and the cloud is out of the runtime +path. Charge Amps needs no cloud at all. + +Two traps worth knowing: + +- **Charge Amps Bluetooth is only discoverable for 10 minutes after power-up.** + If the unit has been on longer, cut the fuse and re-energise before searching. +- **Zaptec appends the charger serial to the URL itself.** Enter the URL without + it. + +For the full commissioning and factory-reset detail per model, see the bench +guide in the device-drivers repository. + +## Control + +FTW throttles, pauses and resumes an OCPP charger the same way it steers any +other EV charger. Loadpoints do not know the difference. + +Every command is expressed as a **current limit**, never as a remote start or +stop. Pausing means "you may draw zero amps"; resuming raises the limit again. + +That is not a stylistic choice. `RemoteStopTransaction` is unreliable on Charge +Amps hardware — units acknowledge the stop and then resume charging on their +own. A charging profile of 0 A is honoured consistently. It also leaves the +transaction open, so the session meter keeps counting across a pause instead of +being split into two sessions. + +Two limits worth knowing: + +- **Below 6 A, FTW sends 0 A.** IEC 61851 has no duty cycle under 6 A. When the + allocator has less headroom than that to give, rounding up would draw current + the site fuse was never asked to carry, so charging stops instead. +- **A pause remembers the previous rate.** Resuming returns to the last non-zero + limit, not to the maximum. + +If FTW loses contact, the charger holds its last granted limit. Dropping to zero +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. + +## Current limits + +- **No TLS**, and the listener cannot be pinned to one interface. +- 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. + +## When you still need a driver + +Only for chargers that do not speak OCPP, or where a vendor protocol exposes +something OCPP does not. The Ambibox V2G / InterControl ambiCHARGE is the +example on the bench: it is DC-coupled and bidirectional, runs over MQTT, and +uses the `ambibox_v2x` driver. + +See [writing-a-driver.md](writing-a-driver.md) for that path. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index 7a2628b3..e2dbc094 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -11,6 +11,11 @@ exact reviewed commit and does not own a second editable source. The `drivers/` tree here is the bundled FTW recovery snapshot; operator-only drivers may still live locally. +> **An EV charger that speaks OCPP does not need a driver.** FTW has a built-in +> OCPP Central System serving 1.6J and 2.0.1, and one server in core handles +> every charger that speaks the protocol. Point the charger at FTW and it +> registers itself. See [ocpp.md](ocpp.md) before writing anything. + ## Metadata Every driver declares one authoritative catalog block: diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 6b989e5e..bca8f254 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -49,6 +49,7 @@ import ( mqttcli "github.com/srcfl/ftw/go/internal/mqtt" "github.com/srcfl/ftw/go/internal/notifications" "github.com/srcfl/ftw/go/internal/nova" + "github.com/srcfl/ftw/go/internal/ocpp" "github.com/srcfl/ftw/go/internal/priceforecast" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/proxy" @@ -1102,6 +1103,38 @@ func main() { slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) } + // ---- Start OCPP Central System, 1.6J and 2.0.1 (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point becomes a device in tel the moment it + // sends its first BootNotification, keyed by the identity segment of the + // URL it connected to, and dispatch picks it up from there like any other + // EV reading. + var ocppSrv *ocpp.Server + if cfg.OCPP != nil && cfg.OCPP.Enabled { + srv, err := ocpp.Start(ctx, &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Port: cfg.OCPP.Port, + PortV201: cfg.OCPP.PortV201, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + }, tel) + if err != nil { + // A charger that cannot reach us is a missing device, not a + // broken site, so keep the rest of the process running. + slog.Error("ocpp: central system failed to start", "err", err) + } else { + ocppSrv = srv + defer ocppSrv.Stop() + slog.Info("ocpp: central system started", + "port", ocppSrv.Port(), + "port_v201", cfg.OCPP.PortV201, + "path", ocppSrv.Path(), + "note", "listener is reachable on every interface; basic auth is the only gate") + } + } + // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) if mpcSvc != nil { @@ -1504,7 +1537,20 @@ func main() { RequestActive: reqActive, }, true } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send) + // An OCPP charge point is not in the driver registry — it connected to + // us rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the + // Lua driver registry. Loadpoints stay unaware of the difference. + send := reg.Send + if ocppSrv != nil { + send = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) // Wire the site fuse so the per-phase EV clamp and the // phase-split derivation can use the actual site voltage and // breaker rating instead of hard-coding 230 V × 16 A. diff --git a/go/go.mod b/go/go.mod index 697b6962..dac6cf13 100644 --- a/go/go.mod +++ b/go/go.mod @@ -11,6 +11,7 @@ require ( github.com/goburrow/serial v0.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/lorenzodonini/ocpp-go v0.19.0 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/parquet-go/parquet-go v0.29.0 github.com/shirou/gopsutil/v4 v4.26.3 @@ -27,11 +28,15 @@ require ( github.com/ebitengine/purego v0.10.0 // indirect github.com/fxamacker/cbor/v2 v2.9.2 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/klauspost/compress v1.17.11 // indirect + github.com/leodido/go-urn v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -40,6 +45,7 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.4.0 // indirect github.com/teambition/rrule-go v1.8.2 // indirect @@ -53,7 +59,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/go-playground/validator.v9 v9.30.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go/go.sum b/go/go.sum index 96f0b704..91fe4e9a 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,11 +1,16 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -26,6 +31,10 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= @@ -47,6 +56,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -57,10 +68,18 @@ github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/lorenzodonini/ocpp-go v0.19.0 h1:THNriVV3bUWkGzaIkDeytcgoeBRjoN9ezPHsddIDfgc= +github.com/lorenzodonini/ocpp-go v0.19.0/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -83,6 +102,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -93,6 +114,14 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/simonvetter/modbus v1.6.4 h1:E03lBz/JftDza/+Ue+vxwkNZ/WW1xiqyFCUQ4NhqHn0= github.com/simonvetter/modbus v1.6.4/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= @@ -123,8 +152,10 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -136,6 +167,11 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 074d84e8..9d8b52e8 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -40,6 +40,57 @@ type Config struct { HomeLink *HomeLink `yaml:"home_link,omitempty" json:"home_link,omitempty"` Nova *Nova `yaml:"nova,omitempty" json:"nova,omitempty"` DeviceRepository *DeviceRepository `yaml:"device_repository,omitempty" json:"device_repository,omitempty"` + OCPP *OCPP `yaml:"ocpp,omitempty" json:"ocpp,omitempty"` +} + +// OCPP configures the built-in OCPP Central System, which serves 1.6J and +// 2.0.1. Chargers connect to us, so there is no driver and no per-charger +// config entry — a charge point appears as a device the moment it sends its +// first BootNotification, keyed by the identity segment of the URL it dialled. +// +// Each version needs its own port: a charger picks its dialect during the +// WebSocket handshake, and the OCPP library keeps one message handler per +// listener, so a single port cannot serve both. +// +// Disabled by default, and enabling it requires credentials. The listener +// cannot be restricted to one interface: the OCPP library builds its own +// listen address from the port alone, so the socket is reachable on every +// interface the host has. Basic auth is the only thing standing in front of +// it, which is why an empty Username or Password is rejected rather than +// silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + PortV201 int `yaml:"port_v201,omitempty" json:"port_v201,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` +} + +// Validate rejects an enabled server that would accept anonymous charge +// points. A nil or disabled section is fine — OCPP is opt-in. +func (o *OCPP) Validate() error { + if o == nil || !o.Enabled { + return nil + } + if o.Username == "" || o.Password == "" { + return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) + } + if o.PortV201 < 0 || o.PortV201 > 65535 { + return fmt.Errorf("ocpp.port_v201 must be between 0 and 65535, got %d", o.PortV201) + } + // Each version needs its own listener, so they cannot share a port. + if o.PortV201 > 0 && o.PortV201 == o.Port { + return fmt.Errorf("ocpp.port_v201 must differ from ocpp.port, both are %d", o.Port) + } + if o.HeartbeatIntervalS < 0 { + return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) + } + return nil } // HomeLink enables the outbound-only encrypted remote read service. The relay @@ -1031,6 +1082,13 @@ func (c Config) MaskSecrets() Config { cp.Password = "" out.HomeAssistant = &cp } + // The OCPP password is the only thing standing in front of a listener that + // is reachable on every interface, so it must never leave over the API. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + out.OCPP = &cp + } if out.Price != nil { cp := *out.Price cp.APIKey = "" @@ -1103,6 +1161,12 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { incoming.CalDAV.Password = existing.CalDAV.Password } + // Masked out on the way to the UI, so an unchanged password comes back + // empty. Without this a save from the settings tab would blank it, and an + // enabled server would then fail validation on the next reload. + if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password } @@ -1518,6 +1582,9 @@ func (c *Config) Validate() error { if err := c.CalDAV.Validate(); err != nil { return err } + if err := c.OCPP.Validate(); err != nil { + return err + } // Empty drivers list is a valid shape — e.g. an EV-only site that // configured a cloud EV charger in the setup wizard and doesn't diff --git a/go/internal/config/example_config_test.go b/go/internal/config/example_config_test.go new file mode 100644 index 00000000..c7d9e042 --- /dev/null +++ b/go/internal/config/example_config_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "testing" +) + +func TestExampleConfigParsesOCPP(t *testing.T) { + data, err := os.ReadFile("../../../config.example.yaml") + if err != nil { + t.Fatalf("read example: %v", err) + } + c, err := Parse(data, ".") + if err != nil { + t.Fatalf("parse example config: %v", err) + } + if c.OCPP == nil { + t.Fatal("example config has no ocpp section") + } + if c.OCPP.Enabled { + t.Error("example config must ship with ocpp disabled") + } + if c.OCPP.Port != 8887 { + t.Errorf("port: got %d want 8887", c.OCPP.Port) + } + if err := c.Validate(); err != nil { + t.Fatalf("example config must validate: %v", err) + } + t.Logf("ocpp parsed: enabled=%v port=%d path=%q heartbeat=%d", + c.OCPP.Enabled, c.OCPP.Port, c.OCPP.Path, c.OCPP.HeartbeatIntervalS) +} diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go new file mode 100644 index 00000000..abe403e3 --- /dev/null +++ b/go/internal/config/ocpp_test.go @@ -0,0 +1,144 @@ +package config + +import "testing" + +// The OCPP listener cannot be pinned to one interface — the library builds its +// listen address from the port alone. Basic auth is therefore the only thing +// between an enabled server and any host that can route to it, so an enabled +// section without credentials has to fail rather than start. +func TestOCPPValidate(t *testing.T) { + tests := []struct { + name string + ocpp *OCPP + wantErr bool + }{ + { + name: "absent section is fine", + ocpp: nil, + }, + { + name: "disabled without credentials is fine", + ocpp: &OCPP{Enabled: false}, + }, + { + name: "enabled without credentials is rejected", + ocpp: &OCPP{Enabled: true}, + wantErr: true, + }, + { + name: "enabled with only a username is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw"}, + wantErr: true, + }, + { + name: "enabled with only a password is rejected", + ocpp: &OCPP{Enabled: true, Password: "secret"}, + wantErr: true, + }, + { + name: "enabled with both is accepted", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret"}, + }, + { + name: "port out of range is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", Port: 70000}, + wantErr: true, + }, + { + name: "negative heartbeat is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", HeartbeatIntervalS: -1}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ocpp.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected an error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// The OCPP password is the only gate in front of a listener reachable on every +// interface. It must never be served over /api/config, and a settings save that +// returns the masked (empty) value must not wipe it. +func TestOCPPPasswordIsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "the-real-secret", + }} + + masked := stored.MaskSecrets() + if masked.OCPP == nil { + t.Fatal("masked config lost the ocpp section") + } + if masked.OCPP.Password != "" { + t.Errorf("password leaked through MaskSecrets: %q", masked.OCPP.Password) + } + if masked.OCPP.Username != "ftw" { + t.Errorf("username should survive masking, got %q", masked.OCPP.Username) + } + // Masking must not mutate the original. + if stored.OCPP.Password != "the-real-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Password) + } + + // The UI round-trips the masked config back on save. + incoming := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "", + }} + incoming.PreserveMaskedSecrets(stored) + if incoming.OCPP.Password != "the-real-secret" { + t.Errorf("password not preserved on save, got %q", incoming.OCPP.Password) + } + + // A genuinely new password must still win. + changed := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "a-new-secret", + }} + changed.PreserveMaskedSecrets(stored) + if changed.OCPP.Password != "a-new-secret" { + t.Errorf("new password was overwritten, got %q", changed.OCPP.Password) + } +} + +// A disabled or absent OCPP section must not stop the rest of the config from +// validating, and an enabled one without credentials must take the whole +// config down with it rather than being skipped. +func TestConfigValidateCoversOCPP(t *testing.T) { + // SmoothingAlpha and MaxAmps are normally filled in by defaults, which this + // test does not run; set them so the only thing under test is OCPP. + base := func() *Config { + return &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 20, Phases: 3, Voltage: 230}, + } + } + + c := base() + if err := c.Validate(); err != nil { + t.Fatalf("config without an ocpp section should validate, got %v", err) + } + + c = base() + c.OCPP = &OCPP{Enabled: true} + if err := c.Validate(); err == nil { + t.Fatal("config with an enabled, credential-less ocpp section should fail") + } + + c = base() + c.OCPP = &OCPP{Enabled: true, Username: "ftw", Password: "secret"} + if err := c.Validate(); err != nil { + t.Fatalf("config with a credentialed ocpp section should validate, got %v", err) + } +} diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go new file mode 100644 index 00000000..3b3533fe --- /dev/null +++ b/go/internal/ocpp/config.go @@ -0,0 +1,38 @@ +package ocpp + +// Config controls the OCPP 1.6J Central System. +// +// Bind to LAN-only addresses by default — there is no TLS in Phase 1. +// Charge points connect via ws://:/; the chargerId +// segment becomes the driver name in telemetry.Store and shows up in +// /api/devices and /api/status.drivers. +// +// NOTE: Bind is advisory-only — the ocpp-go library does not expose a +// bind-address parameter, so the listener currently binds to 0.0.0.0 +// regardless of this field. See the TODO in server.go. +type Config struct { + Enabled bool `yaml:"enabled"` + Bind string `yaml:"bind"` + Port int `yaml:"port"` + PortV201 int `yaml:"port_v201"` + Path string `yaml:"path"` + Username string `yaml:"username"` + Password string `yaml:"password"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s"` +} + +// Defaults fills in any unset fields with safe values. +func (c *Config) Defaults() { + if c.Bind == "" { + c.Bind = "0.0.0.0" + } + if c.Port == 0 { + c.Port = 8887 + } + if c.Path == "" { + c.Path = "/" + } + if c.HeartbeatIntervalS == 0 { + c.HeartbeatIntervalS = 60 + } +} diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go new file mode 100644 index 00000000..1e04937a --- /dev/null +++ b/go/internal/ocpp/control.go @@ -0,0 +1,372 @@ +package ocpp + +// Control for OCPP chargers. +// +// Command has the same shape as drivers.Registry.Send, so the loadpoint +// controller dispatches to an OCPP charger without knowing it is not a Lua +// driver. The command vocabulary is the one every EV driver already implements +// — ev_set_current, ev_pause, ev_start, ev_resume — so nothing upstream of here +// needs a special case. +// +// Everything is expressed as a current limit rather than a remote start/stop. +// That is deliberate: RemoteStopTransaction is unreliable in the field on +// Charge Amps hardware, where units acknowledge the stop and return to charging +// on their own. A charging profile of 0 A is honoured consistently, so pausing +// is "allow zero amps" rather than "end the transaction", and resuming raises +// the limit again. It also leaves the transaction open, so the session meter +// keeps accumulating across a pause instead of being split in two. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +const ( + // IEC 61851 does not allow a duty cycle below 6 A. Charging is either off + // or at least this much; anything between is refused by the vehicle or + // leaves it drawing an unpredictable current. + minChargeAmps = 6.0 + + // Fallback electrical assumptions, used only when the command omits them. + // A command from the loadpoint controller carries the real site values. + defaultVoltage = 230.0 + defaultPhases = 3 + + // Ceiling applied when a command asks to resume without saying how fast + // and we have never set a limit for this charger. + defaultMaxAmps = 16.0 + + // How long to wait for a charger to confirm a profile before giving up. + // Control runs on a tick; a command that has not landed by now is better + // reported as failed than left blocking the loop. + commandTimeout = 10 * time.Second + + // Charge-point-wide default profile. Connector 0 means "every connector", + // which avoids depending on per-connector ids — those are unreliable on + // dual-socket units such as the Charge Amps Aura. + allConnectors = 0 + + // The 2.0.1 equivalent: EVSE 0 addresses the whole charging station. + allEVSEs = 0 + + // A single, stable profile id and stack level means each new limit + // replaces the previous one instead of stacking on top of it. + ftwProfileID = 1 + // 2.0.1 schedules carry their own id; one stable id keeps replacement + // semantics identical to 1.6. + ftwScheduleID = 1 + ftwStackLevel = 0 + scheduleStartS = 0 +) + +// command is the JSON payload the loadpoint controller sends to EV drivers. +// Only the fields that affect a current limit are read here. +type command struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + Voltage float64 `json:"voltage"` + SitePhases int `json:"site_phases"` + MaxAmpsPerPhase float64 `json:"max_amps_per_phase"` + PhaseMode string `json:"phase_mode"` +} + +// ErrNotConnected is returned when a command targets a charge point that is not +// currently connected. Callers use it to tell "wrong name" apart from "charger +// is offline right now". +var ErrNotConnected = errors.New("ocpp: charger not connected") + +// Command applies an EV control command to a connected charge point. The +// signature matches drivers.Registry.Send so it can back a loadpoint +// SenderFunc directly. +func (s *Server) Command(ctx context.Context, id string, payload []byte) error { + if s == nil || s.cs == nil { + return errors.New("ocpp: server not running") + } + if !s.handler.IsOnline(id) { + return fmt.Errorf("%w: %s", ErrNotConnected, id) + } + + var c command + if err := json.Unmarshal(payload, &c); err != nil { + return fmt.Errorf("ocpp: bad command payload for %s: %w", id, err) + } + + switch c.Action { + case "init", "deinit": + // Lifecycle hooks that only mean something to a Lua VM. + return nil + + case "ev_set_current": + return s.setLimit(ctx, id, c.amps(), c.numberPhases()) + + case "ev_pause": + // Phase count is meaningless at zero amps. + return s.setLimit(ctx, id, 0, nil) + + case "ev_start", "ev_resume": + // A resume without a rate means "as fast as previously allowed". + amps := c.amps() + if amps <= 0 { + amps = s.handler.LastAmps(id, c.ceiling()) + } + return s.setLimit(ctx, id, amps, c.numberPhases()) + + default: + return fmt.Errorf("ocpp: unknown action %q for %s", c.Action, id) + } +} + +// ceiling is the highest per-phase current this command permits. +func (c command) ceiling() float64 { + if c.MaxAmpsPerPhase > 0 { + return c.MaxAmpsPerPhase + } + return defaultMaxAmps +} + +// amps converts the requested site power into a per-phase current limit. +// +// Below the IEC minimum the result is zero rather than the minimum: when the +// allocator has less than 6 A of headroom to give, rounding up would draw +// current the site fuse was not asked to carry. Refusing to charge is the safe +// direction of error. +func (c command) amps() float64 { + if c.PowerW <= 0 { + return 0 + } + + voltage := c.Voltage + if voltage <= 0 { + voltage = defaultVoltage + } + phases := c.SitePhases + if phases <= 0 { + phases = defaultPhases + } + if c.PhaseMode == "1p" { + phases = 1 + } + + amps := c.PowerW / (voltage * float64(phases)) + if ceiling := c.ceiling(); amps > ceiling { + amps = ceiling + } + if amps < minChargeAmps { + return 0 + } + return amps +} + +// numberPhases is what to declare in the schedule period, or nil to let the +// charger decide. Only a pinned single-phase command is worth stating. +func (c command) numberPhases() *int { + if c.PhaseMode == "1p" { + n := 1 + return &n + } + return nil +} + +// setLimit installs a charge-point-wide default profile capping the per-phase +// current, and blocks until the charger confirms it, the context ends, or the +// command times out. +func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPhases *int) error { + if amps < 0 { + amps = 0 + } + + // Buffered: the library's callback must never block if we have already + // stopped waiting. + done := make(chan profileResult, 1) + + // The two versions describe the same intent with different types, so the + // request is built per dialect and the outcome normalised back. + var err error + switch version, _ := s.handler.Version(id); version { + case Version201: + err = s.sendProfileV201(id, amps, numberPhases, done) + default: + err = s.sendProfileV16(id, amps, numberPhases, done) + } + if err != nil { + return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + } + + timeout := time.NewTimer(commandTimeout) + defer timeout.Stop() + + select { + case r := <-done: + if r.err != nil { + return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) + } + if !r.answered { + return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) + } + if !r.accepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.status) + } + // Only a real charging rate is worth remembering. Recording the zero + // from a pause would erase the rate a later resume is supposed to + // restore, and the charger would come back at the fallback ceiling + // instead of where it left off. + if amps > 0 { + s.handler.SetLastAmps(id, amps) + } + slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) + return nil + + case <-ctx.Done(): + return fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) + + case <-timeout.C: + return fmt.Errorf("ocpp: %s did not confirm charging profile within %s", id, commandTimeout) + } +} + +// profileResult is a version-neutral answer to a charging profile request, so +// the waiting code above does not need to know which dialect produced it. +type profileResult struct { + answered bool + accepted bool + status string + err error +} + +// sendProfileV16 issues the limit as an OCPP 1.6 TxDefaultProfile. +func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + return s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging.ChargingProfileStatusAccepted + } + done <- r + }, allConnectors, profile) +} + +// sendProfileV201 issues the same limit as an OCPP 2.0.1 TxDefaultProfile. +// +// 2.0.1 carries a list of schedules rather than one, and each schedule needs +// its own id; a single-entry list with a stable id keeps the meaning identical +// to the 1.6 request. +func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + if s.csms == nil { + return fmt.Errorf("ocpp: %s speaks %s but no %s listener is configured", id, Version201, Version201) + } + + period := types201.NewChargingSchedulePeriod(scheduleStartS, amps) + period.NumberPhases = numberPhases + schedule := types201.NewChargingSchedule(ftwScheduleID, types201.ChargingRateUnitAmperes, period) + profile := types201.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types201.ChargingProfilePurposeTxDefaultProfile, + types201.ChargingProfileKindAbsolute, + []types201.ChargingSchedule{*schedule}, + ) + + return s.csms.SetChargingProfile(id, func(conf *smartcharging201.SetChargingProfileResponse, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging201.ChargingProfileStatusAccepted + } + done <- r + }, allEVSEs, profile) +} + +// DefaultMode is what a charger is left in when FTW stops steering it, and +// mirrors the stance every EV driver already takes: hold the last limit. +// +// An EV charger has no autonomous self-consumption mode to fall back to, and +// dropping to zero would strand a driver with an uncharged car because the EMS +// lost contact. Holding the last granted current keeps the site within the +// envelope that was already judged safe. +func (s *Server) DefaultMode(_ context.Context, id string) error { + slog.Info("ocpp: leaving charger at its last granted limit", "charger", id) + return nil +} + +// IsOnline reports whether a charge point currently holds a WebSocket session. +// +// This is deliberately not the same as having a vehicle plugged in. A default +// charging profile is exactly the thing you set on an idle charger, so control +// gates on the session being live, not on the connector being occupied. +func (h *Handler) IsOnline(id string) bool { + if h == nil { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + return ok && s.online +} + +// LastAmps returns the last limit granted to a charger, or fallback if none +// has been set yet. +func (h *Handler) LastAmps(id string, fallback float64) float64 { + if h == nil { + return fallback + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.lastAmps <= 0 { + return fallback + } + return s.lastAmps +} + +// SetLastAmps records an accepted limit so a later resume can restore it. +func (h *Handler) SetLastAmps(id string, amps float64) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + return + } + s.lastAmps = amps +} + +// Names returns the charge points seen since start, connected or not. main.go +// uses it to decide whether a loadpoint driver name belongs to OCPP. +func (h *Handler) Names() []string { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, 0, len(h.chargers)) + for id := range h.chargers { + out = append(out, id) + } + return out +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go new file mode 100644 index 00000000..ee69cfed --- /dev/null +++ b/go/internal/ocpp/control_test.go @@ -0,0 +1,403 @@ +package ocpp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeCharger is a charge point that records the charging profiles it is sent +// and answers with a configurable status. +type fakeCharger struct { + mu sync.Mutex + status smartcharging.ChargingProfileStatus + profiles []*smartcharging.SetChargingProfileRequest +} + +func newFakeCharger() *fakeCharger { + return &fakeCharger{status: smartcharging.ChargingProfileStatusAccepted} +} + +func (f *fakeCharger) OnSetChargingProfile(req *smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging.NewSetChargingProfileConfirmation(status), nil +} + +func (f *fakeCharger) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) setStatus(s smartcharging.ChargingProfileStatus) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = s +} + +// lastLimit returns the amp limit from the most recent profile received. +func (f *fakeCharger) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil || p.ChargingProfile.ChargingSchedule == nil { + t.Fatal("charging profile had no schedule") + } + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// lastNumberPhases returns the declared phase count from the most recent +// profile, or nil when the charger was left to decide. +func (f *fakeCharger) lastNumberPhases(t *testing.T) *int { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].NumberPhases +} + +func (f *fakeCharger) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.profiles) +} + +// connectCharger brings up a charge point against the server and waits until +// the server has registered the session. +// +// The returned stop is idempotent: ocpp-go panics on a second Stop, and the +// cleanup below would otherwise fire after a test that disconnects on purpose. +func connectCharger(t *testing.T, srv *Server, port int, id string) (ocpp16.ChargePoint, *fakeCharger, func()) { + t.Helper() + fake := newFakeCharger() + cp := ocpp16.NewChargePoint(id, nil, nil) + cp.SetSmartChargingHandler(fake) + + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charge point connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cp.Stop) } + t.Cleanup(stop) + + if _, err := cp.BootNotification("Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return cp, fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered charger %s as online", id) + return nil, nil, nil +} + +func mustPayload(t *testing.T, m map[string]any) []byte { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return b +} + +// The whole point of Phase 2: a pause has to arrive as a 0 A limit, not as a +// RemoteStopTransaction, because Charge Amps units resume on their own after a +// remote stop. +func TestPauseSendsZeroAmpLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +func TestSetCurrentConvertsPowerToAmps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Below the IEC 61851 minimum the charger must be told zero, never a rounded-up +// 6 A the site fuse was not asked to carry. +func TestBelowMinimumBecomesZero(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 2000 W over 3 phases at 230 V ≈ 2.9 A per phase — under the minimum. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 2000.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("sub-minimum limit: got %v A, want 0 A", got) + } +} + +func TestMaxAmpsPerPhaseIsRespected(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 22 kW would be 32 A per phase, but the loadpoint allows only 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 22080.0, + "voltage": 230.0, + "site_phases": 3, + "max_amps_per_phase": 16.0, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("clamped limit: got %v A, want 16 A", got) + } +} + +// A pause must not lose the previous rate: resuming without one restores it. +func TestResumeRestoresLastLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + ctx := context.Background() + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Fatalf("pause limit: got %v A, want 0 A", got) + } + + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_resume", + })); err != nil { + t.Fatalf("resume: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("resumed limit: got %v A, want the previous 10 A", got) + } +} + +func TestRejectedProfileIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the charger rejects the profile") + } +} + +func TestUnknownChargerIsNotConnected(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + + err := srv.Command(context.Background(), "nobody-home", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected, got %v", err) + } +} + +// A charger that drops off must not silently swallow commands — control needs +// the error so it can fall back. +func TestDisconnectedChargerRejectsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, _, stop := connectCharger(t, srv, port, "garage-left") + + stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && srv.Handler().IsOnline("garage-left") { + time.Sleep(20 * time.Millisecond) + } + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected after disconnect, got %v", err) + } +} + +// init/deinit are Lua lifecycle hooks. They must be accepted and do nothing +// rather than reaching the charger or erroring. +func TestLifecycleActionsAreNoOps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + for _, action := range []string{"init", "deinit"} { + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": action, + })); err != nil { + t.Errorf("%s: unexpected error %v", action, err) + } + } + if n := fake.count(); n != 0 { + t.Errorf("lifecycle actions sent %d profiles, want 0", n) + } +} + +func TestUnknownActionIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "self_destruct", + })) + if err == nil { + t.Fatal("expected an error for an unknown action") + } + if n := fake.count(); n != 0 { + t.Errorf("unknown action sent %d profiles, want 0", n) + } +} + +// Single-phase mode changes the conversion: the same watts land on one phase. +func TestSinglePhaseModeUsesOnePhase(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 3680 W on one phase at 230 V = 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 3680.0, + "voltage": 230.0, + "site_phases": 3, + "phase_mode": "1p", + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("1p limit: got %v A, want 16 A", got) + } + if got := fake.lastNumberPhases(t); got == nil || *got != 1 { + t.Errorf("1p numberPhases: got %v, want 1", got) + } +} + +// With no phase mode pinned, the phase count is left unset so a charger that +// can switch phases keeps deciding for itself. +func TestThreePhaseLeavesPhaseCountUnset(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastNumberPhases(t); got != nil { + t.Errorf("numberPhases: got %v, want unset", *got) + } +} + +// The status handler marks a charger connected; a suspended charger is still +// commandable, which is what lets a paused session be resumed. +func TestSuspendedChargerStillAcceptsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + cp, fake, _ := connectCharger(t, srv, port, "garage-left") + + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusSuspendedEV); err != nil { + t.Fatalf("status: %v", err) + } + + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current on suspended charger: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("limit: got %v A, want 10 A", got) + } +} diff --git a/go/internal/ocpp/control_v201_test.go b/go/internal/ocpp/control_v201_test.go new file mode 100644 index 00000000..1b0887b9 --- /dev/null +++ b/go/internal/ocpp/control_v201_test.go @@ -0,0 +1,269 @@ +package ocpp + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeStationV201 is a 2.0.1 charging station that records the charging +// profiles it is sent. +type fakeStationV201 struct { + mu sync.Mutex + status smartcharging201.ChargingProfileStatus + profiles []*smartcharging201.SetChargingProfileRequest +} + +func newFakeStationV201() *fakeStationV201 { + return &fakeStationV201{status: smartcharging201.ChargingProfileStatusAccepted} +} + +func (f *fakeStationV201) OnSetChargingProfile(req *smartcharging201.SetChargingProfileRequest) (*smartcharging201.SetChargingProfileResponse, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging201.NewSetChargingProfileResponse(status), nil +} + +func (f *fakeStationV201) OnClearChargingProfile(*smartcharging201.ClearChargingProfileRequest) (*smartcharging201.ClearChargingProfileResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetChargingProfiles(*smartcharging201.GetChargingProfilesRequest) (*smartcharging201.GetChargingProfilesResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetCompositeSchedule(*smartcharging201.GetCompositeScheduleRequest) (*smartcharging201.GetCompositeScheduleResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("station received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil { + t.Fatal("request carried no charging profile") + } + if len(p.ChargingProfile.ChargingSchedule) == 0 { + t.Fatal("charging profile carried no schedule") + } + periods := p.ChargingProfile.ChargingSchedule[0].ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// startDualServer brings up both listeners on free ports. +func startDualServer(t *testing.T, tel *telemetry.Store) (portV16, portV201 int, srv *Server) { + t.Helper() + portV16 = freePort(t) + portV201 = freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: portV16, + PortV201: portV201, + HeartbeatIntervalS: 60, + } + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + + // Both listeners must be reachable before a client tries to connect. + for _, port := range []int{portV16, portV201} { + deadline := time.Now().Add(2 * time.Second) + bound := false + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + bound = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !bound { + t.Fatalf("listener never bound on port %d", port) + } + } + return portV16, portV201, srv +} + +// connectStationV201 brings up a 2.0.1 charging station and waits for the +// server to register it. +func connectStationV201(t *testing.T, srv *Server, port int, id string) (*fakeStationV201, func()) { + t.Helper() + fake := newFakeStationV201() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(fake) + + if err := cs.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charging station connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cs.Stop) } + t.Cleanup(stop) + + station := provisioning.ChargingStationType{Model: "Dawn", VendorName: "Charge Amps"} + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, station.Model, station.VendorName); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil, nil +} + +// A 2.0.1 station must be steerable exactly like a 1.6 one, and the server has +// to answer it in its own dialect. +func TestV201ChargerAcceptsCurrentLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, ok := srv.Handler().Version("garage-v201"); !ok || v != Version201 { + t.Fatalf("version: got %q ok=%v, want %q", v, ok, Version201) + } + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Pause is a 0 A limit on 2.0.1 too, not a remote stop. +func TestV201PauseSendsZeroAmpLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +// Both dialects served at once, each charger steered in its own, sharing one +// charger map and one telemetry store. +func TestBothVersionsServedSimultaneously(t *testing.T) { + portV16, portV201, srv := startDualServer(t, telemetry.NewStore()) + + _, fake16, _ := connectCharger(t, srv, portV16, "garage-v16") + fake201, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, _ := srv.Handler().Version("garage-v16"); v != Version16 { + t.Errorf("v16 charger version: got %q, want %q", v, Version16) + } + if v, _ := srv.Handler().Version("garage-v201"); v != Version201 { + t.Errorf("v201 charger version: got %q, want %q", v, Version201) + } + + ctx := context.Background() + payload := mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + }) + if err := srv.Command(ctx, "garage-v16", payload); err != nil { + t.Fatalf("v16 set current: %v", err) + } + if err := srv.Command(ctx, "garage-v201", payload); err != nil { + t.Fatalf("v201 set current: %v", err) + } + + if got := fake16.lastLimit(t); got != 10 { + t.Errorf("v16 limit: got %v A, want 10 A", got) + } + if got := fake201.lastLimit(t); got != 10 { + t.Errorf("v201 limit: got %v A, want 10 A", got) + } + + // The two must not have been confused for one another. + snap := srv.Handler().Snapshot() + if len(snap) != 2 { + t.Errorf("expected 2 chargers in the snapshot, got %d: %+v", len(snap), snap) + } +} + +// A rejection from a 2.0.1 station has to surface as an error, same as 1.6. +func TestV201RejectedProfileIsAnError(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + fake.mu.Lock() + fake.status = smartcharging201.ChargingProfileStatusRejected + fake.mu.Unlock() + + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the station rejects the profile") + } +} + +// Guard the version-neutral core: a 2.0.1 profile must be a single-entry +// schedule list with amps as the rate unit, matching the 1.6 request's meaning. +func TestV201ProfileShape(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + p := fake.profiles[len(fake.profiles)-1].ChargingProfile + if len(p.ChargingSchedule) != 1 { + t.Fatalf("schedules: got %d, want exactly 1", len(p.ChargingSchedule)) + } + if unit := p.ChargingSchedule[0].ChargingRateUnit; unit != types201.ChargingRateUnitAmperes { + t.Errorf("rate unit: got %q, want %q", unit, types201.ChargingRateUnitAmperes) + } + if p.ChargingProfilePurpose != types201.ChargingProfilePurposeTxDefaultProfile { + t.Errorf("purpose: got %q, want TxDefaultProfile", p.ChargingProfilePurpose) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go new file mode 100644 index 00000000..10ea458c --- /dev/null +++ b/go/internal/ocpp/handlers.go @@ -0,0 +1,305 @@ +package ocpp + +import ( + "encoding/json" + "log/slog" + "strconv" + "sync" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Handler implements ocpp1.6/core.CentralSystemHandler. One Handler is shared +// across every connected charger; per-charger state lives in the chargers +// map. All access is mutex-guarded — handler callbacks fire from the OCPP +// library's goroutines. +type Handler struct { + tel *telemetry.Store + heartbeatIntervalS int + + mu sync.Mutex + chargers map[string]*chargerState + nextTxID int +} + +// chargerState is what we accumulate from successive OCPP messages for one +// charge point. Survives the OCPP library's stateless handler invocations. +type chargerState struct { + // online is whether the charger currently holds a WebSocket session. + // Distinct from connected below, which tracks whether a connector has a + // vehicle on it. A charger can be online with nothing plugged in, and + // still needs to accept a default charging profile in that state. + online bool + connected bool + charging bool + transactionID int + sessionStartMeterWh float64 + sessionMeterWh float64 + lastPowerW float64 + // lastAmps is the most recent per-phase limit this charger accepted. + // A resume with no rate of its own restores it. + lastAmps float64 + // version is the OCPP dialect this charger connected with, which decides + // how commands are encoded on the way back. + version Version + // transactionRef is the 2.0.1 transaction id, which is a string rather + // than the int transactionID above. Empty on the 1.6 path. + transactionRef string +} + +// NewHandler returns a Handler ready to register with a CentralSystem. +// heartbeatIntervalS is what we tell each charger to use in the +// BootNotification confirmation. +func NewHandler(tel *telemetry.Store, heartbeatIntervalS int) *Handler { + return &Handler{ + tel: tel, + heartbeatIntervalS: heartbeatIntervalS, + chargers: map[string]*chargerState{}, + nextTxID: 1, + } +} + +// state returns the per-charger state, creating it lazily on first sight. +func (h *Handler) state(id string) *chargerState { + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// Snapshot returns a copy of all charger states for /api/status etc. +func (h *Handler) Snapshot() map[string]ChargerView { + h.mu.Lock() + defer h.mu.Unlock() + out := make(map[string]ChargerView, len(h.chargers)) + for id, s := range h.chargers { + out[id] = ChargerView{ + Connected: s.connected, + Charging: s.charging, + PowerW: s.lastPowerW, + SessionWh: s.sessionMeterWh, + TxID: s.transactionID, + } + } + return out +} + +// ChargerView is the public snapshot of a charger's state. +type ChargerView struct { + Connected bool `json:"connected"` + Charging bool `json:"charging"` + PowerW float64 `json:"power_w"` + SessionWh float64 `json:"session_wh"` + TxID int `json:"tx_id"` +} + +// OnConnect / OnDisconnect are wired by the Server to the OCPP library's +// connection callbacks, not part of CoreHandler. +func (h *Handler) OnConnect(id string) { + slog.Info("OCPP charger connected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.online = true + h.mu.Unlock() + h.tel.RecordDriverSuccess(id) +} + +func (h *Handler) OnDisconnect(id string) { + slog.Info("OCPP charger disconnected", "charger", id) + s := h.state(id) + h.mu.Lock() + s.online = false + s.connected = false + s.charging = false + s.lastPowerW = 0 + h.mu.Unlock() + // Push a zero so the dispatch clamp releases — otherwise the last known + // non-zero w would survive until staleness kicks in. + h.pushReading(id, s) +} + +// ---- core.CentralSystemHandler ---- + +func (h *Handler) OnBootNotification(id string, req *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + slog.Info("OCPP boot", + "charger", id, + "vendor", req.ChargePointVendor, + "model", req.ChargePointModel, + "fw", req.FirmwareVersion) + h.tel.RecordDriverSuccess(id) + return core.NewBootNotificationConfirmation( + types.NewDateTime(time.Now()), + h.heartbeatIntervalS, + core.RegistrationStatusAccepted, + ), nil +} + +func (h *Handler) OnHeartbeat(id string, _ *core.HeartbeatRequest) (*core.HeartbeatConfirmation, error) { + h.tel.RecordDriverSuccess(id) + return core.NewHeartbeatConfirmation(types.NewDateTime(time.Now())), nil +} + +func (h *Handler) OnAuthorize(id string, req *core.AuthorizeRequest) (*core.AuthorizeConfirmation, error) { + // Phase 1: auto-authorize every tag. RFID gating is a Phase 2 feature + // alongside RemoteStartTransaction. + slog.Debug("OCPP authorize", "charger", id, "tag", req.IdTag) + return core.NewAuthorizationConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + ), nil +} + +func (h *Handler) OnDataTransfer(id string, req *core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + // Vendor-specific extensions — accept gracefully but do nothing. + slog.Debug("OCPP DataTransfer ignored", "charger", id, "vendor", req.VendorId) + return core.NewDataTransferConfirmation(core.DataTransferStatusUnknownVendorId), nil +} + +func (h *Handler) OnStatusNotification(id string, req *core.StatusNotificationRequest) (*core.StatusNotificationConfirmation, error) { + s := h.state(id) + h.mu.Lock() + switch req.Status { + case core.ChargePointStatusAvailable, core.ChargePointStatusUnavailable: + s.connected = false + s.charging = false + case core.ChargePointStatusPreparing, + core.ChargePointStatusFinishing, + core.ChargePointStatusSuspendedEV, + core.ChargePointStatusSuspendedEVSE, + core.ChargePointStatusReserved: + s.connected = true + s.charging = false + case core.ChargePointStatusCharging: + s.connected = true + s.charging = true + case core.ChargePointStatusFaulted: + s.connected = true + s.charging = false + } + h.mu.Unlock() + + if req.Status == core.ChargePointStatusFaulted { + h.tel.EmitMetric(id, "ev_fault", 1, "", "", "") + slog.Warn("OCPP charger faulted", "charger", id, "errorCode", req.ErrorCode, "info", req.Info) + } + slog.Info("OCPP status", + "charger", id, "connector", req.ConnectorId, "status", req.Status) + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStatusNotificationConfirmation(), nil +} + +func (h *Handler) OnMeterValues(id string, req *core.MeterValuesRequest) (*core.MeterValuesConfirmation, error) { + s := h.state(id) + h.mu.Lock() + for _, mv := range req.MeterValue { + for _, sv := range mv.SampledValue { + measurand := sv.Measurand + // OCPP 1.6 default measurand if unspecified. + if measurand == "" { + measurand = types.MeasurandEnergyActiveImportRegister + } + val, err := strconv.ParseFloat(sv.Value, 64) + if err != nil { + continue + } + switch measurand { + case types.MeasurandPowerActiveImport: + if sv.Unit == types.UnitOfMeasureKW { + val *= 1000 + } + s.lastPowerW = val + case types.MeasurandEnergyActiveImportRegister: + if sv.Unit == types.UnitOfMeasureKWh { + val *= 1000 + } + if s.transactionID >= 0 { + s.sessionMeterWh = val - s.sessionStartMeterWh + } + } + } + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewMeterValuesConfirmation(), nil +} + +func (h *Handler) OnStartTransaction(id string, req *core.StartTransactionRequest) (*core.StartTransactionConfirmation, error) { + h.mu.Lock() + txID := h.nextTxID + h.nextTxID++ + s := h.chargersLocked(id) + s.transactionID = txID + s.sessionStartMeterWh = float64(req.MeterStart) + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + h.mu.Unlock() + + slog.Info("OCPP transaction started", + "charger", id, "txid", txID, "tag", req.IdTag, "meter_start_wh", req.MeterStart) + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return core.NewStartTransactionConfirmation( + types.NewIdTagInfo(types.AuthorizationStatusAccepted), + txID, + ), nil +} + +func (h *Handler) OnStopTransaction(id string, req *core.StopTransactionRequest) (*core.StopTransactionConfirmation, error) { + s := h.state(id) + h.mu.Lock() + sessionWh := float64(req.MeterStop) - s.sessionStartMeterWh + s.transactionID = -1 + s.charging = false + s.lastPowerW = 0 + s.sessionMeterWh = sessionWh + h.mu.Unlock() + + slog.Info("OCPP transaction stopped", + "charger", id, "txid", req.TransactionId, + "session_wh", sessionWh, "reason", req.Reason) + h.pushReading(id, s) + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + h.tel.RecordDriverSuccess(id) + return core.NewStopTransactionConfirmation(), nil +} + +// chargersLocked is the same as state(id) but assumes h.mu is already held. +func (h *Handler) chargersLocked(id string) *chargerState { + s, ok := h.chargers[id] + if !ok { + s = &chargerState{transactionID: -1} + h.chargers[id] = s + } + return s +} + +// pushReading pushes the current state as a DerEV reading. The dispatch +// clamp (control/dispatch.go) sums all DerEV readings into state.EVChargingW +// every tick — so the charger's lastPowerW immediately suppresses home +// battery discharge. +func (h *Handler) pushReading(id string, s *chargerState) { + h.mu.Lock() + w := s.lastPowerW + data := map[string]any{ + "type": "ev", + "w": w, + "connected": s.connected, + "charging": s.charging, + "session_wh": s.sessionMeterWh, + } + h.mu.Unlock() + blob, _ := json.Marshal(data) + h.tel.Update(id, telemetry.DerEV, w, nil, blob) +} diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go new file mode 100644 index 00000000..1e6a1c03 --- /dev/null +++ b/go/internal/ocpp/handlers_v201.go @@ -0,0 +1,252 @@ +package ocpp + +// OCPP 2.0.1 CSMS handlers. +// +// These translate 2.0.1 messages into the same charger state and DerEV +// telemetry the 1.6 handlers produce, so everything downstream — dispatch, the +// loadpoint controller, control commands — is unaware of which dialect a +// charger speaks. +// +// The shapes differ more than the names suggest: +// +// - Start/StopTransaction collapse into a single TransactionEvent with a +// Started / Updated / Ended trigger, and the transaction id is a string +// rather than an int. +// - StatusNotification reports per-EVSE connector status with a different +// enum, and no longer carries the "charging" meaning — that now comes from +// the transaction event. +// - Meter samples arrive inside TransactionEvent as well as in MeterValues. +// +// Everything not needed to meter and steer a charger is acknowledged and +// dropped. Accepting a message we ignore is correct here: refusing it would +// make the charger retry forever. + +import ( + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/authorization" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/availability" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/meter" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/transactions" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// handlerV201 adapts a Handler to the 2.0.1 CSMS interfaces. It owns no state +// of its own — everything lands in the shared Handler. +type handlerV201 struct { + *Handler +} + +// ---- provisioning.CSMSHandler ---- + +func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotificationRequest) (*provisioning.BootNotificationResponse, error) { + vendor, model, serial := "", "", "" + if req != nil { + vendor = req.ChargingStation.VendorName + model = req.ChargingStation.Model + serial = req.ChargingStation.SerialNumber + } + slog.Info("OCPP boot", + "charger", id, "version", Version201, + "vendor", vendor, "model", model, "serial", serial) + + h.setVersion(id, Version201) + h.tel.RecordDriverSuccess(id) + + return provisioning.NewBootNotificationResponse( + types201.NewDateTime(time.Now()), + h.heartbeatIntervalS, + provisioning.RegistrationStatusAccepted, + ), nil +} + +// OnNotifyReport carries variable inventory. FTW does not model charger +// configuration, so this is acknowledged and dropped. +func (h *handlerV201) OnNotifyReport(id string, _ *provisioning.NotifyReportRequest) (*provisioning.NotifyReportResponse, error) { + h.tel.RecordDriverSuccess(id) + return provisioning.NewNotifyReportResponse(), nil +} + +// ---- availability.CSMSHandler ---- + +func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + h.tel.RecordDriverSuccess(id) + return availability.NewHeartbeatResponse(*types201.NewDateTime(time.Now())), nil +} + +// OnStatusNotification maps 2.0.1 connector status onto the same +// connected/charging pair the 1.6 path produces. +// +// Unlike 1.6 there is no Charging status here — occupancy is all this tells us, +// and whether energy is actually flowing comes from the transaction event. +func (h *handlerV201) OnStatusNotification(id string, req *availability.StatusNotificationRequest) (*availability.StatusNotificationResponse, error) { + s := h.state(id) + h.mu.Lock() + switch req.ConnectorStatus { + case availability.ConnectorStatusAvailable, availability.ConnectorStatusUnavailable: + s.connected = false + s.charging = false + s.lastPowerW = 0 + case availability.ConnectorStatusOccupied, availability.ConnectorStatusReserved: + s.connected = true + case availability.ConnectorStatusFaulted: + // Matches the 1.6 path: a faulted connector still has a cable in it, + // so it stays connected while charging stops. + s.connected = true + s.charging = false + s.lastPowerW = 0 + } + faulted := req.ConnectorStatus == availability.ConnectorStatusFaulted + h.mu.Unlock() + + slog.Info("OCPP status", + "charger", id, "version", Version201, + "evse", req.EvseID, "connector", req.ConnectorID, "status", req.ConnectorStatus) + + if faulted { + h.tel.RecordDriverError(id, "ocpp: connector faulted") + } else { + h.tel.RecordDriverSuccess(id) + } + h.pushReading(id, s) + return availability.NewStatusNotificationResponse(), nil +} + +// ---- transactions.CSMSHandler ---- + +// OnTransactionEvent replaces 1.6's StartTransaction, StopTransaction and much +// of MeterValues. The trigger says which of those it stands in for. +func (h *handlerV201) OnTransactionEvent(id string, req *transactions.TransactionEventRequest) (*transactions.TransactionEventResponse, error) { + s := h.state(id) + + // Meter samples ride along with every event type. + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + switch req.EventType { + case transactions.TransactionEventStarted: + // 2.0.1 transaction ids are strings; the shared state keeps an int for + // the 1.6 path, so record presence rather than the id itself and keep + // the real one alongside. + h.nextTxID++ + s.transactionID = h.nextTxID + s.transactionRef = req.TransactionInfo.TransactionID + s.sessionStartMeterWh = energyWh + s.sessionMeterWh = 0 + s.connected = true + s.charging = true + + case transactions.TransactionEventUpdated: + s.connected = true + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + // A zero power sample during a live transaction is a genuine pause, + // not a missing reading, so it is taken at face value. + s.charging = powerW > 0 + + case transactions.TransactionEventEnded: + if hasEnergy { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + s.transactionID = -1 + s.transactionRef = "" + s.charging = false + s.lastPowerW = 0 + powerW = 0 + } + + if req.EventType != transactions.TransactionEventEnded { + s.lastPowerW = powerW + } + sessionWh := s.sessionMeterWh + ended := req.EventType == transactions.TransactionEventEnded + h.mu.Unlock() + + slog.Info("OCPP transaction event", + "charger", id, "version", Version201, + "event", req.EventType, "seq", req.SequenceNo, "w", powerW) + + h.pushReading(id, s) + if ended { + h.tel.EmitMetric(id, "ev_session_wh", sessionWh, "Wh", "", "") + } + h.tel.RecordDriverSuccess(id) + + return transactions.NewTransactionEventResponse(), nil +} + +// ---- meter.CSMSHandler ---- + +func (h *handlerV201) OnMeterValues(id string, req *meter.MeterValuesRequest) (*meter.MeterValuesResponse, error) { + s := h.state(id) + powerW, energyWh, hasEnergy := sampledValuesV201(req.MeterValue) + + h.mu.Lock() + s.lastPowerW = powerW + if hasEnergy && s.transactionID >= 0 { + s.sessionMeterWh = energyWh - s.sessionStartMeterWh + } + h.mu.Unlock() + + h.pushReading(id, s) + h.tel.RecordDriverSuccess(id) + return meter.NewMeterValuesResponse(), nil +} + +// ---- authorization.CSMSHandler ---- + +// OnAuthorize accepts every token. FTW is a home energy manager, not an access +// control system: the charger is behind the operator's own front door, and +// refusing here would only stop them charging. Matches the 1.6 path. +func (h *handlerV201) OnAuthorize(id string, _ *authorization.AuthorizeRequest) (*authorization.AuthorizeResponse, error) { + h.tel.RecordDriverSuccess(id) + return authorization.NewAuthorizationResponse(types201.IdTokenInfo{ + Status: types201.AuthorizationStatusAccepted, + }), nil +} + +// sampledValuesV201 pulls active-import power and energy out of a 2.0.1 meter +// value set, normalising kW/kWh to W/Wh. +// +// 2.0.1 always states the measurand, so unlike 1.6 there is no default to +// assume. hasEnergy distinguishes "no energy sample in this batch" from a +// genuine zero reading, which matters because session energy is a difference +// against the transaction's starting register. +func sampledValuesV201(values []types201.MeterValue) (powerW, energyWh float64, hasEnergy bool) { + for _, mv := range values { + for _, sv := range mv.SampledValue { + val := sv.Value + switch sv.Measurand { + case types201.MeasurandPowerActiveImport: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + powerW = val + case types201.MeasurandEnergyActiveImportRegister: + if unitIsKilo(sv.UnitOfMeasure) { + val *= 1000 + } + energyWh = val + hasEnergy = true + } + } + } + return powerW, energyWh, hasEnergy +} + +// unitIsKilo reports whether a sample is expressed in kW or kWh. An absent unit +// means the 2.0.1 default, which is already W/Wh. +func unitIsKilo(u *types201.UnitOfMeasure) bool { + if u == nil { + return false + } + switch u.Unit { + case "kW", "kWh": + return true + default: + return false + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go new file mode 100644 index 00000000..f5f0b1d5 --- /dev/null +++ b/go/internal/ocpp/server.go @@ -0,0 +1,202 @@ +// Package ocpp is the OCPP Central System for FTW, speaking 1.6J and 2.0.1. +// +// EV chargers connect to us via WebSocket. Every BootNotification, MeterValues, +// StatusNotification and transaction message becomes a DerEV reading in +// telemetry.Store, keyed by the charge point identity from the URL path. The +// dispatch layer sums DerEV readings and stops home batteries discharging into +// an active EV charge. Control goes the other way as charging profiles; see +// control.go for why never as a remote stop. +// +// # Provenance +// +// The protocol layer is github.com/lorenzodonini/ocpp-go v0.19.0 (MIT). It is a +// third-party dependency resolved through go.mod like any other — nothing in +// this package is copied or forked from it. It owns the WebSocket transport, +// OCPP-J framing, message types and schema validation. This package owns the +// handlers, the telemetry mapping, the control semantics and the safety clamps. +// +// The split matters when reading a bug: a malformed-message or transport +// failure is upstream, a wrong power figure or a wrong current limit is ours. +// +// Upstream 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 in this package. Upstream has cut no release +// since August 2025 and implements no OCPP 2.1. +package ocpp + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP Central System, serving one listener per enabled +// protocol version. +// +// Each version needs its own port. The OCPP library's ws.Server keeps a single +// message handler, so one listener cannot dispatch both dialects, and a charger +// picks its dialect in the WebSocket handshake before any message is sent. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + csms ocpp201.CSMS + handler *Handler + // done closes when the 1.6 listener goroutine exits; doneV201 likewise for + // 2.0.1. A nil channel means that version was not enabled. + done chan struct{} + doneV201 chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + h.OnConnect(cp.ID()) + // Which listener a charger reached is what identifies its dialect, so + // record it here rather than inferring it from a later message. + h.setVersion(cp.ID(), Version16) + }) + cs.SetChargePointDisconnectedHandler(func(cp ocpp16.ChargePointConnection) { + h.OnDisconnect(cp.ID()) + }) + + s := &Server{cfg: cfg, cs: cs, handler: h, done: make(chan struct{})} + + // OCPP 2.0.1 on its own port, when configured. Same handler and therefore + // the same charger state and telemetry — only the message encoding differs. + if cfg.PortV201 > 0 { + wsServer201 := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer201.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + h201 := &handlerV201{Handler: h} + csms := ocpp201.NewCSMS(nil, wsServer201) + csms.SetProvisioningHandler(h201) + csms.SetAvailabilityHandler(h201) + csms.SetTransactionsHandler(h201) + csms.SetMeterHandler(h201) + csms.SetAuthorizationHandler(h201) + csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnConnect(cs.ID()) + h.setVersion(cs.ID(), Version201) + }) + csms.SetChargingStationDisconnectedHandler(func(cs ocpp201.ChargingStationConnection) { + h.OnDisconnect(cs.ID()) + }) + + s.csms = csms + s.doneV201 = make(chan struct{}) + go func() { + defer close(s.doneV201) + slog.Info("OCPP central system listening", + "version", Version201, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", cfg.Username != "") + csms.Start(cfg.PortV201, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + } + + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { + s.cs.Stop() + if s.csms != nil { + s.csms.Stop() + } + }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version16) + } + if s.doneV201 != nil { + select { + case <-s.doneV201: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version201) + } + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} diff --git a/go/internal/ocpp/server_test.go b/go/internal/ocpp/server_test.go new file mode 100644 index 00000000..05eb88c7 --- /dev/null +++ b/go/internal/ocpp/server_test.go @@ -0,0 +1,188 @@ +package ocpp + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// freePort returns a port the kernel just allocated and immediately gave back. +// Cheaper than racing a hardcoded port across parallel test runs. +func freePort(t *testing.T) int { + t.Helper() + l, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("free port: %v", err) + } + defer l.Close() + return l.Addr().(*net.TCPAddr).Port +} + +// startServer brings up an OCPP CS on a free port and returns the port + a +// matching ws://… URL plus the running server. Caller must defer Stop. +func startServer(t *testing.T, tel *telemetry.Store) (int, *Server) { + t.Helper() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, HeartbeatIntervalS: 60} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + // Wait for the listener to be reachable. cs.Start launches goroutines — + // without a tiny pause the client will racily connect to nothing. + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + return port, srv + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not bind on port %d within deadline", port) + return 0, nil +} + +func TestStopIsConcurrentAndIdempotent(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + + var wg sync.WaitGroup + for range 4 { + wg.Add(1) + go func() { + defer wg.Done() + srv.Stop() + }() + } + wg.Wait() + srv.Stop() +} + +func TestBootAndMeterValuesPushDerEV(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH123456", nil, nil) + url := fmt.Sprintf("ws://127.0.0.1:%d", port) + if err := cp.Start(url); err != nil { + t.Fatalf("client connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("EaseeHome", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + + // Charging status — handler should mark connected+charging. + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusCharging); err != nil { + t.Fatalf("status: %v", err) + } + + // MeterValues with a 7200 W power sample. + mv := []types.MeterValue{{ + Timestamp: types.NewDateTime(time.Now()), + SampledValue: []types.SampledValue{ + { + Value: "7200", + Measurand: types.MeasurandPowerActiveImport, + Unit: types.UnitOfMeasureW, + }, + }, + }} + if _, err := cp.MeterValues(1, mv); err != nil { + t.Fatalf("meter values: %v", err) + } + + // Allow the handler goroutine to flush. + deadline := time.Now().Add(2 * time.Second) + var r *telemetry.DerReading + for time.Now().Before(deadline) { + r = tel.Get("EH123456", telemetry.DerEV) + if r != nil && r.RawW > 0 { + break + } + time.Sleep(50 * time.Millisecond) + } + if r == nil { + t.Fatal("expected DerEV reading for EH123456, got nil") + } + if r.RawW != 7200 { + t.Errorf("expected 7200 W, got %f", r.RawW) + } + + view := srv.Handler().Snapshot()["EH123456"] + if !view.Connected || !view.Charging { + t.Errorf("expected connected+charging, got %+v", view) + } +} + +func TestStartStopTransactionTracksSession(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel) + defer srv.Stop() + + cp := ocpp16.NewChargePoint("EH-SESSION", nil, nil) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + defer cp.Stop() + + if _, err := cp.BootNotification("Home", "Easee"); err != nil { + t.Fatalf("boot: %v", err) + } + startConf, err := cp.StartTransaction(1, "RFID-ABCD", 1000, types.NewDateTime(time.Now())) + if err != nil { + t.Fatalf("start tx: %v", err) + } + if startConf.IdTagInfo.Status != types.AuthorizationStatusAccepted { + t.Errorf("expected accepted, got %s", startConf.IdTagInfo.Status) + } + + if _, err := cp.StopTransaction(8500, types.NewDateTime(time.Now()), startConf.TransactionId); err != nil { + t.Fatalf("stop tx: %v", err) + } + + // Session energy = 8500 - 1000 = 7500 Wh + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()["EH-SESSION"] + if v.SessionWh == 7500 { + return + } + time.Sleep(50 * time.Millisecond) + } + view := srv.Handler().Snapshot()["EH-SESSION"] + t.Errorf("expected session_wh=7500, got %+v", view) +} + +func TestBasicAuthRejectsWrongCredentials(t *testing.T) { + tel := telemetry.NewStore() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, Username: "easee", Password: "secret"} + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + defer srv.Stop() + time.Sleep(100 * time.Millisecond) + + wsClient := ws.NewClient() + wsClient.SetBasicAuth("easee", "wrong-password") + cp := ocpp16.NewChargePoint("EH-AUTH", nil, wsClient) + err = cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)) + if err == nil { + cp.Stop() + t.Error("expected auth failure with wrong password, got nil") + } +} diff --git a/go/internal/ocpp/version.go b/go/internal/ocpp/version.go new file mode 100644 index 00000000..20a129a5 --- /dev/null +++ b/go/internal/ocpp/version.go @@ -0,0 +1,67 @@ +package ocpp + +// OCPP version handling. +// +// A charge point picks its dialect during the WebSocket handshake, via the +// subprotocol header — "ocpp1.6", "ocpp2.0.1". The library's ws.Server keeps a +// single message handler, so one listener serves exactly one version and each +// enabled version gets its own port. Which port a charger dialled is therefore +// what tells us how to talk back to it. +// +// Everything above this file is version-neutral: chargers land in the same +// state map, produce the same DerEV telemetry, and take the same commands. Only +// the message encoding differs, which is what the per-version handlers own. + +// Version is an OCPP protocol version FTW can serve. +type Version string + +const ( + // Version16 is OCPP 1.6J. Every charger on the market speaks it, and every + // charger currently on the bench speaks only it. + Version16 Version = "1.6" + + // Version201 is OCPP 2.0.1. Newer hardware and the version the industry is + // migrating to. + Version201 Version = "2.0.1" +) + +// String makes Version printable in logs and errors. +func (v Version) String() string { return string(v) } + +// Valid reports whether this is a version FTW can serve. Used by config +// validation so a typo fails at startup rather than silently serving nothing. +func (v Version) Valid() bool { + switch v { + case Version16, Version201: + return true + default: + return false + } +} + +// Version returns the OCPP dialect a charge point connected with, and whether +// it has been seen at all. +func (h *Handler) Version(id string) (Version, bool) { + if h == nil { + return "", false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.version == "" { + return "", false + } + return s.version, true +} + +// setVersion records the dialect a charge point connected with. Called from +// each version's connect callback, where the listener identity is known. +func (h *Handler) setVersion(id string, v Version) { + if h == nil { + return + } + s := h.state(id) + h.mu.Lock() + s.version = v + h.mu.Unlock() +}