diff --git a/.changeset/chargers-tab-ocpp-panel.md b/.changeset/chargers-tab-ocpp-panel.md new file mode 100644 index 000000000..fc440894f --- /dev/null +++ b/.changeset/chargers-tab-ocpp-panel.md @@ -0,0 +1,11 @@ +--- +"ftw": minor +--- + +Rename the Loadpoints tab and dashboard section to Chargers, and give it an +OCPP panel: the exact backend URL to enter on a charger, live state for every +connected charge point (vendor, dialect, vehicle, power, session energy), and +the connected charge points offered in the charger-driver dropdown so an OCPP +charger can be bound to the planner without editing YAML. Backed by a new +GET /api/ocpp/chargers endpoint. Docs now recommend a DHCP reservation for the +FTW host before commissioning chargers. diff --git a/.changeset/ocpp-capability-probe.md b/.changeset/ocpp-capability-probe.md new file mode 100644 index 000000000..955b02425 --- /dev/null +++ b/.changeset/ocpp-capability-probe.md @@ -0,0 +1,13 @@ +--- +"ftw": minor +--- + +FTW now asks each OCPP charger whether it can be steered, and says so. Shortly +after a charger connects, core reads its `SupportedFeatureProfiles` (1.6) or +`SmartChargingCtrlr.Available` (2.0.1), records the raw answer, and shows a +Control column on Settings → Chargers: "smart charging", "telemetry only" (with +a warning explaining the charger will meter but never plan), or "not reported" +for a charger that stayed silent. Also exposed as `steerable` and +`feature_profiles` on `GET /api/ocpp/chargers`. The verdict is advisory — +commands are still attempted, so a charger that under-reports its own +capabilities is never locked out of control. diff --git a/.changeset/ocpp-central-system-restore.md b/.changeset/ocpp-central-system-restore.md new file mode 100644 index 000000000..e1d37ca77 --- /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-charger-interop.md b/.changeset/ocpp-charger-interop.md new file mode 100644 index 000000000..37a61c11f --- /dev/null +++ b/.changeset/ocpp-charger-interop.md @@ -0,0 +1,30 @@ +--- +"ftw": patch +--- + +Three fixes from running the OCPP central system against Sourceful's device +simulator. Each one let FTW report a limit it had not actually imposed, or +refuse a control that should have worked. + +**Charging profiles are sent as Relative, not Absolute.** FTW's schedule is a +single period at second 0 with no end — "hold this limit until I say +otherwise". Absolute expresses that only with a `startSchedule` timestamp, and +while the specification says an absolute schedule without one is relative to +the start of charging anyway, a charger that parses the missing timestamp +strictly finds no valid start, treats the profile as not yet active, and +answers **Accepted** while charging on at full rate. Relative carries no +timestamp, so there is nothing to misparse — and nothing that depends on the +charger's clock agreeing with ours. + +**A charger that refuses a charge-point-wide profile is retried on connector +1.** OCPP 1.6 permits a `TxDefaultProfile` on connector 0 — it is how a profile +applies to every connector — but some chargers read the connector-0 rule as +`ChargePointMaxProfile`-only and reject it. Rejecting means no limit at all, so +one retry on the first connector is the difference between a charger FTW steers +and one it can only meter. + +**Manual EV controls reach an OCPP charger.** Pause, Resume, Force start and +set-current posted to `/api/ev/command` went straight to the Lua driver +registry, which an OCPP charge point is not in — it dialled us rather than +being dialled. They failed with `driver "" not found` while automatic +dispatch steered the same charger correctly. diff --git a/.changeset/ocpp-charging-needs.md b/.changeset/ocpp-charging-needs.md new file mode 100644 index 000000000..c066a3b13 --- /dev/null +++ b/.changeset/ocpp-charging-needs.md @@ -0,0 +1,21 @@ +--- +"ftw": minor +--- + +FTW now listens to what the car itself asks for. On an ISO 15118 session an +OCPP 2.0.1 charger forwards the vehicle's own `NotifyEVChargingNeeds` — +the energy it wants, when it expects to leave, and on DC its battery capacity +and present state of charge. Core takes that as the session's truth: the +reported capacity replaces the configured `vehicle_capacity_wh` (measured beats +an operator's estimate of the car that usually parks here), the reported SoC +re-anchors the session estimate, and the two together with the requested energy +derive the target the planner sizes on. A departure time the car states becomes +the loadpoint's target time, and one it does not state never erases the +operator's own. Everything is session-scoped and reverts on plug-out, like an +identified vehicle profile. The report is visible on `GET /api/ocpp/chargers` +as `charging_needs`, and quarantine still applies — a pending charge point's +needs are shown but never reach a loadpoint. + +An AC session states energy without a battery size, so no target fraction is +derived from it; guessing one would feed the planner a number the car never +claimed. diff --git a/.changeset/ocpp-durable-device.md b/.changeset/ocpp-durable-device.md new file mode 100644 index 000000000..0a81496b2 --- /dev/null +++ b/.changeset/ocpp-durable-device.md @@ -0,0 +1,24 @@ +--- +"ftw": minor +--- + +An adopted OCPP charger is now a device like any other. It gets a row in +`/api/devices` and under Settings → Devices, keyed on the vendor and serial +from its `BootNotification` rather than on the name it dialled with — that +name is one an installer typed and the charger's own web page can change, so +persistent state keyed on it would not survive a re-commissioning. Rename a +charger and the row follows it. A charger that reports no serial falls back to +the dialled name, recorded as an endpoint so it reads as stable-until-changed. +Pending chargers get no row: a device row says this hardware is part of the +site, and quarantine says an unadopted charge point is not. + +`GET /api/ocpp/chargers` now also reports each charger's `serial` and +`firmware`, and OCPP 1.6's deprecated `chargeBoxSerialNumber` is read when the +current field is empty — shipped firmware disagrees about which to fill, and +losing it loses the only stable identity some chargers ever report. + +The OCPP server's own settings — on/off, bind address, both ports, path, +username and password — are editable under Settings → Chargers instead of +only in `config.yaml`. TLS paths and per-charger credentials stay in the file: +they are host filesystem paths and one secret per charger, set once at +commissioning. diff --git a/.changeset/ocpp-listener-hardening.md b/.changeset/ocpp-listener-hardening.md new file mode 100644 index 000000000..2ee16b04d --- /dev/null +++ b/.changeset/ocpp-listener-hardening.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +The OCPP listener can now be pinned to one interface, served over TLS, and +given a credential per charger. + +`ocpp.bind` finally does something. The library builds its listen address from +the port alone, so the socket is unavoidably open on every interface; FTW now +refuses the WebSocket handshake for a connection that arrived on any other +address. That is an access control rather than a smaller attack surface — the +port still answers a scan — and the docs say so. + +`ocpp.tls` serves `wss://` instead of `ws://`, ending the plaintext basic auth +anyone on the LAN could sniff. `client_ca_file` additionally requires every +charge point to present a certificate signed by that CA (OCPP 2.0.1 security +profile 3). Half a TLS section is refused at startup rather than quietly +serving plaintext. + +`ocpp.chargers` gives a named charge point a password of its own. On OCPP the +basic-auth username is the charge point identity, so a listed charger must +present both, and the shared password stops being enough to connect under its +name — the impersonation hole the pending-charger quarantine could not close. +It is opt-in per charger; anything unlisted keeps using the shared credential. +Per-charger passwords are masked out of `GET /api/config` and survive a +settings save, matched by charger id rather than position. diff --git a/.changeset/ocpp-pending-quarantine.md b/.changeset/ocpp-pending-quarantine.md new file mode 100644 index 000000000..8d2b2f549 --- /dev/null +++ b/.changeset/ocpp-pending-quarantine.md @@ -0,0 +1,13 @@ +--- +"ftw": minor +--- + +OCPP charge points now start quarantined. A charge point that no charger entry +(loadpoint) names connects as "pending": it shows on Settings → Chargers with +its vendor, dialect and live state so it can be adopted, but its telemetry is +withheld from the site — no DerEV reading, no driver health, no metrics — and +it is never commanded. This stops any device that merely knows the shared OCPP +password from fabricating EV load and steering dispatch (the DerEV sum +suppresses home-battery discharge). Adopting a charger = adding a charger +entry with its id as the charger driver and saving — charger entries +hot-reload, so adoption and un-adoption take effect on the save. diff --git a/.changeset/ocpp-v201-support.md b/.changeset/ocpp-v201-support.md new file mode 100644 index 000000000..597401982 --- /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/.changeset/vehicle-profiles.md b/.changeset/vehicle-profiles.md new file mode 100644 index 000000000..d596d8284 --- /dev/null +++ b/.changeset/vehicle-profiles.md @@ -0,0 +1,13 @@ +--- +"ftw": minor +--- + +Vehicle profiles for chargers shared by several cars. A new `vehicles:` config +list (also editable under Settings → Chargers → Vehicles) holds each car's +battery capacity, identifiers and charging policy — PV-surplus-only and/or a +target SoC the planner fills toward in the cheapest tariff hours. When an OCPP +charging session identifies the car (the RFID idTag on 1.6, a MacAddress or +eMAID idToken on 2.0.1), the charger switches to that car's capacity and +policy for the session; capacity reverts on plug-out. A session matching no +profile changes nothing — the visitor default — and the identity it presented +is shown in the Chargers tab so it can be pasted into a profile. diff --git a/README.md b/README.md index 9c2bd2404..0c3420f73 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 @@ -85,6 +86,11 @@ It installs Docker when needed, creates `~/ftw`, downloads the Compose file and starts core, optimizer, updater and the local MQTT broker. Open `http://:8080/setup` on the LAN. +Give the FTW machine a DHCP reservation (a fixed IP) in your router. Devices +that dial in to FTW — OCPP chargers store their backend URL at commissioning, +and some hardware whitelists which addresses may talk to it — silently lose +the connection if DHCP later hands the host a different address. + Existing Forty Two Watts or older FTW deployments must use the [legacy upgrade guide](docs/upgrade-from-legacy.md) so configuration and state are preserved. Raspberry Pi image installation is covered by @@ -177,6 +183,10 @@ driver, which can update or roll back without a new FTW core release. Device Support may consume the same public source later for other products or a higher support level. +EV chargers that speak OCPP are the exception: they need no driver. FTW runs an +OCPP Central System (1.6J and 2.0.1), so the charger connects and registers itself. +See [docs/ocpp.md](docs/ocpp.md). + ## Releases There are two channels: @@ -201,6 +211,7 @@ metadata are the detailed reference. - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) - [Device driver catalog](https://srcfl.github.io/device-drivers/) — every supported device and the evidence behind it +- [OCPP chargers (no driver needed)](docs/ocpp.md) - [Self-update and release channels](docs/self-update.md) - [Home Assistant](docs/ha-integration.md) - [CalDAV](docs/caldav-integration.md) diff --git a/config.example.yaml b/config.example.yaml index 1eb263a25..c072291f6 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -176,6 +176,44 @@ caldav: plan_path: /ftw/plan/ # plan_publish_interval_s: 900 # how often the plan calendar is reconciled +# Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak +# OCPP connect to FTW directly — there is no driver to write and nothing to add +# under `drivers:`. +# A charger appears as a device on its first BootNotification, keyed by the last +# segment of the URL it dialled: ws://:8887/garage-left +# +# Credentials are required when enabled, and FTW refuses to start without them: +# the OCPP library builds its listen address from the port alone, so the socket +# is open on every interface whatever bind says — bind refuses the handshake for +# a connection that arrived elsewhere, which controls access without closing the +# port. Keep it closed at your router. See docs/ocpp.md. +# Each version needs its own port: a charger picks its dialect in the WebSocket +# handshake, so one listener cannot serve both. 2.1 is not supported — no +# production-grade Go implementation of it exists yet. +ocpp: + enabled: false + # bind: 192.168.1.10 # refuse chargers reaching the box any other + # way; omit for every interface + port: 8887 # OCPP 1.6J + # port_v201: 8888 # OCPP 2.0.1; omit to disable + path: / + username: ftw + password: "" # required when enabled; use a long random string + heartbeat_interval_s: 60 + # Serve wss:// instead of ws://. client_ca_file additionally requires each + # charger to present a certificate signed by that CA — OCPP 2.0.1 security + # profile 3, and the only identity here that cannot be copied between devices. + # tls: + # cert_file: /etc/ftw/ocpp/server.crt + # key_file: /etc/ftw/ocpp/server.key + # client_ca_file: /etc/ftw/ocpp/charger-ca.crt + # Give a charger a credential of its own, so the shared password above stops + # being enough to connect under its name. The id is what the charger dials + # with, and the same string a loadpoint's driver_name adopts. + # chargers: + # - id: garage + # password: "" # a long random string, different per charger + # Persistent state (SQLite) state: path: state.db diff --git a/docs/ocpp.md b/docs/ocpp.md new file mode 100644 index 000000000..a278e9dc2 --- /dev/null +++ b/docs/ocpp.md @@ -0,0 +1,434 @@ +# 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. + +You point the charger at FTW and it appears on **Settings → Chargers** 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. + +Connecting alone does not make it part of the site. A charge point no charger +entry names is **pending**: FTW shows its vendor, dialect and live state so +you can adopt it, but ignores its telemetry and never commands it. To adopt +it, add a charger entry on the same tab with the charge point's id as the +charger driver and save — charger entries hot-reload, so it joins the site on +that save, and removing the entry returns it to pending just as immediately. + +The quarantine is deliberate. Every charge point shares one basic-auth secret, +so "it authenticated" proves the password, not the device. If pending chargers +fed telemetry, anything on the LAN holding that password could invent EV load +— and the dispatch clamp would obligingly stop the home battery discharging +into a charge that does not exist. Naming the id in a charger entry is what +turns *seen* into *trusted*. + +Once adopted it behaves like any other EV reading: `MeterValues` and +`StatusNotification` become telemetry, and dispatch stops the home battery +discharging into an active EV charge. It also gets a row in `/api/devices` +alongside the driver-backed hardware, under **Settings → Devices**. + +That row is keyed on the vendor and serial from `BootNotification`, not on the +name above — a name an installer typed and the charger's own web page can +change is not hardware identity, and state keyed on it would not survive a +re-commissioning. Rename a charger and its row follows it; the persistent +state stays attached to the box on the wall. A charger that reports no serial +(plenty do not) falls back to the dialled name, recorded as an endpoint so it +reads as what it is: stable only until someone changes it. + +Pending chargers get no row. A device row says this hardware is part of the +site, and quarantine says an unadopted charge point is not; it gets one on the +save that adopts it. + +## Protocol versions + +| 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 socket is on every interface + +The OCPP library builds its listen address from the port alone, so the socket +is open on every interface the host has and nothing FTW can configure changes +that. `bind` therefore works one layer up: a connection that arrived on any +other address is refused at the WebSocket handshake, before it can speak OCPP. + +That is an access control, not a smaller attack surface. A port scan still +finds the port open on every interface; what it cannot do is talk to it. + +```yaml +ocpp: + bind: 192.168.1.10 # only chargers reaching the box this way +``` + +On a Raspberry Pi with one LAN connection the default (every interface) is +usually fine. Set `bind` when the host also has a VPN interface, a second NIC, +or a public one. + +Whatever you set: + +- Keep the port closed at your router. Never forward it from the internet. +- Treat the password as a real secret. + +### The four gates + +1. **Basic auth.** Required — an enabled server without a username and + password is refused at startup. +2. **Identity binding.** A charger listed under `ocpp.chargers` has a password + of its own and must present it under its own name. This is what closes + impersonation: without it, identity is client-chosen and shared, so a device + that knows the password *and* an adopted charger's id can pose as it. +3. **Bind.** As above. +4. **Quarantine.** A charge point no charger entry names stays pending, outside + telemetry and dispatch. A stolen password gets an attacker a row in the + Chargers table, not influence over the site. + +### Per-charger credentials + +```yaml +ocpp: + username: ftw + password: "the-shared-one" + chargers: + - id: garage + password: "a-different-long-random-string" +``` + +The `id` is what the charger dials with — the last segment of its URL, and the +same string a charger entry adopts. On OCPP the basic-auth username *is* that +identity, so a listed charger presents `garage` / its own password, and the +shared credential no longer buys its name. + +This is opt-in per charger: anything not listed keeps using the shared +username and password, so adding one entry does not lock the others out. A +charger you have not listed can still be impersonated by something holding the +shared password — list the ones that matter. + +### TLS + +```yaml +ocpp: + tls: + cert_file: /etc/ftw/ocpp/server.crt + key_file: /etc/ftw/ocpp/server.key + client_ca_file: /etc/ftw/ocpp/charger-ca.crt # optional +``` + +Chargers then dial `wss://` instead of `ws://`. Without it, basic auth over +`ws://` sends the credential unencrypted and anyone who can sniff your LAN can +read it. + +`client_ca_file` additionally requires every charge point to present a +certificate signed by that CA — OCPP 2.0.1 security profile 3. It is the +strongest identity available here: unlike a password, it cannot be copied out +of one charger's configuration and replayed by another device unless the +private key was copied too. + +Half a TLS section is refused at startup rather than quietly serving `ws://`. +An operator who asked for `wss://` and silently got plaintext would have no way +to tell the link was never encrypted. + +## Pointing a charger at FTW + +The backend URL is always `ws://:`. What differs +is how you reach the charger to set it. + +**Reserve FTW's IP address first.** Chargers store the backend URL at +commissioning time, and some also whitelist which addresses may talk to them — +an FTW host that later gets a different address from DHCP silently orphans +every charger pointed at it. Give the FTW machine a DHCP reservation (a fixed +IP) in your router before commissioning the first charger. + +`` can be a hostname instead of an IP if the charger's firmware can +resolve it: plain DNS names (a router DNS entry, a local zone) work on most +chargers, but mDNS `.local` names usually do not — charger firmware rarely +ships an mDNS resolver. When in doubt, use the reserved IP. + +The **Settings → Chargers** tab shows the exact URL to enter, and every charge +point that has connected appears there with its vendor, OCPP dialect and live +state — as pending, until you adopt it. Add it as a charger entry on the same +tab (its identity appears in the driver dropdown once it has connected) and +save; that both lets the planner steer it and admits its telemetry into the +site, immediately — no restart needed. + +| 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. + +## Can this charger be steered? + +Not every OCPP charger accepts control. FTW asks each one, once, shortly after +it connects: `GetConfiguration(SupportedFeatureProfiles)` on 1.6, and the +`SmartChargingCtrlr.Available` variable on 2.0.1. The answer lands in the +**Control** column of the Chargers tab and in `GET /api/ocpp/chargers`: + +| Column says | Meaning | +|---|---| +| smart charging | The charger advertises `SmartCharging`; FTW can throttle and pause it. | +| telemetry only | The charger answered without `SmartCharging` — expect metering, no planning. | +| not reported | The charger never answered the probe. Unknown, not incapable. | + +The raw answer is kept (hover the column) and re-probed on every reconnect +until one arrives. + +**The verdict is advisory, never a gate.** FTW still sends charging profiles to +a charger that claims it cannot take them, because vendors under-report and a +firmware update can change the answer without changing the advertisement. What +actually decides is the response to a real command: a charger that refuses +`SetChargingProfile` is walked to its autonomous default and reported through +the same path as any driver that cannot actuate. The probe exists so the UI can +warn *before* you bind a metering-only charger to a charger entry and wonder +why the planner never moves it — and some vendors ship smart charging behind a +firmware update or an installer-app setting, which is worth checking when you +see the warning. + +## 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. + +### How the profile is shaped, and why + +The limit goes out as a `TxDefaultProfile` with one schedule period at second +zero and no end: *hold this until I send another*. Two details of that are +load-bearing, and both come from chargers disagreeing with the specification +in ways that fail silently: + +- **The profile kind is `Relative`, not `Absolute`.** An absolute schedule + states when it starts; FTW's has no start, and the specification says an + absolute schedule without one is relative to the start of charging anyway. + A charger that instead reads the missing timestamp as "not valid yet" + answers **Accepted** and charges on at full rate — the worst failure + available here, because FTW logs a limit it never imposed. Relative carries + no timestamp to misread, and does not depend on the charger's clock agreeing + with ours. +- **Connector 0, then connector 1.** A profile on connector 0 applies to every + connector, which avoids depending on per-connector ids — unreliable on + dual-socket units such as the Charge Amps Aura. Some chargers read the + connector-0 rule as `ChargePointMaxProfile`-only and reject it, so a refusal + is retried once on connector 1. Refusing both is reported as an error: a + charger FTW cannot steer must not look like one it can. + +## One capacity, several cars: vehicle profiles + +`vehicle_capacity_wh` on a charger entry describes the **one** car the charger +usually serves. For a charger shared by several cars, add **vehicle profiles**: + +```yaml +vehicles: + - id: leaf + name: Nissan Leaf + capacity_wh: 40000 + identifiers: ["04A2B3C4"] # RFID tag uid (1.6), MAC / eMAID (2.0.1) + surplus_only: true # this car charges from PV surplus alone + - id: model3 + capacity_wh: 75000 + identifiers: ["aa:bb:cc:dd:ee:ff"] + target_soc: 0.80 # planner fills to 80 % in the cheapest hours +``` + +When a charging session identifies the car, FTW switches the charger to that +car's capacity and charging policy for the session. The capacity reverts on +plug-out; `surplus_only` and the target are set the same way the dashboard +sets them, so they persist until another car (or the operator) changes them. +The identity each session presented is shown in the Chargers tab's Vehicle +column — paste it into a profile's Identifiers from there. The same profiles +are edited under **Settings → Chargers → Vehicles**. + +A car matching no profile changes **nothing**. That is the visitor default: +the loadpoint keeps its own settings, and the visitor charges under them. + +What "identifies" means depends on the dialect: + +- **OCPP 1.6**: the RFID `idTag` that started the transaction. It names the + card, not the car — profiles work when each card lives permanently in one + car. +- **OCPP 2.0.1**: idTokens can name the actual vehicle — `MacAddress` + (autocharge) or `eMAID` (ISO 15118 Plug & Charge) — no card involved. + +A wrong or missing capacity skews planning accuracy only, never safety — the +car's own BMS always protects it. An unprofiled car larger than the configured +capacity makes the SoC estimate rise too fast, so a target charge can stop +early; correct the SoC in the dashboard EV modal, or use Force start. + +## When the car speaks for itself + +With ISO 15118 hardware on OCPP 2.0.1 the car states its own needs, and the +charger forwards them as `NotifyEVChargingNeeds`. That outranks every figure +above: a profile and `vehicle_capacity_wh` are both an operator's estimate of +the car that usually parks here, this is the car actually plugged in. + +What FTW takes from it, for the session only: + +| The car says | FTW does | +|---|---| +| battery capacity (DC) | replaces the session capacity, over a profile's too | +| present state of charge (DC) | re-anchors the session SoC estimate | +| energy requested | with the two above, derives the target the planner fills to | +| departure time | becomes the loadpoint's target time | + +All of it reverts on plug-out, exactly like a profile. A departure time the car +does not state never erases one the operator set. + +An **AC** session states energy without a battery size, so there is no fraction +to derive and the target is left alone — a guess there would feed the planner a +number the car never claimed. A departure time still applies. + +The last report is shown in the Chargers tab and on `GET /api/ocpp/chargers` as +`charging_needs`. Quarantine applies as everywhere else: a pending charge +point's needs are visible so you can see what asked, and reach no loadpoint. + +Most chargers never send this. It needs ISO 15118 on both the charger and the +car; without it, profiles and `vehicle_capacity_wh` remain the whole story. + +## Current limits + +- **The socket cannot be pinned to one interface.** `bind` refuses the + handshake instead, so the port stays open on every interface even when only + one is served. +- Chargers that also have a native protocol may work better through a driver. + Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is + the option when you want the cloud out of the loop. + +## 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 53a3b93f6..24a45ef3d 100644 --- a/docs/writing-a-driver.md +++ b/docs/writing-a-driver.md @@ -17,6 +17,11 @@ signed channel is FTW's default source: - [device driver catalog](https://srcfl.github.io/device-drivers/) — which devices are already covered, and on what evidence. +> **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. + This page is the other half: what FTW's host gives a driver, how FTW grants it, where FTW loads it from, and how to test one against a running instance. It is deliberately not a second authoring guide — two of those drift, and the one diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index 2954b4cfd..8c4350041 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -59,6 +59,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" @@ -845,6 +846,12 @@ func main() { // hot-reload the calendar client (#498). Assigned later (calendar.New). var calSvc *calendar.Service + // Forward-declared so the reload callback can keep the OCPP quarantine + // in step with hot-reloaded loadpoints — adopting a pending charger is + // naming it in a charger entry, and must take effect on the same save. + // Assigned where the OCPP server starts (optional; nil-guarded). + var ocppSrv *ocpp.Server + // ---- Config hot-reload watcher ---- // Named because two callers share it: the fsnotify watcher created // below and POST /api/config (Deps.ConfigApplier), so a config saved @@ -995,6 +1002,23 @@ func main() { lpMgr.Load(buildLoadpointConfigs(newCfg.Loadpoints)) hydrateLoadpointSurplusOnly() + // The OCPP quarantine follows the loadpoints just reloaded: + // a pending charger a new entry names is adopted on this save, + // and one whose entry was removed goes back to pending. + if ocppSrv != nil { + approved := make([]string, 0, len(newCfg.Loadpoints)) + for _, lp := range newCfg.Loadpoints { + if lp.DriverName != "" { + approved = append(approved, lp.DriverName) + } + } + ocppSrv.Handler().SetApprovedIDs(approved) + // A charger adopted by this save booted long ago and will not + // boot again just because we changed our mind, so its device + // row has to be written here rather than waiting for one. + registerOCPPDevices(st, ocppSrv) + } + // Notifications: rebuild the provider from fresh config // (handles the cold-start case where the initial config // had no notifications: block and notifProvider was nil), @@ -1261,6 +1285,169 @@ func main() { slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) } + // ---- Start OCPP 1.6J Central System (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point whose identity (the last segment of the + // URL it dialed) is named by a loadpoint becomes a device in tel on its + // first message and dispatch picks it up like any other EV reading. Any + // other identity is quarantined as pending — visible in the UI, absent + // from telemetry — so a device that merely knows the shared password + // cannot inject EV load into dispatch. + if cfg.OCPP != nil && cfg.OCPP.Enabled { + approved := make([]string, 0, len(cfg.Loadpoints)) + for _, lp := range cfg.Loadpoints { + if lp.DriverName != "" { + approved = append(approved, lp.DriverName) + } + } + ocppCfg := &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Bind: cfg.OCPP.Bind, + Port: cfg.OCPP.Port, + PortV201: cfg.OCPP.PortV201, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + ChargerSecrets: cfg.OCPP.ChargerSecrets(), + ApprovedIDs: approved, + } + if t := cfg.OCPP.TLS; t != nil { + ocppCfg.TLS = &ocpp.TLSConfig{ + CertFile: t.CertFile, + KeyFile: t.KeyFile, + ClientCAFile: t.ClientCAFile, + } + } + srv, err := ocpp.Start(ctx, ocppCfg, 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() + // Vehicle profiles: when a charging transaction identifies the + // car (RFID idTag on 1.6, MacAddress/eMAID idToken on 2.0.1), + // apply the matching vehicles: profile to the loadpoint bound + // to that charger — capacity for SoC/planner sizing, plus the + // profile's charging policy. An identity matching no profile + // changes nothing (the visitor default); it still shows in the + // Chargers panel so the operator can paste it into a profile. + // An adopted charger becomes a device the moment it says what + // it is, keyed on vendor+serial like any driver-backed one. + ocppSrv.Handler().SetIdentityReported(func(ident ocpp.ChargerIdentity) { + registerOCPPDevice(st, ident) + }) + ocppSrv.Handler().SetVehicleIdentified(func(chargerID, vehicleID, source string) { + cfgMu.RLock() + lpID := "" + for _, lp := range cfg.Loadpoints { + if lp.DriverName == chargerID { + lpID = lp.ID + break + } + } + var vehicle *config.Vehicle + if lpID != "" { + if v := cfg.VehicleByIdentifier(vehicleID); v != nil { + vc := *v + vehicle = &vc + } + } + cfgMu.RUnlock() + if lpID == "" { + return + } + if vehicle == nil { + slog.Info("ocpp: session identity matches no vehicle profile — loadpoint keeps its own settings", + "charger", chargerID, "identity", vehicleID, "source", source) + return + } + name := vehicle.Name + if name == "" { + name = vehicle.ID + } + lpMgr.ApplyVehicleProfile(lpID, name, vehicle.CapacityWh) + lpMgr.SetSurplusOnly(lpID, vehicle.SurplusOnly) + if vehicle.TargetSoC > 0 { + lpMgr.SetTarget(lpID, vehicle.TargetSoC, time.Time{}) + } + slog.Info("ocpp: vehicle profile applied", + "charger", chargerID, "lp", lpID, "vehicle", vehicle.ID, + "source", source, "capacity_wh", vehicle.CapacityWh, + "surplus_only", vehicle.SurplusOnly, + "target_soc", vehicle.TargetSoC) + }) + // Charging needs: on an ISO 15118 session the car states what + // it wants — energy, departure, and on DC its own capacity and + // state of charge. That is the car actually plugged in rather + // than an operator's estimate of it, so it takes precedence for + // the session and reverts on plug-out with everything else. + ocppSrv.Handler().SetChargingNeeds(func(chargerID string, needs ocpp.ChargingNeeds) { + cfgMu.RLock() + lpID := "" + for _, lp := range cfg.Loadpoints { + if lp.DriverName == chargerID { + lpID = lp.ID + break + } + } + cfgMu.RUnlock() + if lpID == "" { + return + } + // Capacity first: the SoC anchor below divides delivered + // energy by it, so anchoring against a stale capacity would + // re-base the session estimate on the wrong battery. + if needs.CapacityWh > 0 { + lpMgr.SetSessionCapacityWh(lpID, needs.CapacityWh) + } + if needs.PresentSoC != nil { + lpMgr.AnchorVehicleSoC(lpID, *needs.PresentSoC) + } + target, haveTarget := needs.TargetSoC() + switch { + case haveTarget: + // A departure the car did not state must not erase one + // the operator did. + when := needs.DepartureTime + if when.IsZero() { + if st, ok := lpMgr.State(lpID); ok { + when = st.TargetTime + } + } + lpMgr.SetTarget(lpID, target, when) + case !needs.DepartureTime.IsZero(): + // AC states energy without a battery size, so there is + // no fraction to derive — but the deadline is still the + // car's, and it belongs on the operator's own target. + if st, ok := lpMgr.State(lpID); ok && st.TargetSoC > 0 { + lpMgr.SetTarget(lpID, st.TargetSoC, needs.DepartureTime) + } + } + slog.Info("ocpp: charging needs applied", + "charger", chargerID, "lp", lpID, + "mode", needs.TransferMode, "energy_wh", needs.EnergyWh, + "capacity_wh", needs.CapacityWh, + "departure", needs.DepartureTime, + "target_soc", target, "target_derived", haveTarget) + }) + 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 gates the socket, and chargers no loadpoint names stay pending outside telemetry") + } + } + // Snapshot hook for GET /api/ocpp/chargers. Left nil when the server is + // disabled or failed to start, which the endpoint reports as an empty + // list rather than an error. + var ocppChargersFn func() map[string]ocpp.ChargerView + if ocppSrv != nil { + ocppChargersFn = ocppSrv.Handler().Snapshot + } + // ---- Start MPC planner (optional) ---- mpcSvc = buildMPC(cfg, st, tel, capacities) if mpcSvc != nil { @@ -1618,6 +1805,25 @@ func main() { // driver_failure_default.go. actuation := newDriverActuationTracker(tel) + // 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. Everything above stays unaware of the difference. + // + // Hoisted out of the loadpoint controller below because the API needs the + // same routing: the dashboard's Pause / Resume / Force start post to + // /api/ev/command, and sending those straight to the registry finds no + // driver for a charger that has none. + evSend := reg.Send + if ocppSrv != nil { + evSend = 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) + } + } + // ---- EV loadpoint controller ---- // loadpoint.Controller owns per-tick EV dispatch, including the // energy-allocation contract, snapping and phase transitions. @@ -1665,7 +1871,9 @@ func main() { RequestActive: reqActive, }, true } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send) + // evSend routes OCPP chargers past the driver registry; loadpoints + // stay unaware of the difference. + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, evSend) // A charger that answers every poll and refuses every setpoint is // the storage bug of #800 on the EV wire: it holds its last // current and the plan keeps counting the load. Only the periodic @@ -1675,6 +1883,9 @@ func main() { lpController.SetCycleSender(reg.SendEVContinuation) lpController.SetDispatchOutcome(actuation.recordCommandOutcome) lpController.SetDriverOnline(func(name string) bool { + if ocppSrv != nil && ocppSrv.Handler().IsOnline(name) { + return true + } health := tel.DriverHealth(name) return health != nil && health.IsOnline() }) @@ -2331,6 +2542,8 @@ func main() { LoadModel: loadSvc, Loadpoints: lpMgr, LoadpointCtrl: lpController, + OCPPChargers: ocppChargersFn, + EVSend: evSend, CalDAV: calSvc, HA: haBridge, Registry: reg, @@ -3213,6 +3426,49 @@ func doRolloff(ctx context.Context, st *state.Store, coldDir string) { } } +// registerOCPPDevice writes one charge point's row in the device registry. +// +// A charger is not in the driver registry — it dialled us, so there is no +// driver, no endpoint we chose and no HostEnv identity to read. What it does +// have is a BootNotification, and vendor+serial out of that is a +// hardware-stable key exactly like a driver's. The name it dialled with is +// not: an installer typed it and the charger's own web page can change it, so +// it is recorded as the endpoint and only becomes the key when the charger +// reports no serial at all. +func registerOCPPDevice(st *state.Store, ident ocpp.ChargerIdentity) { + if st == nil || ident.ID == "" { + return + } + dev := state.Device{ + DriverName: ident.ID, + Make: ident.Vendor, + Serial: ident.Serial, + Endpoint: "ocpp://" + ident.ID, + } + id, err := st.RegisterDevice(dev) + if err != nil { + slog.Warn("ocpp: could not register charger as a device", + "charger", ident.ID, "err", err) + return + } + slog.Info("ocpp: charger registered as a device", + "charger", ident.ID, "device_id", id, + "vendor", ident.Vendor, "model", ident.Model, "serial", ident.Serial) +} + +// registerOCPPDevices catches up every adopted charger that has already +// booted. Adoption usually happens long after a BootNotification — an +// operator sees a pending charger in the UI and binds it to a loadpoint — and +// the charger will not boot again just because we changed our mind about it. +func registerOCPPDevices(st *state.Store, srv *ocpp.Server) { + if st == nil || srv == nil { + return + } + for _, ident := range srv.Handler().Identities() { + registerOCPPDevice(st, ident) + } +} + // registerAllDevices snapshots the identity HostEnv has gathered for each // running driver and upserts a row in the devices table. Idempotent. // Called periodically because some drivers (notably MQTT) only learn their diff --git a/go/cmd/ftw/ocpp_devices_test.go b/go/cmd/ftw/ocpp_devices_test.go new file mode 100644 index 000000000..ca36ed905 --- /dev/null +++ b/go/cmd/ftw/ocpp_devices_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/state" +) + +func openTestStore(t *testing.T) *state.Store { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// A charge point earns its device row on vendor+serial, like every other +// device. The name it dialled with is not identity — an installer typed it and +// the charger's own web page can change it — so it is recorded as the endpoint +// and only becomes the key when the charger reports no serial at all. +func TestOCPPDeviceIsKeyedOnHardwareNotTheDialledName(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{ + ID: "garage", + Vendor: "Charge Amps", + Model: "Halo", + Serial: "CA-0001", + }) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 1 { + t.Fatalf("got %d devices, want 1", len(devs)) + } + d := devs[0] + if d.DeviceID != "charge amps:CA-0001" { + t.Errorf("device_id: got %q, want it keyed on vendor+serial", d.DeviceID) + } + if d.DriverName != "garage" { + t.Errorf("driver_name: got %q, want the id the charger dialled with", d.DriverName) + } + if d.Endpoint != "ocpp://garage" { + t.Errorf("endpoint: got %q, want ocpp://garage", d.Endpoint) + } + + // Re-commissioned under a new name: same hardware, same row. + registerOCPPDevice(st, ocpp.ChargerIdentity{ + ID: "carport", + Vendor: "Charge Amps", + Serial: "CA-0001", + }) + devs, _ = st.AllDevices() + if len(devs) != 1 { + t.Fatalf("renaming a charger created a second device row: %+v", devs) + } + if devs[0].DriverName != "carport" { + t.Errorf("driver_name should follow the rename, got %q", devs[0].DriverName) + } +} + +// Plenty of chargers report no serial. They still deserve a row — the name +// they dialled with is stable enough to be an endpoint, which is exactly how +// the registry treats an address that only holds until someone changes it. +func TestOCPPDeviceFallsBackToTheDialledName(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{ID: "garage", Vendor: "Easee"}) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 1 { + t.Fatalf("got %d devices, want 1", len(devs)) + } + if devs[0].DeviceID != "ep:ocpp://garage" { + t.Errorf("device_id: got %q, want the endpoint fallback", devs[0].DeviceID) + } +} + +// An empty identity has nothing to key on and must not create a row. +func TestOCPPDeviceIgnoresAnEmptyIdentity(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{}) + registerOCPPDevice(nil, ocpp.ChargerIdentity{ID: "garage"}) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 0 { + t.Fatalf("got %d devices, want none: %+v", len(devs), devs) + } +} diff --git a/go/go.mod b/go/go.mod index a34185131..77388c92a 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.32.0 github.com/shirou/gopsutil/v4 v4.26.7 @@ -28,7 +29,11 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.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/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.24 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -36,6 +41,7 @@ require ( github.com/parquet-go/jsonlite v1.0.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 @@ -46,7 +52,7 @@ require ( github.com/yusufpapurcu/wmi v1.2.4 // indirect golang.org/x/sync v0.21.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.74.4 // 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 d04371343..6d87997de 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/goburrow/serial v0.1.0 h1:v2T1SQa/dlUqQiYIT8+Cu7YolfqAi3K96UmhwYyuSrA= github.com/goburrow/serial v0.1.0/go.mod h1:sAiqG0nRVswsm1C97xsttiYCzSLBmUZ/VSlVLZJ8haA= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -35,6 +44,8 @@ github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3 h1:LMLX+LgTNWpfvCBdFe github.com/google/pprof v0.0.0-20260802141513-ef3492d7dac3/go.mod h1:jl5iWTm0/hd5PjEYEOuwAJ57L/CibdZfrqZ5XA5GrCk= 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= @@ -45,10 +56,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.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= @@ -69,6 +88,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= @@ -79,6 +100,14 @@ github.com/shirou/gopsutil/v4 v4.26.7 h1:IXzpHz/dkMRYAhKkOXr1HB6SuzWU3eoyyeWe7g3 github.com/shirou/gopsutil/v4 v4.26.7/go.mod h1:5O9FjBiXoTDFatIWjZZosqj4pV0DRtLx598xGbBehzM= 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= @@ -105,8 +134,10 @@ golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= @@ -117,6 +148,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.29.1 h1:MKgdCV3WykTSPqpVrnxdEDS0HEd2FHpKZDzxzU5LyeI= diff --git a/go/internal/api/api.go b/go/internal/api/api.go index fac9600c4..1e75f7a45 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -47,6 +47,7 @@ import ( "github.com/srcfl/ftw/go/internal/loadpoint" "github.com/srcfl/ftw/go/internal/mpc" "github.com/srcfl/ftw/go/internal/notifications" + "github.com/srcfl/ftw/go/internal/ocpp" "github.com/srcfl/ftw/go/internal/prices" "github.com/srcfl/ftw/go/internal/pvmodel" "github.com/srcfl/ftw/go/internal/scanner" @@ -156,6 +157,18 @@ type Deps struct { // path until expiry. Nil disables the endpoint. LoadpointCtrl *loadpoint.Controller + // OCPPChargers snapshots the OCPP central system's charger map for + // GET /api/ocpp/chargers. Nil when the OCPP server is disabled or + // failed to start; the endpoint then reports an empty list. + OCPPChargers func() map[string]ocpp.ChargerView + + // EVSend delivers a command to an EV charger by name, routing an OCPP + // charge point past the driver registry it is not in. Nil falls back to + // Registry.Send, which is correct for a build with no OCPP server and + // wrong for one with a charger that has no driver — the dashboard's + // Pause / Resume / Force start would find no such driver and fail. + EVSend func(ctx context.Context, name string, payload []byte) error + // Optional: CalDAV calendar-constraints client (#498). Nil when the // feature is disabled; GET /api/caldav/status then reports disabled. CalDAV *calendar.Service @@ -419,6 +432,7 @@ func (s *Server) routes() { s.handle("GET /api/drivers/{name}/logs", Local, s.handleDriverLogs) s.handle("GET /api/logs", Local, s.handleGlobalLogs) s.handle("GET /api/support/dump", Local, s.handleSupportDump) + s.handle("GET /api/ocpp/chargers", Local, s.handleOCPPChargers) s.handle("GET /api/support/report", Local, s.handleSupportReport) s.handle("POST /api/drivers/{name}/control", Actuate, s.handleDriverControl) s.handle("DELETE /api/drivers/{name}/control", Actuate, s.handleDriverControlRelease) @@ -3035,7 +3049,10 @@ func (s *Server) handleEVCommand(w http.ResponseWriter, r *http.Request) { return } } - if s.deps.Registry == nil { + // An OCPP-only site has chargers and no Lua driver behind them, so the + // registry being absent is not by itself a reason to refuse: EVSend can + // still deliver. Both missing is. + if s.deps.Registry == nil && s.deps.EVSend == nil { writeJSON(w, 503, map[string]string{"error": "driver registry not available"}) return } @@ -3053,13 +3070,24 @@ func (s *Server) handleEVCommand(w http.ResponseWriter, r *http.Request) { applyManualEVHold(s.deps, driverName, req.Action) } payload, _ := json.Marshal(map[string]any{"action": req.Action}) - if err := s.deps.Registry.Send(r.Context(), driverName, payload); err != nil { + if err := s.sendEV(r.Context(), driverName, payload); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, map[string]string{"status": "ok"}) } +// sendEV delivers an EV command, routing an OCPP charge point past the driver +// registry it is not in. Without this an OCPP charger answers telemetry and +// automatic dispatch but refuses every manual control on the dashboard, which +// reads as the charger being broken rather than unrouted. +func (s *Server) sendEV(ctx context.Context, driverName string, payload []byte) error { + if s.deps.EVSend != nil { + return s.deps.EVSend(ctx, driverName, payload) + } + return s.deps.Registry.Send(ctx, driverName, payload) +} + var validV2XActions = map[string]bool{ "v2x_set_power": true, "v2x_stop": true, diff --git a/go/internal/api/api_ev_ocpp_test.go b/go/internal/api/api_ev_ocpp_test.go new file mode 100644 index 000000000..73406f5c2 --- /dev/null +++ b/go/internal/api/api_ev_ocpp_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// An OCPP charge point has no Lua driver — it dialled us rather than being +// dialled — so it is not in the driver registry. Sending the dashboard's +// Pause / Resume / Force start straight to the registry finds no such driver +// and fails, which reads as the charger being broken rather than unrouted. +// +// Found by running the branch against Sourceful's device simulator: automatic +// dispatch steered the charger and every manual control returned +// `driver "garage" not found`. +func TestEVCommandReachesAChargerWithNoDriver(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("garage", telemetry.DerEV, 0, nil, nil) + + var mu sync.Mutex + var gotName string + var gotAction string + + srv := New(&Deps{ + Tel: tel, + // Registry deliberately nil: this is a site whose only charger is an + // OCPP one, so there is no Lua driver registry to fall back to. + EVSend: func(_ context.Context, name string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + } + _ = json.Unmarshal(payload, &cmd) + mu.Lock() + gotName, gotAction = name, cmd.Action + mu.Unlock() + return nil + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/ev/command", + strings.NewReader(`{"action":"ev_pause","driver":"garage"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want 200 (body: %s)", rr.Code, rr.Body.String()) + } + mu.Lock() + defer mu.Unlock() + if gotName != "garage" { + t.Errorf("command went to %q, want garage", gotName) + } + if gotAction != "ev_pause" { + t.Errorf("action: got %q, want ev_pause", gotAction) + } +} + +// With neither route available the endpoint still refuses rather than +// pretending the command landed. +func TestEVCommandWithNoRouteIsRefused(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("garage", telemetry.DerEV, 0, nil, nil) + srv := New(&Deps{Tel: tel}) + + req := httptest.NewRequest(http.MethodPost, "/api/ev/command", + strings.NewReader(`{"action":"ev_pause","driver":"garage"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("got status %d, want 503 (body: %s)", rr.Code, rr.Body.String()) + } +} diff --git a/go/internal/api/api_ocpp.go b/go/internal/api/api_ocpp.go new file mode 100644 index 000000000..39373ec60 --- /dev/null +++ b/go/internal/api/api_ocpp.go @@ -0,0 +1,64 @@ +package api + +import ( + "net/http" + "sort" + + "github.com/srcfl/ftw/go/internal/ocpp" +) + +// ocppChargerEntry is one charge point in the /api/ocpp/chargers response. +// The embedded view flattens, so the JSON reads as one object per charger. +type ocppChargerEntry struct { + ID string `json:"id"` + ocpp.ChargerView +} + +// handleOCPPChargers reports the OCPP central system's effective config and +// every charge point it currently knows, for the Settings → Chargers panel. +// OCPP being disabled is a normal state, not an error: enabled=false and an +// empty list, so the UI can render its "how to enable" text from a 200. +func (s *Server) handleOCPPChargers(w http.ResponseWriter, r *http.Request) { + resp := struct { + Enabled bool `json:"enabled"` + Port int `json:"port,omitempty"` + PortV201 int `json:"port_v201,omitempty"` + Path string `json:"path,omitempty"` + Chargers []ocppChargerEntry `json:"chargers"` + }{Chargers: []ocppChargerEntry{}} + + if s.deps.CfgMu != nil { + s.deps.CfgMu.RLock() + } + if s.deps.Cfg != nil && s.deps.Cfg.OCPP != nil && s.deps.Cfg.OCPP.Enabled { + resp.Enabled = true + // Mirror ocpp.Config.Defaults so the UI shows the port the listener + // actually took, not the zero the operator left unset. + resp.Port = s.deps.Cfg.OCPP.Port + if resp.Port == 0 { + resp.Port = 8887 + } + resp.PortV201 = s.deps.Cfg.OCPP.PortV201 + resp.Path = s.deps.Cfg.OCPP.Path + if resp.Path == "" { + resp.Path = "/" + } + } + if s.deps.CfgMu != nil { + s.deps.CfgMu.RUnlock() + } + + if s.deps.OCPPChargers != nil { + snap := s.deps.OCPPChargers() + ids := make([]string, 0, len(snap)) + for id := range snap { + ids = append(ids, id) + } + // Stable order so the panel does not shuffle between refreshes. + sort.Strings(ids) + for _, id := range ids { + resp.Chargers = append(resp.Chargers, ocppChargerEntry{ID: id, ChargerView: snap[id]}) + } + } + writeJSON(w, 200, resp) +} diff --git a/go/internal/api/api_ocpp_test.go b/go/internal/api/api_ocpp_test.go new file mode 100644 index 000000000..7deb0ce9e --- /dev/null +++ b/go/internal/api/api_ocpp_test.go @@ -0,0 +1,105 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/config" + "github.com/srcfl/ftw/go/internal/ocpp" +) + +func getOCPPChargers(t *testing.T, srv *Server) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodGet, "/api/ocpp/chargers", nil) + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + if rr.Code != 200 { + t.Fatalf("status = %d, want 200", rr.Code) + } + var out map[string]any + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatalf("bad JSON: %v", err) + } + return out +} + +// OCPP off is a normal state the panel renders from, not an error. +func TestOCPPChargersDisabled(t *testing.T) { + srv := New(&Deps{}) + out := getOCPPChargers(t, srv) + if out["enabled"] != false { + t.Fatalf("enabled = %v, want false", out["enabled"]) + } + if chargers, ok := out["chargers"].([]any); !ok || len(chargers) != 0 { + t.Fatalf("chargers = %v, want empty array", out["chargers"]) + } +} + +func TestOCPPChargersReportsSnapshotSorted(t *testing.T) { + cfg := &config.Config{OCPP: &config.OCPP{Enabled: true, PortV201: 8888}} + steerable := true + srv := New(&Deps{ + Cfg: cfg, + CfgMu: &sync.RWMutex{}, + OCPPChargers: func() map[string]ocpp.ChargerView { + return map[string]ocpp.ChargerView{ + "garage-right": {Online: true, Version: "2.0.1", Pending: true}, + "garage-left": { + Online: true, Connected: true, Charging: true, + PowerW: 7400, Version: "1.6", LastAmps: 10, + Vendor: "Charge Amps", Model: "Dawn", + FeatureProfiles: "Core,SmartCharging", Steerable: &steerable, + }, + } + }, + }) + out := getOCPPChargers(t, srv) + if out["enabled"] != true { + t.Fatalf("enabled = %v, want true", out["enabled"]) + } + // Unset port must surface as the default the listener actually took. + if out["port"] != float64(8887) { + t.Fatalf("port = %v, want 8887", out["port"]) + } + if out["port_v201"] != float64(8888) { + t.Fatalf("port_v201 = %v, want 8888", out["port_v201"]) + } + chargers, ok := out["chargers"].([]any) + if !ok || len(chargers) != 2 { + t.Fatalf("chargers = %v, want 2 entries", out["chargers"]) + } + first := chargers[0].(map[string]any) + if first["id"] != "garage-left" { + t.Fatalf("first id = %v, want garage-left (sorted)", first["id"]) + } + if first["vendor"] != "Charge Amps" || first["model"] != "Dawn" { + t.Fatalf("vendor/model = %v/%v", first["vendor"], first["model"]) + } + if first["charging"] != true || first["power_w"] != float64(7400) { + t.Fatalf("charging/power = %v/%v", first["charging"], first["power_w"]) + } + if first["last_amps"] != float64(10) || first["version"] != "1.6" { + t.Fatalf("last_amps/version = %v/%v", first["last_amps"], first["version"]) + } + // Quarantine state must reach the UI: pending surfaces on the pending + // charger and, being omitempty, stays absent from the adopted one. + if _, ok := first["pending"]; ok { + t.Fatalf("adopted charger should not carry pending, got %v", first["pending"]) + } + // Capability discovery must reach the UI, and stay tri-state: the probed + // charger reports its verdict, the unprobed one omits the field so the + // panel can say "not reported" rather than "cannot be steered". + if first["steerable"] != true || first["feature_profiles"] != "Core,SmartCharging" { + t.Fatalf("steerable/profiles = %v/%v", first["steerable"], first["feature_profiles"]) + } + second := chargers[1].(map[string]any) + if second["pending"] != true { + t.Fatalf("pending = %v, want true for garage-right", second["pending"]) + } + if _, ok := second["steerable"]; ok { + t.Fatalf("unprobed charger should omit steerable, got %v", second["steerable"]) + } +} diff --git a/go/internal/api/api_tiers_test.go b/go/internal/api/api_tiers_test.go index 52bc2ddac..b0233e6bd 100644 --- a/go/internal/api/api_tiers_test.go +++ b/go/internal/api/api_tiers_test.go @@ -193,6 +193,9 @@ func TestNoSecretBearingReadCrossesTheSession(t *testing.T) { "/api/support/dump", "/api/oauth/myuplink/start", "/api/oauth/myuplink/callback", + // vehicle_id is the RFID idTag the card presented, which is what + // authorizes a charge at the charger itself. + "/api/ocpp/chargers", } rig := newTieredSession(t, apiauth.RoleViewer) diff --git a/go/internal/config/config.go b/go/internal/config/config.go index a38d06d78..2037f0bf7 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "math" + "net" "net/url" "os" "path/filepath" @@ -36,12 +37,14 @@ type Config struct { EVCharger *EVCharger `yaml:"ev_charger,omitempty" json:"ev_charger,omitempty"` CalDAV *CalDAV `yaml:"caldav,omitempty" json:"caldav,omitempty"` Loadpoints []Loadpoint `yaml:"loadpoints,omitempty" json:"loadpoints,omitempty"` + Vehicles []Vehicle `yaml:"vehicles,omitempty" json:"vehicles,omitempty"` V2X *V2XPolicy `yaml:"v2x,omitempty" json:"v2x,omitempty"` Notifications *Notifications `yaml:"notifications,omitempty" json:"notifications,omitempty"` AppLink *AppLink `yaml:"app_link,omitempty" json:"app_link,omitempty"` FleetPing *FleetPing `yaml:"fleet_ping,omitempty" json:"fleet_ping,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"` // LoadWarnings collects recoverable problems Parse repaired instead of // refusing the file: an on-disk config an older version accepted must @@ -51,6 +54,152 @@ type Config struct { LoadWarnings []string `yaml:"-" json:"-"` } +// OCPP configures the built-in OCPP 1.6J and 2.0.1 Central System. 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. +// +// Disabled by default, and enabling it requires credentials. The socket cannot +// be restricted to one interface: the OCPP library builds its own listen +// address from the port alone, so it is open on every interface the host has. +// Bind therefore refuses the handshake for a connection that arrived +// elsewhere, which controls access without shrinking the attack surface — an +// empty Username or Password is still rejected rather than silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + + // Bind is the address chargers are served on. Empty or 0.0.0.0 accepts + // every interface, which is the default and what a flat home LAN wants. + // Set it to one LAN address to refuse chargers reaching the box any + // other way — over a VPN interface, say, or a second NIC. + Bind string `yaml:"bind,omitempty" json:"bind,omitempty"` + + 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"` + + // TLS serves wss:// instead of ws://. Optional, and worth the + // certificate management on any site where the charger and the box are + // not on the same trusted wire. + TLS *OCPPTLS `yaml:"tls,omitempty" json:"tls,omitempty"` + + // Chargers gives named charge points a credential of their own, so the + // shared password stops being enough to claim their identity. Optional + // and per charger: anything not listed keeps using Username/Password. + Chargers []OCPPCharger `yaml:"chargers,omitempty" json:"chargers,omitempty"` +} + +// OCPPTLS points at the certificate the OCPP listener presents, and optionally +// at the CA that signs the charge points allowed to connect. +type OCPPTLS struct { + CertFile string `yaml:"cert_file,omitempty" json:"cert_file,omitempty"` + KeyFile string `yaml:"key_file,omitempty" json:"key_file,omitempty"` + + // ClientCAFile turns on mutual TLS: every charge point must present a + // certificate signed by this CA. That is OCPP 2.0.1 security profile 3, + // and the only identity here that cannot be copied out of one charger's + // configuration and replayed by another device. + ClientCAFile string `yaml:"client_ca_file,omitempty" json:"client_ca_file,omitempty"` +} + +// OCPPCharger is one charge point's own credential. +// +// The ID is the identity the charger dials with — the last segment of its URL, +// and the same string a loadpoint's driver_name uses to adopt it. On OCPP the +// basic-auth username is that identity, so a charger listed here must present +// both, and a device holding only the shared password can no longer connect +// under its name. +type OCPPCharger struct { + ID string `yaml:"id" json:"id"` + Password string `yaml:"password,omitempty" json:"password,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) + } + // A bind address that does not parse would silently fall back to "every + // interface" — the opposite of what the operator asked for. + if o.Bind != "" && net.ParseIP(o.Bind) == nil { + return fmt.Errorf("ocpp.bind must be an IP address, got %q", o.Bind) + } + if err := o.TLS.validate(); err != nil { + return err + } + seen := make(map[string]bool, len(o.Chargers)) + for i, c := range o.Chargers { + if c.ID == "" { + return fmt.Errorf("ocpp.chargers[%d].id is required", i) + } + if seen[c.ID] { + return fmt.Errorf("ocpp.chargers has two entries for %q", c.ID) + } + seen[c.ID] = true + // An entry with no password would quietly fall back to the shared + // one, leaving the operator believing this charger was pinned to a + // credential of its own. + if c.Password == "" { + return fmt.Errorf("ocpp.chargers[%d] (%s) needs a password; remove the entry to use the shared one", i, c.ID) + } + } + return nil +} + +// validate checks the TLS section without touching the filesystem — the paths +// are read when the listener starts, which is where a missing file is +// reported. A nil section means plaintext ws://, which is the default. +func (t *OCPPTLS) validate() error { + if t == nil { + return nil + } + if t.CertFile == "" && t.KeyFile == "" { + if t.ClientCAFile != "" { + return errors.New("ocpp.tls.client_ca_file needs cert_file and key_file: client certificates are only verified on a TLS listener") + } + return nil + } + if t.CertFile == "" || t.KeyFile == "" { + return errors.New("ocpp.tls needs both cert_file and key_file") + } + return nil +} + +// ChargerSecrets is the per-charger credential map, keyed by charge point +// identity. Empty when none are configured. +func (o *OCPP) ChargerSecrets() map[string]string { + if o == nil || len(o.Chargers) == 0 { + return nil + } + out := make(map[string]string, len(o.Chargers)) + for _, c := range o.Chargers { + if c.ID != "" && c.Password != "" { + out[c.ID] = c.Password + } + } + return out +} + // AppLink controls the outbound connection the FTW app reaches this box // through. It defaults on when the section is absent; an explicit false is the // opt-out. One switch is enough because the relay is content-blind and fixed @@ -271,14 +420,23 @@ type Nova struct { // Loadpoint is one EV charge point the planner can reason about. // The planner and go/internal/loadpoint optimize battery + EV jointly. type Loadpoint struct { - ID string `yaml:"id" json:"id"` - DriverName string `yaml:"driver_name" json:"driver_name"` - MinChargeW float64 `yaml:"min_charge_w,omitempty" json:"min_charge_w,omitempty"` - MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` - AllowedStepsW []float64 `yaml:"allowed_steps_w,omitempty" json:"allowed_steps_w,omitempty"` - VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` - PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` - PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` + ID string `yaml:"id" json:"id"` + DriverName string `yaml:"driver_name" json:"driver_name"` + MinChargeW float64 `yaml:"min_charge_w,omitempty" json:"min_charge_w,omitempty"` + MaxChargeW float64 `yaml:"max_charge_w,omitempty" json:"max_charge_w,omitempty"` + AllowedStepsW []float64 `yaml:"allowed_steps_w,omitempty" json:"allowed_steps_w,omitempty"` + // VehicleCapacityWh is the usable battery capacity of ONE vehicle — the + // car this charger usually serves. It feeds the SoC estimate and the + // planner's energy sizing; charging works without it, and a wrong value + // costs planning accuracy, never safety. When several cars share the + // charger, add Vehicles profiles: a charging session that identifies + // the car (RFID idTag on 1.6, MacAddress/eMAID idToken on 2.0.1) + // switches the loadpoint to that car's capacity and policy for the + // session. A session matching no profile leaves this value in charge — + // the visitor default. + VehicleCapacityWh float64 `yaml:"vehicle_capacity_wh,omitempty" json:"vehicle_capacity_wh,omitempty"` + PluginSoC float64 `yaml:"plugin_soc,omitempty" json:"plugin_soc,omitempty"` + PluginSoCPct float64 `yaml:"plugin_soc_pct,omitempty" json:"plugin_soc_pct,omitempty"` // PhaseMode selects how the controller picks between 1Φ and 3Φ // delivery: "3p" (default) | "1p" | "auto". Empty == "3p" for @@ -289,6 +447,88 @@ type Loadpoint struct { SurplusOnly bool `yaml:"surplus_only,omitempty" json:"surplus_only,omitempty"` } +// Vehicle is a car profile the loadpoint can switch to when a charging +// session identifies the vehicle. Identification comes from the OCPP +// transaction: the RFID idTag on 1.6 (names the card — works only if the +// card lives in the car), a MacAddress (autocharge) or eMAID (ISO 15118 +// Plug & Charge) idToken on 2.0.1 (names the actual vehicle). A session +// whose identity matches no profile leaves the loadpoint's own settings +// untouched — that is the visitor default. Tracked upstream in issue #835. +type Vehicle struct { + // ID is the stable slug other config and logs refer to. + ID string `yaml:"id" json:"id"` + // Name is the human label the UI shows; falls back to ID. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + // CapacityWh is this car's usable battery capacity. Applied to the + // loadpoint for the session so SoC estimation and planner energy + // sizing follow the car actually plugged in. 0 = leave unchanged. + CapacityWh float64 `yaml:"capacity_wh,omitempty" json:"capacity_wh,omitempty"` + // Identifiers are the identity strings that mean "this car": RFID tag + // uids, MAC addresses, eMAIDs. Compared case-insensitively, trimmed. + Identifiers []string `yaml:"identifiers,omitempty" json:"identifiers,omitempty"` + // SurplusOnly, when the car is identified, sets the loadpoint's + // PV-surplus-only flag to exactly this value — charge this car from + // surplus PV alone (true) or allow grid charging (false). + SurplusOnly bool `yaml:"surplus_only,omitempty" json:"surplus_only,omitempty"` + // TargetSoC > 0 sets a charge target for the session (0–1), which is + // what hands the loadpoint to the planner: it fills toward the target + // in the cheapest tariff slots. 0 = no target, loadpoint keeps its own. + TargetSoC float64 `yaml:"target_soc,omitempty" json:"target_soc,omitempty"` + TargetSoCPct float64 `yaml:"target_soc_pct,omitempty" json:"target_soc_pct,omitempty"` +} + +// VehicleByIdentifier finds the vehicle profile claiming an identity string, +// or nil. Matching is case-insensitive on trimmed identifiers, so an eMAID +// or MAC compares the way the wire formats vary. +func (c *Config) VehicleByIdentifier(identifier string) *Vehicle { + want := strings.ToLower(strings.TrimSpace(identifier)) + if want == "" { + return nil + } + for i := range c.Vehicles { + for _, id := range c.Vehicles[i].Identifiers { + if strings.ToLower(strings.TrimSpace(id)) == want { + return &c.Vehicles[i] + } + } + } + return nil +} + +// validateVehicles keeps vehicle profiles unambiguous: unique ids, sane +// numbers, and no identifier claimed by two cars — the identify-then-apply +// path must never have to guess which profile wins. +func (c *Config) validateVehicles() error { + ids := make(map[string]bool, len(c.Vehicles)) + claimed := make(map[string]string, len(c.Vehicles)) + for _, v := range c.Vehicles { + if v.ID == "" { + return errors.New("vehicle: id is required") + } + if ids[v.ID] { + return fmt.Errorf("vehicle %q: duplicate id", v.ID) + } + ids[v.ID] = true + if v.CapacityWh < 0 { + return fmt.Errorf("vehicle %q: capacity_wh must be >= 0", v.ID) + } + if v.TargetSoC < 0 || v.TargetSoC > 1 { + return fmt.Errorf("vehicle %q: target_soc must be within 0..1", v.ID) + } + for _, ident := range v.Identifiers { + key := strings.ToLower(strings.TrimSpace(ident)) + if key == "" { + return fmt.Errorf("vehicle %q: empty identifier", v.ID) + } + if owner, dup := claimed[key]; dup { + return fmt.Errorf("vehicle %q: identifier %q already claimed by vehicle %q", v.ID, ident, owner) + } + claimed[key] = v.ID + } + } + return nil +} + // V2XPolicy is the opt-in policy envelope for automatic V2X use. The // current V2X pilot still dispatches only manual operator commands; this // config lets the API expose "what would be safe right now?" before the @@ -1176,6 +1416,24 @@ 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. + // The per-charger passwords are the same secret with a narrower blast + // radius, and the slice has to be copied rather than blanked in place — + // the struct copy above shares its backing array with the live config. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + if len(cp.Chargers) > 0 { + chargers := make([]OCPPCharger, len(cp.Chargers)) + for i, c := range cp.Chargers { + c.Password = "" + chargers[i] = c + } + cp.Chargers = chargers + } + out.OCPP = &cp + } if out.Price != nil { cp := *out.Price cp.APIKey = "" @@ -1248,6 +1506,27 @@ 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 { + if incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } + // Per-charger passwords are masked the same way, and are matched by + // charger id rather than position: the settings UI can reorder the + // list or drop an entry, and restoring by index would then hand one + // charger another's credential. + if len(incoming.OCPP.Chargers) > 0 { + stored := existing.OCPP.ChargerSecrets() + for i := range incoming.OCPP.Chargers { + c := &incoming.OCPP.Chargers[i] + if c.Password == "" { + c.Password = stored[c.ID] + } + } + } + } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password } @@ -1751,6 +2030,12 @@ func (c *Config) Validate() error { if err := c.FleetPing.Validate(); err != nil { return err } + if err := c.OCPP.Validate(); err != nil { + return err + } + if err := c.validateVehicles(); 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 000000000..c7d9e0429 --- /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_auth_test.go b/go/internal/config/ocpp_auth_test.go new file mode 100644 index 000000000..e9583ae37 --- /dev/null +++ b/go/internal/config/ocpp_auth_test.go @@ -0,0 +1,177 @@ +package config + +import "testing" + +func enabledOCPP(mut func(o *OCPP)) *OCPP { + o := &OCPP{Enabled: true, Username: "ftw", Password: "shared-secret"} + if mut != nil { + mut(o) + } + return o +} + +func TestOCPPBindValidation(t *testing.T) { + tests := []struct { + name string + bind string + wantErr bool + }{ + {name: "empty means every interface", bind: ""}, + {name: "unspecified", bind: "0.0.0.0"}, + {name: "a LAN address", bind: "192.168.1.10"}, + {name: "IPv6", bind: "::1"}, + // A hostname would silently fall back to "every interface", which is + // the opposite of what the operator asked for. + {name: "hostname", bind: "ftw.local", wantErr: true}, + {name: "address with a port", bind: "192.168.1.10:8887", wantErr: true}, + {name: "nonsense", bind: "everywhere", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := enabledOCPP(func(o *OCPP) { o.Bind = tc.bind }).Validate() + if tc.wantErr && err == nil { + t.Errorf("bind %q accepted, want rejected", tc.bind) + } + if !tc.wantErr && err != nil { + t.Errorf("bind %q rejected: %v", tc.bind, err) + } + }) + } +} + +func TestOCPPTLSValidation(t *testing.T) { + tests := []struct { + name string + tls *OCPPTLS + wantErr bool + }{ + {name: "absent"}, + {name: "empty section"}, + {name: "cert and key", tls: &OCPPTLS{CertFile: "c.pem", KeyFile: "k.pem"}}, + {name: "mutual TLS", tls: &OCPPTLS{CertFile: "c.pem", KeyFile: "k.pem", ClientCAFile: "ca.pem"}}, + {name: "cert without key", tls: &OCPPTLS{CertFile: "c.pem"}, wantErr: true}, + {name: "key without cert", tls: &OCPPTLS{KeyFile: "k.pem"}, wantErr: true}, + // Client certificates are only verified on a TLS listener, so this + // would look like mutual TLS and be plaintext. + {name: "client CA alone", tls: &OCPPTLS{ClientCAFile: "ca.pem"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + o := enabledOCPP(func(o *OCPP) { o.TLS = tc.tls }) + if tc.name == "empty section" { + o.TLS = &OCPPTLS{} + } + err := o.Validate() + if tc.wantErr && err == nil { + t.Errorf("%+v accepted, want rejected", tc.tls) + } + if !tc.wantErr && err != nil { + t.Errorf("%+v rejected: %v", tc.tls, err) + } + }) + } +} + +func TestOCPPPerChargerValidation(t *testing.T) { + tests := []struct { + name string + chargers []OCPPCharger + wantErr bool + }{ + {name: "none"}, + {name: "one", chargers: []OCPPCharger{{ID: "garage", Password: "x"}}}, + {name: "two", chargers: []OCPPCharger{{ID: "garage", Password: "x"}, {ID: "carport", Password: "y"}}}, + {name: "no id", chargers: []OCPPCharger{{Password: "x"}}, wantErr: true}, + // An entry with no password would quietly fall back to the shared + // one while the operator believes it is pinned. + {name: "no password", chargers: []OCPPCharger{{ID: "garage"}}, wantErr: true}, + {name: "duplicate id", chargers: []OCPPCharger{{ID: "garage", Password: "x"}, {ID: "garage", Password: "y"}}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := enabledOCPP(func(o *OCPP) { o.Chargers = tc.chargers }).Validate() + if tc.wantErr && err == nil { + t.Errorf("%+v accepted, want rejected", tc.chargers) + } + if !tc.wantErr && err != nil { + t.Errorf("%+v rejected: %v", tc.chargers, err) + } + }) + } +} + +// Per-charger passwords are the same secret as the shared one with a narrower +// blast radius, so they get the same treatment: never served, never wiped by a +// settings save that returns the masked value. +func TestOCPPPerChargerPasswordsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{ + {ID: "garage", Password: "garage-secret"}, + {ID: "carport", Password: "carport-secret"}, + } + })} + + masked := stored.MaskSecrets() + for i, c := range masked.OCPP.Chargers { + if c.Password != "" { + t.Errorf("charger %d (%s) leaked its password: %q", i, c.ID, c.Password) + } + if c.ID == "" { + t.Errorf("charger %d lost its id to masking", i) + } + } + // The struct copy shares its backing array with the live config, so this + // is the assertion that catches blanking in place. + if stored.OCPP.Chargers[0].Password != "garage-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Chargers[0].Password) + } + + // A save that round-trips the masked values must not wipe them, and must + // match by id — the settings UI may reorder or drop entries, and + // restoring by position would hand one charger another's credential. + incoming := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{ + {ID: "carport", Password: ""}, + {ID: "garage", Password: ""}, + } + })} + incoming.PreserveMaskedSecrets(stored) + if got := incoming.OCPP.Chargers[0].Password; got != "carport-secret" { + t.Errorf("carport got %q, want carport-secret", got) + } + if got := incoming.OCPP.Chargers[1].Password; got != "garage-secret" { + t.Errorf("garage got %q, want garage-secret", got) + } + + // A genuinely new password still wins. + changed := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "garage", Password: "rotated"}} + })} + changed.PreserveMaskedSecrets(stored) + if got := changed.OCPP.Chargers[0].Password; got != "rotated" { + t.Errorf("rotated password not kept, got %q", got) + } + + // A charger with no stored secret stays empty rather than inheriting one. + fresh := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "driveway", Password: ""}} + })} + fresh.PreserveMaskedSecrets(stored) + if got := fresh.OCPP.Chargers[0].Password; got != "" { + t.Errorf("an unknown charger inherited a secret: %q", got) + } +} + +func TestOCPPChargerSecrets(t *testing.T) { + var nilOCPP *OCPP + if got := nilOCPP.ChargerSecrets(); got != nil { + t.Errorf("nil section: got %v, want nil", got) + } + o := enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "garage", Password: "x"}} + }) + secrets := o.ChargerSecrets() + if len(secrets) != 1 || secrets["garage"] != "x" { + t.Errorf("got %v, want one garage entry", secrets) + } +} diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go new file mode 100644 index 000000000..abe403e34 --- /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/config/units.go b/go/internal/config/units.go index 783209b5e..f79e8f32b 100644 --- a/go/internal/config/units.go +++ b/go/internal/config/units.go @@ -37,6 +37,11 @@ func (c *Config) NormalizeUnits() { lp.PluginSoC = pickFraction(lp.PluginSoC, lp.PluginSoCPct) lp.PluginSoCPct = 0 } + for i := range c.Vehicles { + v := &c.Vehicles[i] + v.TargetSoC = pickFraction(v.TargetSoC, v.TargetSoCPct) + v.TargetSoCPct = 0 + } c.Site.PVSurplusAbsorbSoCCap = pickFraction(c.Site.PVSurplusAbsorbSoCCap, c.Site.PVSurplusAbsorbSoCCapPct) c.Site.PVSurplusAbsorbSoCCapPct = 0 } diff --git a/go/internal/config/units_test.go b/go/internal/config/units_test.go index d7bfbcf81..dad658451 100644 --- a/go/internal/config/units_test.go +++ b/go/internal/config/units_test.go @@ -13,8 +13,9 @@ func TestNormalizeUnitsFoldsLegacyKWpAndPercent(t *testing.T) { {Name: "south", KWp: 6, TiltDeg: &tilt, AzimuthDeg: &az}, }, }, - CalDAV: &CalDAV{Enabled: true, EVDefaultTargetSoCPct: 80}, - Site: Site{PVSurplusAbsorbSoCCapPct: 88}, + CalDAV: &CalDAV{Enabled: true, EVDefaultTargetSoCPct: 80}, + Site: Site{PVSurplusAbsorbSoCCapPct: 88}, + Vehicles: []Vehicle{{ID: "leaf", TargetSoCPct: 80}}, } c.NormalizeUnits() if c.Planner.SoCMin != 0.10 || c.Planner.SoCMax != 0.90 { @@ -32,4 +33,7 @@ func TestNormalizeUnitsFoldsLegacyKWpAndPercent(t *testing.T) { if c.Site.PVSurplusAbsorbSoCCap != 0.88 { t.Fatalf("absorb cap = %v, want 0.88", c.Site.PVSurplusAbsorbSoCCap) } + if c.Vehicles[0].TargetSoC != 0.80 || c.Vehicles[0].TargetSoCPct != 0 { + t.Fatalf("vehicle target SoC = %+v, want 0.80 with legacy pct cleared", c.Vehicles[0]) + } } diff --git a/go/internal/config/vehicles_test.go b/go/internal/config/vehicles_test.go new file mode 100644 index 000000000..49a4e025f --- /dev/null +++ b/go/internal/config/vehicles_test.go @@ -0,0 +1,47 @@ +package config + +import "testing" + +// Vehicle profiles are applied by identity match at transaction start, so +// validation must guarantee the match can never be ambiguous, and lookup +// must tolerate the case/whitespace variation of RFID tags, MACs and eMAIDs. +func TestVehicleValidationAndLookup(t *testing.T) { + c := &Config{Vehicles: []Vehicle{ + {ID: "leaf", Name: "Nissan Leaf", CapacityWh: 40000, + Identifiers: []string{"04A2B3C4", "aa:bb:cc:dd:ee:ff"}}, + {ID: "model3", CapacityWh: 75000, Identifiers: []string{"DEADBEEF"}, + SurplusOnly: true, TargetSoC: 0.80}, + }} + if err := c.validateVehicles(); err != nil { + t.Fatalf("valid vehicles rejected: %v", err) + } + if v := c.VehicleByIdentifier(" aa:BB:cc:DD:ee:FF "); v == nil || v.ID != "leaf" { + t.Fatalf("MAC should match leaf, got %+v", v) + } + if v := c.VehicleByIdentifier("deadbeef"); v == nil || v.ID != "model3" { + t.Fatalf("tag should match model3, got %+v", v) + } + if v := c.VehicleByIdentifier("unknown"); v != nil { + t.Fatalf("unknown identifier matched %+v", v) + } + if v := c.VehicleByIdentifier(" "); v != nil { + t.Fatalf("blank identifier matched %+v", v) + } + + dup := &Config{Vehicles: []Vehicle{ + {ID: "a", Identifiers: []string{"TAG1"}}, + {ID: "b", Identifiers: []string{" tag1 "}}, + }} + if err := dup.validateVehicles(); err == nil { + t.Fatal("identifier claimed by two vehicles should fail validation") + } + if err := (&Config{Vehicles: []Vehicle{{ID: "a"}, {ID: "a"}}}).validateVehicles(); err == nil { + t.Fatal("duplicate vehicle id should fail validation") + } + if err := (&Config{Vehicles: []Vehicle{{ID: "x", TargetSoC: 1.5}}}).validateVehicles(); err == nil { + t.Fatal("target_soc > 1 should fail validation") + } + if err := (&Config{Vehicles: []Vehicle{{ID: "x", Identifiers: []string{" "}}}}).validateVehicles(); err == nil { + t.Fatal("blank identifier should fail validation") + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index d4ae1b9ec..50394459f 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -143,6 +143,9 @@ type State struct { VehicleDriver string `json:"vehicle_driver,omitempty"` VehicleStale bool `json:"vehicle_stale,omitempty"` SoCSource string `json:"soc_source,omitempty"` + // VehicleName is the vehicle profile the session identified (via the + // charging transaction's idTag/idToken), empty when none matched. + VehicleName string `json:"vehicle_name,omitempty"` // MinChargeW / MaxChargeW / AllowedStepsW are repeated here so the // UI has everything for rendering in one fetch. @@ -269,6 +272,22 @@ type loadpointRuntime struct { // every plug-in transition (prev !pluggedIn → now pluggedIn). sessionPluginSoC float64 + // vehicleName is the vehicle profile applied for this session after + // the charging transaction identified the car (Manager. + // ApplyVehicleProfile); baseCapacityWh remembers the capacity to + // restore on plug-out — the profile is session-scoped, the next car + // may be a different one. + vehicleName string + baseCapacityWh float64 + + // capacityFromCar is set when the vehicle itself reported its battery + // capacity (OCPP 2.0.1 NotifyEVChargingNeeds), which outranks both the + // configured value and a profile's — one is measured, the others are + // an operator's estimate of the car that usually parks here. It shares + // baseCapacityWh with vehicleName: whichever arrives first snapshots + // the configured capacity, and plug-out restores it either way. + capacityFromCar bool + // schedule carries the operator's persistent intent. Empty when // none is set. Survives config hot-reload because Load() copies // it across from the previous runtime row. @@ -392,6 +411,17 @@ func (m *Manager) Load(cfgs []Config) { lp.chargingSteadySince = existing.chargingSteadySince lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed + lp.vehicleName = existing.vehicleName + lp.capacityFromCar = existing.capacityFromCar + if existing.vehicleName != "" || existing.capacityFromCar { + // An identified car survives config hot-reload: keep the + // session's applied capacity, but re-base the plug-out + // restore on the NEW config's value. + lp.baseCapacityWh = c.VehicleCapacityWh + if existing.VehicleCapacityWh > 0 { + lp.Config.VehicleCapacityWh = existing.VehicleCapacityWh + } + } } newByID[c.ID] = lp newOrder = append(newOrder, c.ID) @@ -515,6 +545,14 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.notRequestingSince = time.Time{} lp.sessionComplete = false lp.socSource = "" + if lp.vehicleName != "" || lp.capacityFromCar { + // The identified car left with its session — the next one may + // be different, so restore the loadpoint's own capacity. + lp.VehicleCapacityWh = lp.baseCapacityWh + lp.vehicleName = "" + lp.capacityFromCar = false + lp.baseCapacityWh = 0 + } } lp.pluggedIn = pluggedIn lp.currentPowerW = powerW @@ -725,6 +763,62 @@ func (m *Manager) HydrateSurplusOnly(load func(id string) (bool, bool)) { } } +// ApplyVehicleProfile switches the loadpoint to an identified car for the +// rest of the session: capacityWh (when > 0) replaces the configured +// vehicle capacity so SoC inference and planner energy sizing follow the +// car actually plugged in. Reverted on plug-out; survives config +// hot-reloads (Load carries it across). The caller applies the profile's +// policy fields (surplus_only, target) through the ordinary setters. +// Returns false for an unknown loadpoint id. +func (m *Manager) ApplyVehicleProfile(id, vehicleName string, capacityWh float64) bool { + m.mu.Lock() + defer m.mu.Unlock() + lp, ok := m.byID[id] + if !ok { + return false + } + if lp.vehicleName == "" && !lp.capacityFromCar { + lp.baseCapacityWh = lp.VehicleCapacityWh + } + lp.vehicleName = vehicleName + // A capacity the car measured for this session outranks the profile's + // configured guess, whichever arrived first. + if capacityWh > 0 && !lp.capacityFromCar { + lp.VehicleCapacityWh = capacityWh + } + lp.updatedAtMs = m.now().UnixMilli() + return true +} + +// SetSessionCapacityWh overrides the vehicle capacity for the rest of the +// session with a figure the car itself reported — OCPP 2.0.1 +// NotifyEVChargingNeeds carries the EV's own battery capacity. +// +// Measured outranks configured, so this wins over both vehicle_capacity_wh and +// a vehicle profile applied for the same session, in either order. It shares +// the profile's session scope: plug-out restores the loadpoint's own value, +// because the next car may be a different one. +// +// Returns false for an unknown loadpoint id or a non-positive capacity. +func (m *Manager) SetSessionCapacityWh(id string, capacityWh float64) bool { + if capacityWh <= 0 { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + lp, ok := m.byID[id] + if !ok { + return false + } + if lp.vehicleName == "" && !lp.capacityFromCar { + lp.baseCapacityWh = lp.VehicleCapacityWh + } + lp.capacityFromCar = true + lp.VehicleCapacityWh = capacityWh + lp.updatedAtMs = m.now().UnixMilli() + return true +} + // SetCurrentSoC lets an operator correct the inferred vehicle SoC // mid-session. Chargers like Easee don't report the vehicle's actual // BMS state, so the manager defaults to @@ -818,6 +912,7 @@ func (lp *loadpointRuntime) snapshot() State { SurplusOnly: lp.Config.SurplusOnly, Schedule: lp.schedule, SoCSource: lp.socSource, + VehicleName: lp.vehicleName, } } diff --git a/go/internal/loadpoint/session_capacity_test.go b/go/internal/loadpoint/session_capacity_test.go new file mode 100644 index 000000000..f01b68fdd --- /dev/null +++ b/go/internal/loadpoint/session_capacity_test.go @@ -0,0 +1,90 @@ +package loadpoint + +import "testing" + +// A capacity the car itself reported (OCPP 2.0.1 NotifyEVChargingNeeds) is +// session-scoped exactly like a vehicle profile: it steers SoC inference while +// the car is plugged in, survives a config hot-reload, and reverts on plug-out +// because the next car may be a different one. +func TestSetSessionCapacitySessionScoped(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + if m.SetSessionCapacityWh("nope", 40000) { + t.Fatal("unknown loadpoint should not apply") + } + if m.SetSessionCapacityWh("a", 0) { + t.Fatal("a car that reported no capacity must not zero the loadpoint's own") + } + if !m.SetSessionCapacityWh("a", 40000) { + t.Fatal("apply failed") + } + + // 20 kWh delivered on a 40 kWh car = +50 points from the 30 % anchor. + // With the configured 60 kWh it would only be +33. + m.Observe("a", true, 7000, 20000, true) + st, _ := m.State("a") + if st.CurrentSoC < 0.79 || st.CurrentSoC > 0.81 { + t.Fatalf("SoC should follow the reported 40 kWh capacity, got %v", st.CurrentSoC) + } + + // Hot-reload mid-session keeps the car's figure, not the config's. + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + cfgs := m.Configs() + if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 40000 { + t.Fatalf("reported capacity should survive config reload, got %+v", cfgs) + } + + // Plug-out restores the loadpoint's own capacity. + m.Observe("a", false, 0, 0, false) + cfgs = m.Configs() + if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 60000 { + t.Fatalf("capacity should restore to the configured 60000, got %+v", cfgs) + } +} + +// Measured outranks configured, whichever order the two arrive in: a vehicle +// profile is an operator's estimate of the car that usually parks here, the +// NotifyEVChargingNeeds figure is the car that is actually plugged in. +func TestReportedCapacityOutranksProfile(t *testing.T) { + t.Run("profile first", func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + m.ApplyVehicleProfile("a", "Leaf", 40000) + m.SetSessionCapacityWh("a", 77000) + + if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { + t.Fatalf("the car's own capacity should win, got %v", got) + } + // Plug-out still restores the configured value, not the profile's. + m.Observe("a", false, 0, 0, false) + if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { + t.Fatalf("plug-out should restore the configured 60000, got %v", got) + } + }) + + t.Run("car first", func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + m.SetSessionCapacityWh("a", 77000) + m.ApplyVehicleProfile("a", "Leaf", 40000) + + if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { + t.Fatalf("a later profile must not overwrite the car's own capacity, got %v", got) + } + // The profile still names the car for the UI. + st, _ := m.State("a") + if st.VehicleName != "Leaf" { + t.Fatalf("vehicle name should still apply, got %+v", st) + } + m.Observe("a", false, 0, 0, false) + if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { + t.Fatalf("plug-out should restore the configured 60000, got %v", got) + } + }) +} diff --git a/go/internal/loadpoint/vehicle_profile_test.go b/go/internal/loadpoint/vehicle_profile_test.go new file mode 100644 index 000000000..ef0108412 --- /dev/null +++ b/go/internal/loadpoint/vehicle_profile_test.go @@ -0,0 +1,50 @@ +package loadpoint + +import "testing" + +// A vehicle profile is session-scoped: applied when the charging transaction +// identifies the car, steering SoC inference with that car's capacity, and +// reverted on plug-out because the next car may be a different one. +func TestApplyVehicleProfileSessionScoped(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + if m.ApplyVehicleProfile("nope", "Leaf", 40000) { + t.Fatal("unknown loadpoint should not apply") + } + if !m.ApplyVehicleProfile("a", "Leaf", 40000) { + t.Fatal("apply failed") + } + st, _ := m.State("a") + if st.VehicleName != "Leaf" { + t.Fatalf("vehicle name missing from state: %+v", st) + } + + // 20 kWh delivered on a 40 kWh car = +50 points from the 30 % anchor. + // With the configured 60 kWh it would only be +33 — the applied + // capacity must win. + m.Observe("a", true, 7000, 20000, true) + st, _ = m.State("a") + if st.CurrentSoC < 0.79 || st.CurrentSoC > 0.81 { + t.Fatalf("SoC should follow the applied 40 kWh capacity, got %v", st.CurrentSoC) + } + + // Hot-reload mid-session: the identified car survives. + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + st, _ = m.State("a") + if st.VehicleName != "Leaf" { + t.Fatalf("vehicle should survive config reload, got %+v", st) + } + + // Plug-out reverts to the loadpoint's own capacity. + m.Observe("a", false, 0, 0, false) + st, _ = m.State("a") + if st.VehicleName != "" { + t.Fatalf("vehicle should clear on plug-out: %+v", st) + } + cfgs := m.Configs() + if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 60000 { + t.Fatalf("capacity should restore to the configured 60000, got %+v", cfgs) + } +} diff --git a/go/internal/ocpp/auth.go b/go/internal/ocpp/auth.go new file mode 100644 index 000000000..1bb7ab663 --- /dev/null +++ b/go/internal/ocpp/auth.go @@ -0,0 +1,178 @@ +package ocpp + +// Who may speak, as whom, and from where. +// +// Three gates sit in front of a charge point, and they answer different +// questions. Basic auth proves knowledge of a secret. The identity binding +// below proves the connection is entitled to the identity it claims. The +// quarantine in handlers.go decides whether an authenticated, entitled charger +// is part of this site at all. +// +// The library hands us two callbacks. SetBasicAuthHandler sees the credential +// but not the identity; SetCheckClientHandler sees the identity and the whole +// HTTP request — including the credential, the local address the connection +// landed on, and any client certificate. Everything that needs both therefore +// happens in checkClient, which the library calls immediately after basic auth +// and before the WebSocket upgrade. + +import ( + "crypto/subtle" + "log/slog" + "net" + "net/http" +) + +// authorizer holds the credentials and the interface restriction for one +// listener. The zero value authorizes everything, which is what an OCPP +// section with no username configured asks for. +type authorizer struct { + // sharedUser and sharedPass are the site-wide credential. Every charge + // point without one of its own uses it. + sharedUser string + sharedPass string + + // perCharger maps a charge point identity to its own password. A + // charger listed here must present that password AND connect under + // that identity — see checkClient. This is what makes an adopted + // charger un-impersonable by anything holding only the shared secret. + perCharger map[string]string + + // bindIP is the address the operator asked the listener to serve on. + // Nil, unspecified (0.0.0.0 / ::) means every interface. + bindIP net.IP +} + +// newAuthorizer builds the gate for a listener from config. +func newAuthorizer(cfg *Config) *authorizer { + a := &authorizer{ + sharedUser: cfg.Username, + sharedPass: cfg.Password, + } + if len(cfg.ChargerSecrets) > 0 { + a.perCharger = make(map[string]string, len(cfg.ChargerSecrets)) + for id, pass := range cfg.ChargerSecrets { + if id != "" && pass != "" { + a.perCharger[id] = pass + } + } + } + if ip := net.ParseIP(cfg.Bind); ip != nil && !ip.IsUnspecified() { + a.bindIP = ip + } + return a +} + +// requiresCredential reports whether any credential is configured at all. +// +// The library treats a registered basic-auth handler as "credentials are +// mandatory" and answers 401 to a charger that sends none, so the handler must +// stay unregistered when nothing is configured — otherwise enabling OCPP with +// no username would lock out every charger instead of admitting them all. +func (a *authorizer) requiresCredential() bool { + if a == nil { + return false + } + return a.sharedUser != "" || a.sharedPass != "" || len(a.perCharger) > 0 +} + +// secretEqual compares in constant time so a wrong password cannot be found +// one character at a time. +func secretEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// basicAuth is the first gate: does this connection know a secret we issued? +// +// It cannot yet tell whether the credential belongs to the identity being +// claimed — the library does not pass the URL here — so it accepts any +// credential we recognise and leaves the binding to checkClient. On OCPP the +// basic-auth username is the charge point identity, which is why a per-charger +// secret is looked up by username. +func (a *authorizer) basicAuth(user, pass string) bool { + if a == nil { + return true + } + if secret, ok := a.perCharger[user]; ok { + return secretEqual(pass, secret) + } + if a.sharedUser == "" && a.sharedPass == "" { + // No credential configured. Validation refuses this for an enabled + // server, so it is reachable only in tests. + return true + } + return user == a.sharedUser && secretEqual(pass, a.sharedPass) +} + +// checkClient is the second gate, and the one that closes impersonation. +// +// A charge point picks its own identity — it is the last segment of the URL it +// dialled — so "it authenticated" has never proved which device it is. Where a +// charger has its own credential, this requires the connection to present that +// exact credential under that exact identity: the shared password no longer +// buys an attacker an adopted charger's name, only a pending row. +// +// It also enforces the configured bind address. The library builds its listen +// address from the port alone, so the socket itself is unavoidably on every +// interface; refusing the handshake here is what actually stops a charger from +// talking to us over one the operator did not offer. A port scan still sees an +// open port — this is an access control, not a smaller attack surface. +func (a *authorizer) checkClient(id string, r *http.Request) bool { + if a == nil { + return true + } + if !a.allowedLocalAddr(r) { + slog.Warn("ocpp: refused a charge point that arrived on an address the server is not offered on", + "charger", id, "bind", a.bindIP.String(), "arrived_on", localAddr(r)) + return false + } + secret, hasOwn := a.perCharger[id] + if !hasOwn { + return true + } + user, pass, ok := r.BasicAuth() + if !ok || user != id || !secretEqual(pass, secret) { + slog.Warn("ocpp: refused a connection claiming a charger that has its own credential", + "charger", id, "presented_user", user) + return false + } + return true +} + +// allowedLocalAddr reports whether the connection landed on the interface the +// operator asked for. Always true when no specific bind address is set. +func (a *authorizer) allowedLocalAddr(r *http.Request) bool { + if a.bindIP == nil { + return true + } + addr := localAddr(r) + if addr == "" { + // No local address on the request context means we cannot tell, and + // refusing every connection is worse than the status quo ante. + return true + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + ip := net.ParseIP(host) + if ip == nil { + return true + } + // Equal treats an IPv4-mapped IPv6 address as the IPv4 it maps to, which + // is how a dual-stack listener reports an IPv4 connection. + return a.bindIP.Equal(ip) +} + +// localAddr is the address on this host that the connection arrived at, which +// net/http puts on every request's context. +func localAddr(r *http.Request) string { + if r == nil { + return "" + } + v := r.Context().Value(http.LocalAddrContextKey) + addr, ok := v.(net.Addr) + if !ok || addr == nil { + return "" + } + return addr.String() +} diff --git a/go/internal/ocpp/auth_test.go b/go/internal/ocpp/auth_test.go new file mode 100644 index 000000000..b09bc4a6a --- /dev/null +++ b/go/internal/ocpp/auth_test.go @@ -0,0 +1,234 @@ +package ocpp + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// requestFrom builds the request the library hands checkClient: basic auth in +// the header, and the local address net/http puts on every connection context. +func requestFrom(t *testing.T, user, pass, arrivedOn string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/garage", nil) + if user != "" || pass != "" { + r.SetBasicAuth(user, pass) + } + if arrivedOn != "" { + addr, err := net.ResolveTCPAddr("tcp", arrivedOn) + if err != nil { + t.Fatalf("resolve %q: %v", arrivedOn, err) + } + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, addr) + r = r.WithContext(ctx) + } + return r +} + +// A charger with a credential of its own cannot be impersonated by something +// holding only the shared password. That is the whole point of the feature: +// identity is client-chosen, so authenticating has never proved which device +// is on the other end. +func TestPerChargerCredentialBlocksImpersonation(t *testing.T) { + a := newAuthorizer(&Config{ + Username: "ftw", + Password: "shared-secret", + ChargerSecrets: map[string]string{"garage": "garage-only-secret"}, + }) + + t.Run("its own credential is accepted", func(t *testing.T) { + if !a.basicAuth("garage", "garage-only-secret") { + t.Error("basic auth rejected the charger's own credential") + } + if !a.checkClient("garage", requestFrom(t, "garage", "garage-only-secret", "")) { + t.Error("checkClient rejected the charger's own credential") + } + }) + + t.Run("the shared credential no longer buys its name", func(t *testing.T) { + // Basic auth passes — the shared secret is real — and the identity + // binding is what refuses. + if !a.basicAuth("ftw", "shared-secret") { + t.Fatal("the shared credential should still authenticate") + } + if a.checkClient("garage", requestFrom(t, "ftw", "shared-secret", "")) { + t.Error("the shared password claimed a charger that has its own credential") + } + }) + + t.Run("its own password under another username is refused", func(t *testing.T) { + if a.checkClient("garage", requestFrom(t, "ftw", "garage-only-secret", "")) { + t.Error("accepted the charger's password presented under another identity") + } + }) + + t.Run("a wrong password for its own name is refused", func(t *testing.T) { + if a.basicAuth("garage", "shared-secret") { + t.Error("a charger with its own credential fell back to the shared one") + } + if a.checkClient("garage", requestFrom(t, "garage", "nope", "")) { + t.Error("accepted a wrong password") + } + }) + + t.Run("chargers without one keep using the shared credential", func(t *testing.T) { + if !a.basicAuth("ftw", "shared-secret") { + t.Error("shared credential rejected") + } + if !a.checkClient("carport", requestFrom(t, "ftw", "shared-secret", "")) { + t.Error("a charger with no credential of its own should use the shared one") + } + }) +} + +// Bind is enforced at the handshake because the socket cannot be pinned: the +// library builds its listen address from the port alone. +func TestBindAddressIsEnforcedAtTheHandshake(t *testing.T) { + tests := []struct { + name string + bind string + arrivedOn string + want bool + }{ + {name: "same address", bind: "192.168.1.10", arrivedOn: "192.168.1.10:8887", want: true}, + {name: "another interface", bind: "192.168.1.10", arrivedOn: "10.8.0.1:8887", want: false}, + {name: "loopback when bound to the LAN", bind: "192.168.1.10", arrivedOn: "127.0.0.1:8887", want: false}, + {name: "unspecified accepts anything", bind: "0.0.0.0", arrivedOn: "10.8.0.1:8887", want: true}, + {name: "empty accepts anything", bind: "", arrivedOn: "10.8.0.1:8887", want: true}, + // A dual-stack listener reports an IPv4 connection as v4-mapped v6. + {name: "v4-mapped v6 matches its v4", bind: "192.168.1.10", arrivedOn: "[::ffff:192.168.1.10]:8887", want: true}, + // Not knowing where it landed must not lock every charger out. + {name: "no local address is allowed", bind: "192.168.1.10", arrivedOn: "", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := newAuthorizer(&Config{Bind: tc.bind}) + got := a.checkClient("garage", requestFrom(t, "", "", tc.arrivedOn)) + if got != tc.want { + t.Errorf("checkClient on %s with bind %q: got %v, want %v", + tc.arrivedOn, tc.bind, got, tc.want) + } + }) + } +} + +// With no credentials configured the basic-auth handler must stay unregistered +// — the library answers 401 to a charger that sends none whenever a handler +// exists, so registering it would lock out every charger instead of admitting +// them all. +func TestNoCredentialsMeansNoBasicAuthHandler(t *testing.T) { + if newAuthorizer(&Config{}).requiresCredential() { + t.Error("an OCPP section with no credentials should not demand one") + } + if !newAuthorizer(&Config{Username: "ftw", Password: "x"}).requiresCredential() { + t.Error("a shared credential should be demanded") + } + if !newAuthorizer(&Config{ChargerSecrets: map[string]string{"garage": "x"}}).requiresCredential() { + t.Error("a per-charger credential should be demanded") + } +} + +// TLS has to fail loudly. An operator who asked for wss:// and silently got +// ws:// would have no way to tell the link was never encrypted. +func TestTLSMisconfigurationRefusesToStart(t *testing.T) { + tests := []struct { + name string + tls *TLSConfig + }{ + {name: "cert without key", tls: &TLSConfig{CertFile: "cert.pem"}}, + {name: "key without cert", tls: &TLSConfig{KeyFile: "key.pem"}}, + {name: "cert file missing", tls: &TLSConfig{CertFile: "no-such-cert.pem", KeyFile: "no-such-key.pem"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: freePort(t), TLS: tc.tls} + if _, err := Start(context.Background(), cfg, telemetry.NewStore()); err == nil { + t.Fatal("a broken TLS config started anyway, serving plaintext") + } + }) + } +} + +func TestSchemeFollowsTLS(t *testing.T) { + if got := (&Config{}).Scheme(); got != "ws" { + t.Errorf("plaintext scheme: got %q, want ws", got) + } + cfg := &Config{TLS: &TLSConfig{CertFile: "c.pem", KeyFile: "k.pem"}} + if got := cfg.Scheme(); got != "wss" { + t.Errorf("TLS scheme: got %q, want wss", got) + } +} + +// End to end over a real connection: the library must actually consult both +// gates, in the order that makes the identity binding effective. +func TestPerChargerCredentialOverTheWire(t *testing.T) { + port := freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: port, + HeartbeatIntervalS: 60, + Username: "ftw", + Password: "shared-secret", + ChargerSecrets: map[string]string{"garage": "garage-only-secret"}, + ApprovedIDs: []string{"garage"}, + } + srv, err := Start(context.Background(), cfg, telemetry.NewStore()) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + waitForListener(t, port) + + connect := func(t *testing.T, id, user, pass string) error { + t.Helper() + client := ws.NewClient() + client.SetBasicAuth(user, pass) + cp := ocpp16.NewChargePoint(id, nil, client) + err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)) + if err == nil { + t.Cleanup(cp.Stop) + } + return err + } + + if err := connect(t, "garage", "garage", "garage-only-secret"); err != nil { + t.Fatalf("the charger's own credential was refused: %v", err) + } + if err := connect(t, "garage-impostor", "ftw", "shared-secret"); err != nil { + t.Fatalf("a charger without its own credential should still connect: %v", err) + } + // The gate this test exists for. It also catches the library detail that + // makes it fragile: ocppj.Server.Start replaces the connection check the + // ws.Server was given, so a gate registered on the raw server is silently + // discarded and every impersonation attempt succeeds. + if err := connect(t, "garage", "ftw", "shared-secret"); err == nil { + t.Fatal("the shared password connected as a charger that has its own credential") + } +} + +// waitForListener blocks until the port accepts, so a client never races the +// listener goroutine. +func waitForListener(t *testing.T, port int) { + t.Helper() + 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 + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not bind on port %d within deadline", port) +} diff --git a/go/internal/ocpp/capabilities.go b/go/internal/ocpp/capabilities.go new file mode 100644 index 000000000..2a3d641b0 --- /dev/null +++ b/go/internal/ocpp/capabilities.go @@ -0,0 +1,93 @@ +package ocpp + +// Capability discovery: can this charger be steered, or does it only meter? +// +// OCPP 1.6 chargers advertise feature profiles through the +// SupportedFeatureProfiles configuration key; SmartCharging in that list is +// what makes SetChargingProfile work. 2.0.1 models the same fact as the +// SmartChargingCtrlr component's Available variable. FTW probes once per +// charger (re-trying on every connect/boot until an answer arrives), records +// the raw answer, and derives a tri-state verdict: steerable, telemetry-only, +// or unknown. +// +// The verdict is advisory, never a gate. Commands are still attempted and +// refusals handled empirically by the actuation tracker — a charger that +// misreports its own capabilities cannot lock itself out of control. What the +// verdict buys is honesty up front: the Chargers tab can say "telemetry only" +// before the operator binds a charger the planner will never steer. + +import ( + "log/slog" + "strings" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +// profilesIncludeSmartCharging reports whether a SupportedFeatureProfiles +// CSV names SmartCharging. Vendors vary in spacing and case. +func profilesIncludeSmartCharging(csv string) bool { + for _, p := range strings.Split(csv, ",") { + if strings.EqualFold(strings.TrimSpace(p), "SmartCharging") { + return true + } + } + return false +} + +// probeFeatureProfiles16 asks a 1.6 charge point for its feature profiles. +// Async: the library delivers the confirmation on its own goroutine. +func probeFeatureProfiles16(cs ocpp16.CentralSystem, h *Handler, id string) { + err := cs.GetConfiguration(id, func(conf *core.GetConfigurationConfirmation, err error) { + defer h.probeFinished(id) + if err != nil || conf == nil { + slog.Info("ocpp: charger did not answer GetConfiguration — control capability stays unknown", + "charger", id, "err", err) + return + } + for _, kv := range conf.ConfigurationKey { + if kv.Key == "SupportedFeatureProfiles" && kv.Value != nil { + h.setControlCapability(id, *kv.Value, profilesIncludeSmartCharging(*kv.Value)) + return + } + } + slog.Info("ocpp: charger answered GetConfiguration without SupportedFeatureProfiles — control capability stays unknown", + "charger", id) + }, []string{"SupportedFeatureProfiles"}) + if err != nil { + // The request never left, so no callback will clear the marker. + h.probeFinished(id) + slog.Warn("ocpp: GetConfiguration send failed", "charger", id, "err", err) + } +} + +// probeSmartChargingV201 asks a 2.0.1 station whether SmartChargingCtrlr is +// available — the 2.0.1 shape of the SmartCharging feature profile. +func probeSmartChargingV201(csms ocpp201.CSMS, h *Handler, id string) { + err := csms.GetVariables(id, func(resp *provisioning.GetVariablesResponse, err error) { + defer h.probeFinished(id) + if err != nil || resp == nil || len(resp.GetVariableResult) == 0 { + slog.Info("ocpp: station did not answer GetVariables — control capability stays unknown", + "charger", id, "err", err) + return + } + r := resp.GetVariableResult[0] + if r.AttributeStatus != provisioning.GetVariableStatusAccepted { + slog.Info("ocpp: SmartChargingCtrlr not reported — control capability stays unknown", + "charger", id, "status", r.AttributeStatus) + return + } + available := strings.EqualFold(strings.TrimSpace(r.AttributeValue), "true") + h.setControlCapability(id, "SmartChargingCtrlr.Available="+strings.TrimSpace(r.AttributeValue), available) + }, []provisioning.GetVariableData{{ + Component: types201.Component{Name: "SmartChargingCtrlr"}, + Variable: types201.Variable{Name: "Available"}, + }}) + if err != nil { + h.probeFinished(id) + slog.Warn("ocpp: GetVariables send failed", "charger", id, "err", err) + } +} diff --git a/go/internal/ocpp/capabilities_test.go b/go/internal/ocpp/capabilities_test.go new file mode 100644 index 000000000..0f2471a25 --- /dev/null +++ b/go/internal/ocpp/capabilities_test.go @@ -0,0 +1,215 @@ +package ocpp + +import ( + "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/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func TestProfilesIncludeSmartCharging(t *testing.T) { + cases := map[string]bool{ + "Core,FirmwareManagement,SmartCharging": true, + "Core, LocalAuthListManagement, Reservation, SmartCharging, RemoteTrigger": true, + "core,smartcharging": true, // vendors vary in case + "Core,FirmwareManagement,RemoteTrigger": false, + "": false, + "SmartChargingProfileMax": false, // must not substring-match + } + for csv, want := range cases { + if got := profilesIncludeSmartCharging(csv); got != want { + t.Errorf("profilesIncludeSmartCharging(%q) = %v, want %v", csv, got, want) + } + } +} + +// capabilityCP is a charge point that answers GetConfiguration with whatever +// feature profiles the test wants — including "no answer at all", which is +// what a charger that does not implement the key looks like on the wire. +type capabilityCP struct { + mu sync.Mutex + profiles string + omitKey bool + asked int +} + +func (c *capabilityCP) OnGetConfiguration(req *core.GetConfigurationRequest) (*core.GetConfigurationConfirmation, error) { + c.mu.Lock() + defer c.mu.Unlock() + c.asked++ + if c.omitKey { + return core.NewGetConfigurationConfirmation(nil), nil + } + v := c.profiles + return core.NewGetConfigurationConfirmation([]core.ConfigurationKey{ + {Key: "SupportedFeatureProfiles", Readonly: true, Value: &v}, + }), nil +} + +func (c *capabilityCP) askedCount() int { + c.mu.Lock() + defer c.mu.Unlock() + return c.asked +} + +// The rest of core.ChargePointHandler, unused by these tests. +func (c *capabilityCP) OnChangeAvailability(*core.ChangeAvailabilityRequest) (*core.ChangeAvailabilityConfirmation, error) { + return core.NewChangeAvailabilityConfirmation(core.AvailabilityStatusAccepted), nil +} +func (c *capabilityCP) OnChangeConfiguration(*core.ChangeConfigurationRequest) (*core.ChangeConfigurationConfirmation, error) { + return core.NewChangeConfigurationConfirmation(core.ConfigurationStatusAccepted), nil +} +func (c *capabilityCP) OnClearCache(*core.ClearCacheRequest) (*core.ClearCacheConfirmation, error) { + return core.NewClearCacheConfirmation(core.ClearCacheStatusAccepted), nil +} +func (c *capabilityCP) OnDataTransfer(*core.DataTransferRequest) (*core.DataTransferConfirmation, error) { + return core.NewDataTransferConfirmation(core.DataTransferStatusAccepted), nil +} +func (c *capabilityCP) OnRemoteStartTransaction(*core.RemoteStartTransactionRequest) (*core.RemoteStartTransactionConfirmation, error) { + return core.NewRemoteStartTransactionConfirmation(types.RemoteStartStopStatusAccepted), nil +} +func (c *capabilityCP) OnRemoteStopTransaction(*core.RemoteStopTransactionRequest) (*core.RemoteStopTransactionConfirmation, error) { + return core.NewRemoteStopTransactionConfirmation(types.RemoteStartStopStatusAccepted), nil +} +func (c *capabilityCP) OnReset(*core.ResetRequest) (*core.ResetConfirmation, error) { + return core.NewResetConfirmation(core.ResetStatusAccepted), nil +} +func (c *capabilityCP) OnUnlockConnector(*core.UnlockConnectorRequest) (*core.UnlockConnectorConfirmation, error) { + return core.NewUnlockConnectorConfirmation(core.UnlockStatusUnlocked), nil +} + +// waitSteerable polls the snapshot until the capability verdict lands. +func waitSteerable(t *testing.T, srv *Server, id string) ChargerView { + t.Helper() + deadline := time.Now().Add(3 * time.Second) + for time.Now().Before(deadline) { + v := srv.Handler().Snapshot()[id] + if v.Steerable != nil { + return v + } + time.Sleep(25 * time.Millisecond) + } + return srv.Handler().Snapshot()[id] +} + +func connectCapabilityCP(t *testing.T, port int, id string, cp *capabilityCP) ocpp16.ChargePoint { + t.Helper() + client := ocpp16.NewChargePoint(id, nil, nil) + client.SetCoreHandler(cp) + if err := client.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + var once sync.Once + t.Cleanup(func() { once.Do(client.Stop) }) + if _, err := client.BootNotification("Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + return client +} + +// A charger advertising SmartCharging is recorded as steerable, and the probe +// stops asking once it has an answer. +func TestCapabilityProbeRecordsSmartCharging(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "steerable-cp") + defer srv.Stop() + + cp := &capabilityCP{profiles: "Core,FirmwareManagement,SmartCharging"} + connectCapabilityCP(t, port, "steerable-cp", cp) + + v := waitSteerable(t, srv, "steerable-cp") + if v.Steerable == nil || !*v.Steerable { + t.Fatalf("expected steerable=true, got %+v", v) + } + if v.FeatureProfiles != "Core,FirmwareManagement,SmartCharging" { + t.Errorf("raw profiles not recorded: %+v", v) + } + // Connect and boot both call maybeProbeCapability milliseconds apart. + // The in-flight marker must collapse that into a single request, and + // the stored answer must stop any later one. + if n := cp.askedCount(); n != 1 { + t.Errorf("connect+boot should ask exactly once, asked %d times", n) + } + srv.Handler().maybeProbeCapability("steerable-cp") + time.Sleep(200 * time.Millisecond) + if n := cp.askedCount(); n != 1 { + t.Errorf("probe re-asked after an answer was stored: %d", n) + } +} + +// A probe still in flight when the socket drops must not wedge the marker: +// the reconnect is exactly when the charger should be asked again. +func TestCapabilityProbeMarkerClearedOnDisconnect(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "flappy") + defer srv.Stop() + + h := srv.Handler() + // Simulate "probe fired, no answer yet" without a live charge point. + h.mu.Lock() + h.probing["flappy"] = true + h.mu.Unlock() + + h.OnDisconnect("flappy") + + h.mu.Lock() + stuck := h.probing["flappy"] + h.mu.Unlock() + if stuck { + t.Error("disconnect must clear an in-flight probe marker") + } + _ = port +} + +// A charger without SmartCharging is recorded as telemetry-only — the UI +// warns rather than the code blocking, so control is still attempted. +func TestCapabilityProbeRecordsTelemetryOnly(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "meter-only") + defer srv.Stop() + + connectCapabilityCP(t, port, "meter-only", &capabilityCP{profiles: "Core,FirmwareManagement"}) + + v := waitSteerable(t, srv, "meter-only") + if v.Steerable == nil || *v.Steerable { + t.Fatalf("expected steerable=false, got %+v", v) + } + if v.FeatureProfiles != "Core,FirmwareManagement" { + t.Errorf("raw profiles not recorded: %+v", v) + } +} + +// A charger that answers without the key leaves the verdict unknown — absent +// from the JSON entirely, which the UI renders as "not reported" rather than +// claiming the charger cannot be steered. +func TestCapabilityProbeUnknownWhenKeyMissing(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "quiet-cp") + defer srv.Stop() + + cp := &capabilityCP{omitKey: true} + connectCapabilityCP(t, port, "quiet-cp", cp) + time.Sleep(600 * time.Millisecond) + + v := srv.Handler().Snapshot()["quiet-cp"] + if v.Steerable != nil { + t.Fatalf("expected unknown steerability, got %v", *v.Steerable) + } + if v.FeatureProfiles != "" { + t.Errorf("no profiles should be recorded, got %q", v.FeatureProfiles) + } + // An unanswered probe must not wedge the in-flight marker: the charger + // gets asked again next time, which is how a firmware update that adds + // the key is ever noticed. + askedBefore := cp.askedCount() + srv.Handler().maybeProbeCapability("quiet-cp") + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && cp.askedCount() == askedBefore { + time.Sleep(25 * time.Millisecond) + } + if cp.askedCount() <= askedBefore { + t.Errorf("probe should retry while the verdict is unknown, still %d", cp.askedCount()) + } +} diff --git a/go/internal/ocpp/charging_needs.go b/go/internal/ocpp/charging_needs.go new file mode 100644 index 000000000..3a2a76dd7 --- /dev/null +++ b/go/internal/ocpp/charging_needs.go @@ -0,0 +1,235 @@ +package ocpp + +// NotifyEVChargingNeeds — what the car asked for. +// +// During an ISO 15118 charge-parameter discovery the EV states its own needs, +// and the charging station forwards them to us as NotifyEVChargingNeeds. This +// is the vehicle speaking rather than the charger or the operator: the energy +// it wants, when it expects to leave, and on DC also its battery capacity and +// present state of charge. +// +// That outranks configuration. Both `vehicle_capacity_wh` and a vehicle +// profile are an operator's estimate of the car that usually parks here; this +// is the car actually plugged in, for this session. Where the message carries +// a figure it replaces the configured one for the session and reverts on +// plug-out, exactly like an identified vehicle profile. +// +// Quarantine applies as everywhere else: a pending charge point's needs are +// recorded and visible in the API so an operator can see what asked, but the +// callback that reaches a loadpoint never fires for it. +// +// Units follow the core convention — energy in Wh, SoC as a 0-1 fraction. The +// wire carries Wh and whole percent, converted here at the boundary. + +import ( + "log/slog" + "time" + + "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/units" +) + +// ChargingNeeds is one NotifyEVChargingNeeds report in core units. +// +// Only TransferMode is mandatory on the wire, so a zero field means "the car +// did not say", never "zero". The two SoC fields are pointers because there a +// genuine zero is meaningful — an empty battery is a real state of charge. +type ChargingNeeds struct { + // TransferMode is the energy transfer the car asked for: + // AC_single_phase, AC_two_phase, AC_three_phase or DC. + TransferMode string `json:"transfer_mode,omitempty"` + + // EnergyWh is the energy requested, including preconditioning. + EnergyWh float64 `json:"energy_wh,omitempty"` + + // DepartureTime is when the EV estimates it will leave. Zero when the + // car did not say, which is the common case on AC. + DepartureTime time.Time `json:"departure_time,omitempty"` + + // PresentSoC and FullSoC are 0-1 fractions: where the battery is now, + // and where the car considers it full. DC sessions only — the AC + // parameter set has no SoC at all. + PresentSoC *float64 `json:"present_soc,omitempty"` + FullSoC *float64 `json:"full_soc,omitempty"` + + // CapacityWh is the car's own battery capacity. DC sessions only. + CapacityWh float64 `json:"capacity_wh,omitempty"` + + // MaxCurrentA, MaxVoltageV and MaxPowerW are the car's own ceilings. + // They are recorded but never used to raise a limit — control clamps + // down from the loadpoint's rating, never up from the car's claim. + MaxCurrentA float64 `json:"max_current_a,omitempty"` + MaxVoltageV float64 `json:"max_voltage_v,omitempty"` + MaxPowerW float64 `json:"max_power_w,omitempty"` + + // EVSEID is the EVSE the needs apply to. A home charger has one. + EVSEID int `json:"evse_id,omitempty"` + + // ReceivedAt is when we took the report, so a stale departure time is + // recognisable as stale rather than read as current intent. + ReceivedAt time.Time `json:"received_at,omitempty"` +} + +// TargetSoC derives the state of charge this session should aim for: where the +// battery is now, plus the energy the car asked for, over its own capacity. +// +// Reports false unless the car gave all three. An AC session states energy +// alone, and energy alone says nothing about a fraction of a battery whose +// size is unknown — guessing one there would feed the planner a number the car +// never claimed. Capped at FullSoC when the car named one, otherwise at 1. +func (n ChargingNeeds) TargetSoC() (float64, bool) { + if n.PresentSoC == nil || n.CapacityWh <= 0 || n.EnergyWh <= 0 { + return 0, false + } + ceiling := 1.0 + if n.FullSoC != nil && *n.FullSoC > 0 { + ceiling = *n.FullSoC + } + target := *n.PresentSoC + n.EnergyWh/n.CapacityWh + if target > ceiling { + target = ceiling + } + return units.ClampFraction(target), true +} + +// chargingNeedsFrom converts a wire request into core units. +func chargingNeedsFrom(req *smartcharging.NotifyEVChargingNeedsRequest, now time.Time) ChargingNeeds { + n := ChargingNeeds{ + TransferMode: string(req.ChargingNeeds.RequestedEnergyTransfer), + EVSEID: req.EvseID, + ReceivedAt: now, + } + if dt := req.ChargingNeeds.DepartureTime; dt != nil { + n.DepartureTime = dt.Time + } + if ac := req.ChargingNeeds.ACChargingParameters; ac != nil { + n.EnergyWh = float64(ac.EnergyAmount) + n.MaxCurrentA = float64(ac.EVMaxCurrent) + n.MaxVoltageV = float64(ac.EVMaxVoltage) + } + if dc := req.ChargingNeeds.DCChargingParameters; dc != nil { + n.MaxCurrentA = float64(dc.EVMaxCurrent) + n.MaxVoltageV = float64(dc.EVMaxVoltage) + if dc.EnergyAmount != nil { + n.EnergyWh = float64(*dc.EnergyAmount) + } + if dc.EVMaxPower != nil { + n.MaxPowerW = float64(*dc.EVMaxPower) + } + if dc.EVEnergyCapacity != nil { + n.CapacityWh = float64(*dc.EVEnergyCapacity) + } + if dc.StateOfCharge != nil { + f := units.ClampFraction(float64(*dc.StateOfCharge) / 100.0) + n.PresentSoC = &f + } + if dc.FullSoC != nil { + f := units.ClampFraction(float64(*dc.FullSoC) / 100.0) + n.FullSoC = &f + } + } + return n +} + +// noteChargingNeeds records a report and, for an adopted charger, hands it to +// the loadpoint layer. +// +// Unlike a vehicle identity this fires on every report rather than only on +// change: the car is allowed to revise what it wants mid-session — a departure +// time moves, a preconditioning estimate is refined — and the latest statement +// is the one the planner should size on. +func (h *Handler) noteChargingNeeds(id string, n ChargingNeeds) { + h.mu.Lock() + s := h.chargersLocked(id) + s.needs = &n + fn := h.chargingNeeds + approved := h.approved[id] + h.mu.Unlock() + if approved && fn != nil { + fn(id, n) + } +} + +// SetChargingNeeds registers the callback fired when an adopted charger +// reports what its car asked for. Wired by main.go to the loadpoint bound to +// that charger. Pending chargers never reach it. +func (h *Handler) SetChargingNeeds(fn func(chargerID string, needs ChargingNeeds)) { + if h == nil { + return + } + h.mu.Lock() + h.chargingNeeds = fn + h.mu.Unlock() +} + +// ChargingNeeds returns the last report from a charger, if any. +func (h *Handler) ChargingNeeds(id string) (ChargingNeeds, bool) { + if h == nil { + return ChargingNeeds{}, false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.needs == nil { + return ChargingNeeds{}, false + } + return *s.needs, true +} + +// ---- smartcharging.CSMSHandler ---- + +// OnNotifyEVChargingNeeds takes the car's stated needs. +// +// Accepted means we processed the message, not that we can meet the request — +// the spec is explicit about that, and the schedule we send back is whatever +// the planner works out on its own tick. +func (h *handlerV201) OnNotifyEVChargingNeeds(id string, req *smartcharging.NotifyEVChargingNeedsRequest) (*smartcharging.NotifyEVChargingNeedsResponse, error) { + if req == nil { + return smartcharging.NewNotifyEVChargingNeedsResponse(smartcharging.EVChargingNeedsStatusRejected), nil + } + n := chargingNeedsFrom(req, time.Now()) + + slog.Info("OCPP charging needs", + "charger", id, "version", Version201, + "evse", n.EVSEID, "mode", n.TransferMode, + "energy_wh", n.EnergyWh, "capacity_wh", n.CapacityWh, + "departure", n.DepartureTime) + + h.noteChargingNeeds(id, n) + h.telSuccess(id) + return smartcharging.NewNotifyEVChargingNeedsResponse(smartcharging.EVChargingNeedsStatusAccepted), nil +} + +// OnNotifyEVChargingSchedule carries the schedule the EV worked out for +// itself. FTW plans centrally against site power, price and PV, so this is +// acknowledged and dropped — accepting it costs nothing and refusing would +// make the charger retry forever. +func (h *handlerV201) OnNotifyEVChargingSchedule(id string, _ *smartcharging.NotifyEVChargingScheduleRequest) (*smartcharging.NotifyEVChargingScheduleResponse, error) { + h.telSuccess(id) + return smartcharging.NewNotifyEVChargingScheduleResponse(types201.GenericStatusAccepted), nil +} + +// OnNotifyChargingLimit reports a limit imposed by something other than us — +// a local load-management box. Acknowledged and dropped: the charger enforces +// it whatever we send, and our own commands are already clamped below the +// loadpoint's rating. +func (h *handlerV201) OnNotifyChargingLimit(id string, _ *smartcharging.NotifyChargingLimitRequest) (*smartcharging.NotifyChargingLimitResponse, error) { + h.telSuccess(id) + return smartcharging.NewNotifyChargingLimitResponse(), nil +} + +// OnClearedChargingLimit is the other half of that: the external limit is +// gone. Acknowledged and dropped for the same reason. +func (h *handlerV201) OnClearedChargingLimit(id string, _ *smartcharging.ClearedChargingLimitRequest) (*smartcharging.ClearedChargingLimitResponse, error) { + h.telSuccess(id) + return smartcharging.NewClearedChargingLimitResponse(), nil +} + +// OnReportChargingProfiles answers a GetChargingProfiles we never send. +// Acknowledged and dropped. +func (h *handlerV201) OnReportChargingProfiles(id string, _ *smartcharging.ReportChargingProfilesRequest) (*smartcharging.ReportChargingProfilesResponse, error) { + h.telSuccess(id) + return smartcharging.NewReportChargingProfilesResponse(), nil +} diff --git a/go/internal/ocpp/charging_needs_test.go b/go/internal/ocpp/charging_needs_test.go new file mode 100644 index 000000000..97d8d4c82 --- /dev/null +++ b/go/internal/ocpp/charging_needs_test.go @@ -0,0 +1,296 @@ +package ocpp + +import ( + "fmt" + "math" + "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" +) + +func intp(v int) *int { return &v } + +// nearly compares derived fractions, which come out of a division and so do +// not land on the exact decimal the case is written with. +func nearly(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +// connectStationForNeeds is connectStationV201 with the station itself handed +// back, so a test can send station-initiated messages rather than only receive +// the ones the CSMS pushes. +func connectStationForNeeds(t *testing.T, srv *Server, port int, id string) ocpp201.ChargingStation { + t.Helper() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(newFakeStationV201()) + + 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 + t.Cleanup(func() { once.Do(cs.Stop) }) + + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, "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 cs + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil +} + +// The wire speaks Wh and whole percent; core speaks Wh and 0-1 fractions. +// A DC report carries every field, so this is where a scale slip would show. +func TestChargingNeedsFromDCConvertsUnits(t *testing.T) { + departure := time.Date(2026, 8, 29, 7, 30, 0, 0, time.UTC) + req := smartcharging201.NewNotifyEVChargingNeedsRequest(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeDC, + DepartureTime: types201.NewDateTime(departure), + DCChargingParameters: &smartcharging201.DCChargingParameters{ + EVMaxCurrent: 125, + EVMaxVoltage: 400, + EnergyAmount: intp(30000), + EVMaxPower: intp(50000), + StateOfCharge: intp(20), + EVEnergyCapacity: intp(75000), + FullSoC: intp(90), + }, + }) + + now := time.Date(2026, 8, 29, 6, 0, 0, 0, time.UTC) + n := chargingNeedsFrom(req, now) + + if n.TransferMode != "DC" { + t.Errorf("transfer mode: got %q, want DC", n.TransferMode) + } + if n.EnergyWh != 30000 { + t.Errorf("energy: got %v Wh, want 30000", n.EnergyWh) + } + if n.CapacityWh != 75000 { + t.Errorf("capacity: got %v Wh, want 75000", n.CapacityWh) + } + if n.MaxPowerW != 50000 { + t.Errorf("max power: got %v W, want 50000", n.MaxPowerW) + } + if n.PresentSoC == nil || *n.PresentSoC != 0.20 { + t.Errorf("present SoC: got %v, want the 0-1 fraction 0.20", n.PresentSoC) + } + if n.FullSoC == nil || *n.FullSoC != 0.90 { + t.Errorf("full SoC: got %v, want the 0-1 fraction 0.90", n.FullSoC) + } + if !n.DepartureTime.Equal(departure) { + t.Errorf("departure: got %v, want %v", n.DepartureTime, departure) + } + if !n.ReceivedAt.Equal(now) { + t.Errorf("received at: got %v, want %v", n.ReceivedAt, now) + } + if n.EVSEID != 1 { + t.Errorf("evse: got %d, want 1", n.EVSEID) + } +} + +// AC states energy and current, and has no SoC or capacity at all. +func TestChargingNeedsFromACHasNoSoC(t *testing.T) { + req := smartcharging201.NewNotifyEVChargingNeedsRequest(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeAC3Phase, + ACChargingParameters: &smartcharging201.ACChargingParameters{ + EnergyAmount: 12000, + EVMinCurrent: 6, + EVMaxCurrent: 16, + EVMaxVoltage: 230, + }, + }) + + n := chargingNeedsFrom(req, time.Now()) + + if n.EnergyWh != 12000 { + t.Errorf("energy: got %v Wh, want 12000", n.EnergyWh) + } + if n.MaxCurrentA != 16 { + t.Errorf("max current: got %v A, want 16", n.MaxCurrentA) + } + if n.PresentSoC != nil || n.CapacityWh != 0 { + t.Errorf("AC carries no SoC or capacity, got soc=%v capacity=%v", n.PresentSoC, n.CapacityWh) + } + if _, ok := n.TargetSoC(); ok { + t.Error("derived a target SoC from energy alone — a fraction of an unknown battery") + } +} + +func TestChargingNeedsTargetSoC(t *testing.T) { + soc := func(f float64) *float64 { return &f } + + tests := []struct { + name string + needs ChargingNeeds + want float64 + ok bool + }{ + { + name: "present plus requested over capacity", + needs: ChargingNeeds{PresentSoC: soc(0.20), EnergyWh: 30000, CapacityWh: 75000}, + want: 0.60, + ok: true, + }, + { + name: "capped at the car's own full SoC", + needs: ChargingNeeds{PresentSoC: soc(0.50), EnergyWh: 60000, CapacityWh: 75000, FullSoC: soc(0.80)}, + want: 0.80, + ok: true, + }, + { + name: "capped at 1 when the car named no full SoC", + needs: ChargingNeeds{PresentSoC: soc(0.50), EnergyWh: 60000, CapacityWh: 75000}, + want: 1.0, + ok: true, + }, + { + name: "no capacity means no fraction", + needs: ChargingNeeds{PresentSoC: soc(0.20), EnergyWh: 30000}, + ok: false, + }, + { + name: "no present SoC means no fraction", + needs: ChargingNeeds{EnergyWh: 30000, CapacityWh: 75000}, + ok: false, + }, + { + name: "no energy request means nothing to add", + needs: ChargingNeeds{PresentSoC: soc(0.20), CapacityWh: 75000}, + ok: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := tc.needs.TargetSoC() + if ok != tc.ok { + t.Fatalf("ok: got %v, want %v", ok, tc.ok) + } + if ok && !nearly(got, tc.want) { + t.Errorf("target SoC: got %v, want %v", got, tc.want) + } + }) + } +} + +// Quarantine covers charging needs like everything else: a charge point no +// loadpoint names may state what it wants, and nothing acts on it. +func TestPendingChargerChargingNeedsNeverReachLoadpoint(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var fired []string + h.SetChargingNeeds(func(chargerID string, _ ChargingNeeds) { + fired = append(fired, chargerID) + }) + + needs := ChargingNeeds{TransferMode: "DC", EnergyWh: 30000} + h.noteChargingNeeds("intruder", needs) + h.noteChargingNeeds("garage", needs) + + if len(fired) != 1 || fired[0] != "garage" { + t.Fatalf("callback fired for %v, want only the adopted charger", fired) + } + // Recorded either way — the operator has to be able to see what asked. + if _, ok := h.ChargingNeeds("intruder"); !ok { + t.Error("a pending charger's needs should still be visible in the API") + } +} + +// The car may revise what it wants mid-session, so every report fires — +// unlike a vehicle identity, which fires only when it changes. +func TestChargingNeedsFireOnEveryReport(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var got []float64 + h.SetChargingNeeds(func(_ string, n ChargingNeeds) { + got = append(got, n.EnergyWh) + }) + + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 30000}) + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 30000}) + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 18000}) + + if len(got) != 3 { + t.Fatalf("fired %d times for 3 reports: %v", len(got), got) + } + last, ok := h.ChargingNeeds("garage") + if !ok || last.EnergyWh != 18000 { + t.Errorf("stored needs: got %v ok=%v, want the latest 18000 Wh", last.EnergyWh, ok) + } +} + +// End to end over a real 2.0.1 connection. Without SetSmartChargingHandler the +// CSMS rejects the message as unsupported, so this is what proves the handler +// is actually registered rather than merely written. +func TestV201StationReportsChargingNeeds(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + srv.Handler().SetApprovedIDs([]string{"garage-needs"}) + + applied := make(chan ChargingNeeds, 1) + srv.Handler().SetChargingNeeds(func(_ string, n ChargingNeeds) { + select { + case applied <- n: + default: + } + }) + + station := connectStationForNeeds(t, srv, portV201, "garage-needs") + + departure := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + resp, err := station.NotifyEVChargingNeeds(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeDC, + DepartureTime: types201.NewDateTime(departure), + DCChargingParameters: &smartcharging201.DCChargingParameters{ + EVMaxCurrent: 125, + EVMaxVoltage: 400, + EnergyAmount: intp(30000), + StateOfCharge: intp(20), + EVEnergyCapacity: intp(75000), + }, + }) + if err != nil { + t.Fatalf("NotifyEVChargingNeeds: %v", err) + } + if resp.Status != smartcharging201.EVChargingNeedsStatusAccepted { + t.Fatalf("status: got %v, want Accepted", resp.Status) + } + + select { + case n := <-applied: + if n.CapacityWh != 75000 { + t.Errorf("capacity: got %v Wh, want 75000", n.CapacityWh) + } + target, ok := n.TargetSoC() + if !ok || !nearly(target, 0.60) { + t.Errorf("target SoC: got %v ok=%v, want 0.60", target, ok) + } + if !n.DepartureTime.Equal(departure) { + t.Errorf("departure: got %v, want %v", n.DepartureTime, departure) + } + case <-time.After(2 * time.Second): + t.Fatal("charging needs never reached the loadpoint callback") + } + + // And it is visible in the snapshot the Chargers panel renders. + view, ok := srv.Handler().Snapshot()["garage-needs"] + if !ok || view.ChargingNeeds == nil { + t.Fatal("snapshot carried no charging needs") + } + if view.ChargingNeeds.EnergyWh != 30000 { + t.Errorf("snapshot energy: got %v Wh, want 30000", view.ChargingNeeds.EnergyWh) + } +} diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go new file mode 100644 index 000000000..7324a8a12 --- /dev/null +++ b/go/internal/ocpp/config.go @@ -0,0 +1,142 @@ +package ocpp + +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" +) + +// Config controls the OCPP Central System. +// +// Charge points connect to ws://:/, or wss:// when TLS +// is configured. When a loadpoint names that chargerId (see ApprovedIDs) it +// becomes the driver name in telemetry.Store and shows up in /api/devices and +// /api/status.drivers; otherwise the charger stays pending and appears only in +// /api/ocpp/chargers. +type Config struct { + Enabled bool `yaml:"enabled"` + + // Bind is the address the listener is offered on. The OCPP library + // builds its own listen address from the port alone, so the socket is + // unavoidably open on every interface; what this does is refuse the + // WebSocket handshake for any connection that arrived somewhere else + // (see authorizer.checkClient). That is an access control, not a + // smaller attack surface — a port scan still finds the port. Empty or + // unspecified (0.0.0.0 / ::) accepts every interface. + 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"` + + // TLS, when set, serves wss:// instead of ws://. Optional client + // certificate verification implements OCPP 2.0.1 security profile 3, + // where the certificate — not a shared password — is what identifies a + // charge point. + TLS *TLSConfig `yaml:"tls"` + + // ChargerSecrets maps a charge point identity to a password of its own. + // A charger listed here must present that password under that identity, + // so the shared password no longer buys an attacker its name. Derived by + // the caller from the config's per-charger entries, never read from YAML + // here. + ChargerSecrets map[string]string `yaml:"-"` + + // ApprovedIDs is the set of charger identities that are part of the site + // — the ids loadpoints name in config. Derived by the caller, never read + // from YAML. A charger connecting under any other identity is accepted at + // the protocol level but quarantined as "pending": visible in the API and + // UI so an operator can adopt it, withheld from telemetry so it cannot + // influence dispatch. Empty means every charger is pending. + ApprovedIDs []string `yaml:"-"` +} + +// TLSConfig points at the certificate this server presents, and optionally at +// the CA that signs the charge points allowed to connect. +type TLSConfig struct { + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + + // ClientCAFile, when set, requires every charge point to present a + // certificate signed by this CA and rejects the handshake otherwise. + // That is a far stronger identity than a shared password: it cannot be + // copied out of one charger's config and replayed by another device + // unless its private key was copied too. + ClientCAFile string `yaml:"client_ca_file"` +} + +// Enabled reports whether TLS is fully configured. Both files are required — +// a certificate without its key cannot serve. +func (t *TLSConfig) Enabled() bool { + return t != nil && t.CertFile != "" && t.KeyFile != "" +} + +// configured reports whether the operator asked for TLS at all, however +// incompletely. Enabled answers "can we serve it"; this answers "were we meant +// to", and the gap between the two is a misconfiguration that must fail loudly +// rather than quietly fall back to ws://. +func (t *TLSConfig) configured() bool { + return t != nil && (t.CertFile != "" || t.KeyFile != "" || t.ClientCAFile != "") +} + +// serverTLS builds the tls.Config for the listener, reading the client CA from +// disk when one is configured. +// +// Returns an error rather than falling back to plaintext: an operator who +// asked for TLS and got ws:// because a path was wrong would have no way to +// tell, and would believe the link was encrypted. +func (t *TLSConfig) serverTLS() (*tls.Config, error) { + if !t.Enabled() { + return nil, errors.New("ocpp: tls needs both cert_file and key_file") + } + if _, err := os.Stat(t.CertFile); err != nil { + return nil, fmt.Errorf("ocpp: tls cert_file: %w", err) + } + if _, err := os.Stat(t.KeyFile); err != nil { + return nil, fmt.Errorf("ocpp: tls key_file: %w", err) + } + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if t.ClientCAFile == "" { + return cfg, nil + } + pem, err := os.ReadFile(t.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("ocpp: tls client_ca_file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ocpp: tls client_ca_file %s contains no certificate", t.ClientCAFile) + } + cfg.ClientAuth = tls.RequireAndVerifyClientCert + cfg.ClientCAs = pool + return cfg, nil +} + +// 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 + } +} + +// Scheme is the URL scheme charge points must dial, for logs and the UI. +func (c *Config) Scheme() string { + if c != nil && c.TLS.Enabled() { + return "wss" + } + return "ws" +} diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go new file mode 100644 index 000000000..2e39ae3c9 --- /dev/null +++ b/go/internal/ocpp/control.go @@ -0,0 +1,427 @@ +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 + + // Where a limit goes when the charger refuses a charge-point-wide one. + // OCPP 1.6 permits a TxDefaultProfile on connector 0 — it is how a + // profile is applied to every connector — but some chargers read the + // connector-0 rule as ChargePointMaxProfile-only and reject it. On a + // single-socket unit connector 1 means the same thing. + firstConnector = 1 + + // 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 +) + +// The profile is Relative, not Absolute. +// +// FTW's schedule has one period at second 0 and no end: "hold this limit until +// I send another". Absolute expresses that only with a startSchedule +// timestamp, and the specification says an absolute schedule with none is +// relative to the start of charging anyway — so the two spellings mean the +// same thing here, and only one of them can be misread. +// +// It is misread in practice. A charger that parses the missing timestamp +// strictly finds no valid start, treats the profile as not yet active, and +// answers Accepted while charging on at full rate. That is the worst failure +// this layer has: FTW logs a limit it never imposed, and the planner counts +// energy the site is not saving. Relative needs no timestamp, so there is +// nothing to misparse — and it does not depend on the charger's clock +// agreeing with ours, which on EV chargers is not a safe assumption. +const ( + profileKind16 = types.ChargingProfileKindRelative + profileKind201 = types201.ChargingProfileKindRelative +) + +// 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 + } + + r, err := s.attemptLimit(ctx, id, amps, numberPhases, allConnectors) + if err != nil { + return err + } + // A charger that answers Rejected to a charge-point-wide profile usually + // reads OCPP 1.6 as allowing connector 0 for ChargePointMaxProfile alone. + // The specification does permit a TxDefaultProfile there — it is how a + // profile is applied to every connector — but a charger that disagrees + // otherwise accepts no limit at all, which is a charger FTW meters and + // cannot steer. Retrying on the first connector costs one message and + // covers the single-socket units this matters for. + if r.answered && !r.accepted { + slog.Info("ocpp: charger refused a charge-point-wide profile, retrying on connector 1", + "charger", id, "status", r.status) + retry, retryErr := s.attemptLimit(ctx, id, amps, numberPhases, firstConnector) + if retryErr != nil { + return retryErr + } + r = retry + } + + 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 +} + +// attemptLimit sends one charging profile and waits for the charger's answer. +// +// The returned profileResult carries the charger's verdict, including a +// refusal; the error is reserved for the cases where no verdict exists — +// transport failure, cancellation, silence. +func (s *Server) attemptLimit(ctx context.Context, id string, amps float64, numberPhases *int, connectorID int) (profileResult, error) { + // 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, connectorID, done) + default: + err = s.sendProfileV16(id, amps, numberPhases, connectorID, done) + } + if err != nil { + return profileResult{}, fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + } + + timeout := time.NewTimer(commandTimeout) + defer timeout.Stop() + + select { + case r := <-done: + return r, nil + case <-ctx.Done(): + return profileResult{}, fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) + case <-timeout.C: + return profileResult{}, 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, connectorID 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, + profileKind16, + 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 + }, connectorID, 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, evseID 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, + profileKind201, + []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 + }, evseID, 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_interop_test.go b/go/internal/ocpp/control_interop_test.go new file mode 100644 index 000000000..32845ee92 --- /dev/null +++ b/go/internal/ocpp/control_interop_test.go @@ -0,0 +1,114 @@ +package ocpp + +import ( + "context" + "testing" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Both cases here were found by running the branch against Sourceful's device +// simulator rather than by reading the specification, which is why they are +// pinned: each one let FTW believe it had imposed a limit it had not. + +// setCurrent11kW is the ordinary dispatch command, at a rate the fixtures' +// chargers accept. +func setCurrent11kW(t *testing.T) []byte { + t.Helper() + return mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + }) +} + +// The profile must not be Absolute. +// +// FTW's schedule has one period at second 0 and no end. Absolute expresses +// that only with a startSchedule timestamp; the specification says an absolute +// schedule without one is relative to the start of charging anyway, so the two +// spellings mean the same thing — but a charger that parses the missing +// timestamp strictly finds no valid start, treats the profile as not yet +// active, and answers Accepted while charging on at full rate. +func TestChargingProfileIsRelativeSoItCannotBeReadAsNotYetStarted(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + got := fake.profiles[len(fake.profiles)-1].ChargingProfile + if got.ChargingProfileKind != types.ChargingProfileKindRelative { + t.Errorf("profile kind: got %v, want Relative — Absolute needs a startSchedule FTW does not send", + got.ChargingProfileKind) + } + if got.ChargingSchedule != nil && got.ChargingSchedule.StartSchedule != nil { + t.Error("a relative schedule must carry no startSchedule") + } +} + +// OCPP 1.6 permits a TxDefaultProfile on connector 0 — it is how a profile is +// applied to every connector — but some chargers read the connector-0 rule as +// ChargePointMaxProfile-only and refuse it. Refusing means no limit at all, so +// FTW retries on the first connector rather than leaving the charger unsteered. +func TestChargerRefusingConnectorZeroIsRetriedOnConnectorOne(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + fake.mu.Lock() + fake.statusFn = func(req *smartcharging.SetChargingProfileRequest) smartcharging.ChargingProfileStatus { + if req.ConnectorId == 0 { + return smartcharging.ChargingProfileStatusRejected + } + return smartcharging.ChargingProfileStatusAccepted + } + fake.mu.Unlock() + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err != nil { + t.Fatalf("a charger that only accepts a real connector was left unsteered: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) != 2 { + t.Fatalf("got %d profiles, want the charge-point-wide try then the connector retry", len(fake.profiles)) + } + if fake.profiles[0].ConnectorId != 0 { + t.Errorf("first attempt went to connector %d, want the charge-point-wide 0", fake.profiles[0].ConnectorId) + } + if fake.profiles[1].ConnectorId != 1 { + t.Errorf("retry went to connector %d, want 1", fake.profiles[1].ConnectorId) + } +} + +// A charger that refuses both is a charger FTW cannot steer, and that has to +// surface as an error rather than a silent success. +func TestChargerRefusingEveryConnectorFails(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err == nil { + t.Fatal("a charger refusing every connector reported success") + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) != 2 { + t.Errorf("got %d attempts, want exactly one retry and no more", len(fake.profiles)) + } +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go new file mode 100644 index 000000000..03d746e4f --- /dev/null +++ b/go/internal/ocpp/control_test.go @@ -0,0 +1,409 @@ +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 + // statusFn, when set, decides per request instead of status — for + // chargers that accept some profiles and refuse others. + statusFn func(*smartcharging.SetChargingProfileRequest) smartcharging.ChargingProfileStatus +} + +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 + if f.statusFn != nil { + status = f.statusFn(req) + } + 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 000000000..1b0887b9f --- /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 000000000..2a920518e --- /dev/null +++ b/go/internal/ocpp/handlers.go @@ -0,0 +1,592 @@ +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 + // approved is the set of charger ids named by a loadpoint in config. A + // charger outside it is "pending": it may connect and is visible in + // Snapshot, but nothing it reports enters telemetry — no DerEV reading, + // no driver health, no metrics — so it cannot influence dispatch. See + // SetApprovedIDs. + approved map[string]bool + nextTxID int + + // vehicleIdentified, when set, fires once per new vehicle identity seen + // on an APPROVED charger's transaction — main.go uses it to apply the + // matching vehicle profile (capacity, charging policy) to the loadpoint. + // Pending chargers never fire it: quarantine means no influence. + vehicleIdentified func(chargerID, vehicleID, source string) + + // chargingNeeds, when set, fires on every NotifyEVChargingNeeds an + // APPROVED charger reports — main.go uses it to size the session on + // what the car asked for (see charging_needs.go). Pending chargers + // never fire it, for the same reason they never fire the one above. + chargingNeeds func(chargerID string, needs ChargingNeeds) + + // identityReported, when set, fires when an APPROVED charger says what + // it is (BootNotification) — main.go writes its row in the device + // registry from it. See identity.go. + identityReported func(ChargerIdentity) + + // capabilityProbe, set by the Server, asks a charger which feature + // profiles it supports (see capabilities.go). Fired from OnConnect and + // OnBootNotification until the charger answers, then never again. + capabilityProbe func(id string) + // probing holds the chargers with a capability request in flight, so + // the connect and boot triggers — which arrive milliseconds apart, long + // before any answer — ask once between them rather than twice. + probing map[string]bool +} + +// 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 + // vendor and model come from BootNotification and exist so the UI can + // label a charger with what it actually is rather than its URL segment. + // serial is the hardware-stable half of its identity — what the device + // row is keyed on, when the charger reports one at all. firmware is + // shown next to them and is otherwise unused. + vendor string + model string + serial string + firmware string + // vehicleID is the identity presented when the current/last transaction + // started: the RFID idTag on 1.6, or a 2.0.1 idToken — where the token + // type MacAddress (autocharge) or eMAID (ISO 15118) names the actual + // vehicle rather than a card. vehicleIDSource records which kind it was. + // Kept after the session ends so the UI can show what was last seen. + vehicleID string + vehicleIDSource string + // featureProfiles is the raw capability answer from the probe in + // capabilities.go (the SupportedFeatureProfiles CSV on 1.6, a + // synthesized SmartChargingCtrlr line on 2.0.1); empty until one + // arrived. steerable is the verdict derived from it: nil = never + // answered, false = telemetry only, true = accepts charging profiles. + featureProfiles string + steerable *bool + // needs is the last NotifyEVChargingNeeds this charger's car reported + // (2.0.1 only), nil until one arrives. Kept after the session ends so + // the UI can show what the last car asked for. + needs *ChargingNeeds +} + +// 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{}, + approved: map[string]bool{}, + probing: map[string]bool{}, + nextTxID: 1, + } +} + +// SetApprovedIDs declares which charger ids are part of the site — the ids +// loadpoints name in config. Everything else that connects is quarantined as +// pending: accepted at the protocol level so the operator can see it in the +// UI and adopt it, but kept out of telemetry so an unknown-but-authenticated +// device cannot fabricate EV load and steer dispatch (the DerEV sum +// suppresses home-battery discharge). Replaces the previous set. +// +// Loadpoints hot-reload, so this is called again on every config apply: +// adoption takes effect on the next message from the charger, and a charger +// whose loadpoint was removed goes back to pending — with a zero reading +// pushed first, for the same reason OnDisconnect pushes one: its last +// non-zero power must not linger in the DerEV sum it is no longer part of. +func (h *Handler) SetApprovedIDs(ids []string) { + m := make(map[string]bool, len(ids)) + for _, id := range ids { + if id != "" { + m[id] = true + } + } + h.mu.Lock() + var revoked []string + for id := range h.chargers { + if h.approved[id] && !m[id] { + revoked = append(revoked, id) + } + } + h.approved = m + h.mu.Unlock() + for _, id := range revoked { + blob, _ := json.Marshal(map[string]any{"type": "ev", "w": 0.0}) + h.tel.Update(id, telemetry.DerEV, 0, nil, blob) + } +} + +// isApproved reports whether a charger id is named by a charger entry +// (loadpoint) and therefore allowed to feed the site model. +func (h *Handler) isApproved(id string) bool { + h.mu.Lock() + defer h.mu.Unlock() + return h.approved[id] +} + +// The tel* wrappers are the quarantine choke points: every telemetry write +// for a charger goes through one of them, so a pending charger is dropped +// here once rather than gated at each call site. +func (h *Handler) telSuccess(id string) { + if h.isApproved(id) { + h.tel.RecordDriverSuccess(id) + } +} + +func (h *Handler) telError(id, msg string) { + if h.isApproved(id) { + h.tel.RecordDriverError(id, msg) + } +} + +func (h *Handler) telMetric(id, name string, v float64, unit string) { + if h.isApproved(id) { + h.tel.EmitMetric(id, name, v, unit, "", "") + } +} + +// SetVehicleIdentified registers the callback fired when a transaction on an +// approved charger presents a new vehicle identity (RFID idTag on 1.6, any +// idToken on 2.0.1). Fired outside the handler lock. +func (h *Handler) SetVehicleIdentified(fn func(chargerID, vehicleID, source string)) { + h.mu.Lock() + h.vehicleIdentified = fn + h.mu.Unlock() +} + +// setControlCapability records a capability-probe answer and the verdict +// derived from it. A telemetry-only verdict is worth a warning: the operator +// may be about to bind a charger the planner can never steer. +func (h *Handler) setControlCapability(id, raw string, steerable bool) { + h.mu.Lock() + s := h.chargersLocked(id) + s.featureProfiles = raw + b := steerable + s.steerable = &b + h.mu.Unlock() + if steerable { + slog.Info("ocpp: charger supports smart charging", "charger", id, "profiles", raw) + } else { + slog.Warn("ocpp: charger declares no smart-charging support — telemetry only, FTW cannot steer it", + "charger", id, "profiles", raw) + } +} + +// maybeProbeCapability fires the capability probe unless the charger has +// already answered one, or one is in flight. Called from OnConnect and boot +// notifications so a charger that ignores the probe pre-boot gets asked again +// post-boot, and again on every reconnect, until an answer sticks. +func (h *Handler) maybeProbeCapability(id string) { + h.mu.Lock() + fn := h.capabilityProbe + skip := h.chargersLocked(id).steerable != nil || h.probing[id] + if fn != nil && !skip { + h.probing[id] = true + } + h.mu.Unlock() + if fn != nil && !skip { + go fn(id) + } +} + +// probeFinished releases the in-flight marker, whatever the outcome. A probe +// that failed leaves steerable nil, so the next connect asks again. +func (h *Handler) probeFinished(id string) { + h.mu.Lock() + delete(h.probing, id) + h.mu.Unlock() +} + +// noteVehicleID records the identity a transaction presented and fires the +// vehicleIdentified callback when it is new for this charger. Quarantine +// applies: a pending charger's identity is stored (the UI shows it so the +// operator can build a profile from it) but never fires the callback. +func (h *Handler) noteVehicleID(id, vehicleID, source string) { + if vehicleID == "" { + return + } + h.mu.Lock() + s := h.chargersLocked(id) + changed := s.vehicleID != vehicleID + s.vehicleID = vehicleID + s.vehicleIDSource = source + fn := h.vehicleIdentified + approved := h.approved[id] + h.mu.Unlock() + if changed && approved && fn != nil { + fn(id, vehicleID, source) + } +} + +// 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 { + v := ChargerView{ + Online: s.online, + Connected: s.connected, + Charging: s.charging, + PowerW: s.lastPowerW, + SessionWh: s.sessionMeterWh, + TxID: s.transactionID, + Version: string(s.version), + LastAmps: s.lastAmps, + Vendor: s.vendor, + Model: s.model, + Serial: s.serial, + Firmware: s.firmware, + Pending: !h.approved[id], + VehicleID: s.vehicleID, + VehicleIDSource: s.vehicleIDSource, + FeatureProfiles: s.featureProfiles, + } + if s.steerable != nil { + b := *s.steerable + v.Steerable = &b + } + if s.needs != nil { + n := *s.needs + v.ChargingNeeds = &n + } + out[id] = v + } + return out +} + +// ChargerView is the public snapshot of a charger's state. +type ChargerView struct { + // Online is the WebSocket session; Connected is a vehicle on the + // connector. A charger is usually online long before it is connected. + Online bool `json:"online"` + Connected bool `json:"connected"` + Charging bool `json:"charging"` + PowerW float64 `json:"power_w"` + SessionWh float64 `json:"session_wh"` + TxID int `json:"tx_id"` + // Version is the OCPP dialect ("1.6" or "2.0.1"); empty until known. + Version string `json:"version,omitempty"` + // LastAmps is the last non-zero per-phase limit the charger accepted — + // what a resume would restore, not necessarily what is flowing now. + LastAmps float64 `json:"last_amps,omitempty"` + Vendor string `json:"vendor,omitempty"` + Model string `json:"model,omitempty"` + // Serial is the hardware-stable half of the charger's identity, and + // what its /api/devices row is keyed on. Empty when the charger + // reports none — plenty do not. + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + // Pending is set when no charger entry (loadpoint) names this id. A + // pending charger is visible here but quarantined from the site: its + // telemetry is withheld from dispatch until an operator adopts it. + Pending bool `json:"pending,omitempty"` + // VehicleID is the identity the current/last transaction presented — + // an RFID idTag on 1.6 (names the card), a MacAddress/eMAID idToken on + // 2.0.1 (names the car). VehicleIDSource says which kind. + VehicleID string `json:"vehicle_id,omitempty"` + VehicleIDSource string `json:"vehicle_id_source,omitempty"` + // FeatureProfiles is the charger's raw capability answer (see + // capabilities.go); Steerable the verdict: absent = charger never + // answered the probe, false = telemetry only, true = accepts charging + // profiles. + FeatureProfiles string `json:"feature_profiles,omitempty"` + Steerable *bool `json:"steerable,omitempty"` + // ChargingNeeds is the last thing the car itself asked for, when it + // said (2.0.1 NotifyEVChargingNeeds). Absent on 1.6, which has no such + // message, and on any session the EV did not negotiate. + ChargingNeeds *ChargingNeeds `json:"charging_needs,omitempty"` +} + +// OnConnect / OnDisconnect are wired by the Server to the OCPP library's +// connection callbacks, not part of CoreHandler. +func (h *Handler) OnConnect(id string) { + if h.isApproved(id) { + slog.Info("OCPP charger connected", "charger", id) + } else { + slog.Info("OCPP charger connected as pending — no charger entry names it, telemetry withheld", + "charger", id) + } + s := h.state(id) + h.mu.Lock() + s.online = true + h.mu.Unlock() + h.telSuccess(id) + h.maybeProbeCapability(id) +} + +func (h *Handler) OnDisconnect(id string) { + slog.Info("OCPP charger disconnected", "charger", id) + s := h.state(id) + h.mu.Lock() + // A probe in flight when the socket dropped may never get its callback, + // which would leave the marker set and block every future probe. The + // reconnect is exactly when we want to ask again, so clear it here. + delete(h.probing, id) + 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) { + // 1.6 has two serial fields. chargePointSerialNumber is the current + // one; chargeBoxSerialNumber is deprecated in the spec and still what + // a good deal of shipped firmware fills in, so fall back to it rather + // than lose the only hardware-stable identity a charger will give us. + serial := req.ChargePointSerialNumber + if serial == "" { + serial = req.ChargeBoxSerialNumber + } + slog.Info("OCPP boot", + "charger", id, + "vendor", req.ChargePointVendor, + "model", req.ChargePointModel, + "serial", serial, + "fw", req.FirmwareVersion) + s := h.state(id) + h.mu.Lock() + s.vendor = req.ChargePointVendor + s.model = req.ChargePointModel + s.serial = serial + s.firmware = req.FirmwareVersion + h.mu.Unlock() + h.noteIdentity(id) + h.telSuccess(id) + h.maybeProbeCapability(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.telSuccess(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.telMetric(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.telSuccess(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.telSuccess(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) + // The 1.6 idTag names the RFID card that started the session — the + // closest thing to a vehicle identity this dialect has. + h.noteVehicleID(id, req.IdTag, "rfid") + h.pushReading(id, s) + h.telSuccess(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.telMetric(id, "ev_session_wh", sessionWh, "Wh") + h.telSuccess(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() + approved := h.approved[id] + w := s.lastPowerW + data := map[string]any{ + "type": "ev", + "w": w, + "connected": s.connected, + "charging": s.charging, + "session_wh": s.sessionMeterWh, + } + h.mu.Unlock() + if !approved { + // Pending charger: visible in Snapshot, absent from the site model. + return + } + 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 000000000..5b9e0520f --- /dev/null +++ b/go/internal/ocpp/handlers_v201.go @@ -0,0 +1,268 @@ +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) + + s := h.state(id) + h.mu.Lock() + s.vendor = vendor + s.model = model + s.serial = serial + if req != nil && req.ChargingStation.FirmwareVersion != "" { + s.firmware = req.ChargingStation.FirmwareVersion + } + h.mu.Unlock() + h.setVersion(id, Version201) + h.noteIdentity(id) + h.telSuccess(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.telSuccess(id) + return provisioning.NewNotifyReportResponse(), nil +} + +// ---- availability.CSMSHandler ---- + +func (h *handlerV201) OnHeartbeat(id string, _ *availability.HeartbeatRequest) (*availability.HeartbeatResponse, error) { + h.telSuccess(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.telError(id, "ocpp: connector faulted") + } else { + h.telSuccess(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) + + // 2.0.1 idTokens can name the actual vehicle: MacAddress is autocharge, + // eMAID is ISO 15118 Plug & Charge. Cards (ISO14443/ISO15693) still + // only name the card. The token may ride on any event type. + if req.IDToken != nil { + h.noteVehicleID(id, req.IDToken.IdToken, string(req.IDToken.Type)) + } + h.pushReading(id, s) + if ended { + h.telMetric(id, "ev_session_wh", sessionWh, "Wh") + } + h.telSuccess(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.telSuccess(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.telSuccess(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/identity.go b/go/internal/ocpp/identity.go new file mode 100644 index 000000000..593ff9697 --- /dev/null +++ b/go/internal/ocpp/identity.go @@ -0,0 +1,103 @@ +package ocpp + +// Hardware identity for a driverless device. +// +// Everything else in FTW is a device because config named a driver and the +// driver reported a serial. A charge point is the other way round: it dials +// us, picks its own name, and tells us what it is only in BootNotification. +// +// The name it picked is not identity. It is the last segment of a URL the +// installer typed, it is what a charger entry adopts, and it can be changed on +// the charger's own web page — that makes it a YAML name by another route, and +// persistent state keyed on it would not survive a re-commissioning. The +// vendor and serial from BootNotification are the hardware-stable pair, and +// they are what the device row is keyed on. The URL identity is the fallback +// for a charger that reports no serial at all, recorded as an endpoint so it +// reads as what it is: stable only until someone changes it. + +import "log/slog" + +// ChargerIdentity is what a charge point told us about itself, in the shape +// the device registry wants. +type ChargerIdentity struct { + // ID is the identity the charger dialled with — its driver name in + // telemetry, and what a charger entry adopts. + ID string + // Vendor, Model, Serial and Firmware come from BootNotification. Any of + // them may be empty; chargers vary in what they bother to report. + Vendor string + Model string + Serial string + Firmware string +} + +// Identities returns the adopted chargers that have told us what they are. +// +// Pending chargers are left out on purpose: a device row is a statement that +// this hardware is part of the site, and quarantine says an unadopted charge +// point is not. It is visible in the Chargers panel for an operator to adopt, +// and gets its row on the next boot or config apply after that. +func (h *Handler) Identities() []ChargerIdentity { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]ChargerIdentity, 0, len(h.chargers)) + for id, s := range h.chargers { + if !h.approved[id] { + continue + } + if s.vendor == "" && s.serial == "" { + // Nothing to key on yet — the charger has connected but not + // booted. Registering now would create a row keyed on the URL + // identity that the real serial could never replace. + continue + } + out = append(out, ChargerIdentity{ + ID: id, + Vendor: s.vendor, + Model: s.model, + Serial: s.serial, + Firmware: s.firmware, + }) + } + return out +} + +// SetIdentityReported registers the callback fired when an adopted charger +// reports what it is. main.go writes the device row from it. +func (h *Handler) SetIdentityReported(fn func(ChargerIdentity)) { + if h == nil { + return + } + h.mu.Lock() + h.identityReported = fn + h.mu.Unlock() +} + +// noteIdentity fires the callback after a BootNotification. Quarantine +// applies: a pending charger's identity is recorded and shown, and never +// becomes a device. +func (h *Handler) noteIdentity(id string) { + h.mu.Lock() + s := h.chargersLocked(id) + ident := ChargerIdentity{ + ID: id, + Vendor: s.vendor, + Model: s.model, + Serial: s.serial, + Firmware: s.firmware, + } + fn := h.identityReported + approved := h.approved[id] + h.mu.Unlock() + if !approved || fn == nil { + return + } + if ident.Vendor == "" && ident.Serial == "" { + slog.Info("ocpp: charger reported neither vendor nor serial — device identity falls back to the name it dialled with", + "charger", id) + } + fn(ident) +} diff --git a/go/internal/ocpp/identity_test.go b/go/internal/ocpp/identity_test.go new file mode 100644 index 000000000..d20c459ad --- /dev/null +++ b/go/internal/ocpp/identity_test.go @@ -0,0 +1,143 @@ +package ocpp + +import ( + "fmt" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A device row is a statement that this hardware is part of the site, so +// quarantine covers it: a pending charge point's identity is recorded and +// shown, and never becomes a device. +func TestIdentityIsWithheldFromPendingChargers(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var reported []ChargerIdentity + h.SetIdentityReported(func(i ChargerIdentity) { + reported = append(reported, i) + }) + + for _, id := range []string{"garage", "intruder"} { + s := h.state(id) + h.mu.Lock() + s.vendor, s.model, s.serial = "Charge Amps", "Halo", "SN-"+id + h.mu.Unlock() + h.noteIdentity(id) + } + + if len(reported) != 1 || reported[0].ID != "garage" { + t.Fatalf("identity reported for %v, want only the adopted charger", reported) + } + if reported[0].Serial != "SN-garage" { + t.Errorf("serial: got %q, want SN-garage", reported[0].Serial) + } + + ids := h.Identities() + if len(ids) != 1 || ids[0].ID != "garage" { + t.Fatalf("Identities returned %v, want only the adopted charger", ids) + } +} + +// Adoption happens long after a BootNotification — an operator binds a pending +// charger to a loadpoint — and the charger will not boot again for it. The +// catch-up path is Identities, so it must return a charger that booted while +// it was still pending. +func TestIdentitiesCatchUpAfterAdoption(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + + s := h.state("garage") + h.mu.Lock() + s.vendor, s.serial = "Easee", "EH123456" + h.mu.Unlock() + h.noteIdentity("garage") + + if got := h.Identities(); len(got) != 0 { + t.Fatalf("a pending charger should have no identity to register, got %v", got) + } + + h.SetApprovedIDs([]string{"garage"}) + got := h.Identities() + if len(got) != 1 || got[0].Serial != "EH123456" { + t.Fatalf("after adoption: got %v, want the charger that already booted", got) + } +} + +// A charger that has connected but not booted has nothing hardware-stable to +// key on. Registering it then would create a row keyed on the name it dialled +// with, which the real serial could never replace. +func TestIdentityWaitsForBoot(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + h.OnConnect("garage") + + if got := h.Identities(); len(got) != 0 { + t.Fatalf("connected but not booted should yield no identity, got %v", got) + } +} + +// 1.6 has two serial fields and shipped firmware disagrees about which to +// fill, so losing the deprecated one loses the only stable identity some +// chargers ever report. +func TestBootNotificationTakesEitherSerialField(t *testing.T) { + tests := []struct { + name string + props []func(*core.BootNotificationRequest) + want string + }{ + { + name: "chargePointSerialNumber", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { r.ChargePointSerialNumber = "CP-1" }}, + want: "CP-1", + }, + { + name: "deprecated chargeBoxSerialNumber", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { r.ChargeBoxSerialNumber = "BOX-1" }}, + want: "BOX-1", + }, + { + name: "both, the current field wins", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { + r.ChargePointSerialNumber = "CP-1" + r.ChargeBoxSerialNumber = "BOX-1" + }}, + want: "CP-1", + }, + {name: "neither", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + id := "boot-" + tc.name + port, srv := startServer(t, telemetry.NewStore(), id) + t.Cleanup(srv.Stop) + + cp := ocpp16.NewChargePoint(id, nil, nil) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(cp.Stop) + + if _, err := cp.BootNotification("Halo", "Charge Amps", tc.props...); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if v, ok := srv.Handler().Snapshot()[id]; ok && v.Vendor != "" { + if v.Serial != tc.want { + t.Fatalf("serial: got %q, want %q", v.Serial, tc.want) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("charger never appeared in the snapshot") + }) + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go new file mode 100644 index 000000000..8bf3616ce --- /dev/null +++ b/go/internal/ocpp/server.go @@ -0,0 +1,284 @@ +// Package ocpp is the OCPP Central System for FTW, speaking 1.6J and 2.0.1. +// +// EV chargers connect to us via WebSocket. For a charger a loadpoint names in +// config, 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. +// +// A charger no loadpoint names is quarantined as "pending": it may stay +// connected and is visible in Snapshot so the UI can offer it for adoption, +// but none of its messages reach telemetry — an unknown device that merely +// knows the shared basic-auth secret cannot fabricate EV load and steer +// dispatch. See Handler.SetApprovedIDs. +// +// # 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" + "net/http" + "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() + + auth := newAuthorizer(cfg) + wsServer, err := newListener(cfg, auth) + if err != nil { + return nil, err + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + h.SetApprovedIDs(cfg.ApprovedIDs) + cs.SetCoreHandler(h) + cs.SetNewChargePointHandler(func(cp ocpp16.ChargePointConnection) { + // Which listener a charger reached is what identifies its dialect, so + // record it here rather than inferring it from a later message — and + // before OnConnect, whose capability probe dispatches on it. + h.setVersion(cp.ID(), Version16) + h.OnConnect(cp.ID()) + }) + 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, err := newListener(cfg, auth) + if err != nil { + return nil, err + } + h201 := &handlerV201{Handler: h} + csms := ocpp201.NewCSMS(nil, wsServer201) + csms.SetProvisioningHandler(h201) + csms.SetAvailabilityHandler(h201) + csms.SetTransactionsHandler(h201) + csms.SetMeterHandler(h201) + csms.SetAuthorizationHandler(h201) + // Smart charging is registered for what the car reports, not for + // what we send: charging profiles go out through control.go. The + // one message here that changes behaviour is + // NotifyEVChargingNeeds; the rest of the profile is acknowledged + // and dropped. See charging_needs.go. + csms.SetSmartChargingHandler(h201) + csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { + h.setVersion(cs.ID(), Version201) + h.OnConnect(cs.ID()) + }) + 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, "scheme", cfg.Scheme(), + "bind", cfg.Bind, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", auth.requiresCredential(), + "per_charger_credentials", len(cfg.ChargerSecrets), + "client_certs", cfg.TLS != nil && cfg.TLS.ClientCAFile != "") + csms.Start(cfg.PortV201, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + } + + // Capability probe: whether a charger can be steered (SmartCharging / + // SmartChargingCtrlr) or only meters. Fired by the Handler from connect + // and boot until the charger answers; dispatched here by dialect. The + // closure reads s.csms at call time, so a 2.0.1 charger probes correctly + // even though the CSMS is wired after the 1.6 server. + h.capabilityProbe = func(id string) { + h.mu.Lock() + ver := h.chargersLocked(id).version + h.mu.Unlock() + if ver == Version201 && s.csms != nil { + probeSmartChargingV201(s.csms, h, id) + return + } + probeFeatureProfiles16(cs, h, id) + } + + go func() { + defer close(s.done) + slog.Info("OCPP central system listening", + "version", Version16, "scheme", cfg.Scheme(), + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", auth.requiresCredential(), + "per_charger_credentials", len(cfg.ChargerSecrets), + "client_certs", cfg.TLS != nil && cfg.TLS.ClientCAFile != "") + // The socket itself is opened on every interface — ws.Server.Start + // builds its address from the port alone. cfg.Bind is enforced one + // layer up, in authorizer.checkClient, which refuses the handshake + // for a connection that arrived somewhere else. + // 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 +} + +// newListener builds one version's WebSocket server — TLS when configured — +// with both authorization gates wired. +// +// The basic-auth handler is registered only when a credential exists: the +// library reads a registered handler as "credentials are mandatory" and +// answers 401 to a charger that sends none, so registering it unconditionally +// would lock out every charger on a server with no username instead of +// admitting them all. checkClient is always safe to register; it authorizes +// everything when nothing is configured. +func newListener(cfg *Config, auth *authorizer) (ws.WsServer, error) { + var srv *ws.Server + if cfg.TLS.configured() { + // Half a TLS section is an error, not a reason to serve plaintext: + // an operator who asked for wss:// and silently got ws:// would + // have no way to tell the link was never encrypted. + tlsCfg, err := cfg.TLS.serverTLS() + if err != nil { + return nil, err + } + srv = ws.NewTLSServer(cfg.TLS.CertFile, cfg.TLS.KeyFile, tlsCfg) + } else { + srv = ws.NewServer() + } + if auth.requiresCredential() { + srv.SetBasicAuthHandler(auth.basicAuth) + } + srv.SetCheckClientHandler(auth.checkClient) + return &guardedServer{Server: srv, check: auth.checkClient}, nil +} + +// guardedServer keeps our connection check installed. +// +// ocppj.Server.Start unconditionally calls SetCheckClientHandler with its own +// handler, which is nil unless the caller reached past the 1.6/2.0.1 facade to +// set one. Handing the raw ws.Server to NewCentralSystem therefore discards +// the bind and identity gates silently at startup — the listener comes up, the +// logs say the gates are configured, and every impersonation attempt is +// accepted. This wrapper chains instead of replacing, so ours runs first and +// the library's own check still runs after. +type guardedServer struct { + *ws.Server + check func(id string, r *http.Request) bool +} + +func (g *guardedServer) SetCheckClientHandler(handler func(id string, r *http.Request) bool) { + g.Server.SetCheckClientHandler(func(id string, r *http.Request) bool { + if !g.check(id, r) { + return false + } + return handler == nil || handler(id, r) + }) +} + +// 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 000000000..c5c385ed9 --- /dev/null +++ b/go/internal/ocpp/server_test.go @@ -0,0 +1,302 @@ +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. +// approved lists the charger ids treated as loadpoint-named; chargers outside +// it connect as pending and push no telemetry. +func startServer(t *testing.T, tel *telemetry.Store, approved ...string) (int, *Server) { + t.Helper() + port := freePort(t) + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: port, HeartbeatIntervalS: 60, ApprovedIDs: approved} + 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, "EH123456") + 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, "EH-SESSION") + defer srv.Stop() + + // The idTag that starts a transaction is the session's vehicle identity + // — main.go turns it into a vehicle-profile lookup, so the callback and + // the snapshot must both carry it. + var idMu sync.Mutex + var identified [][3]string + srv.Handler().SetVehicleIdentified(func(chargerID, vehicleID, source string) { + idMu.Lock() + identified = append(identified, [3]string{chargerID, vehicleID, source}) + idMu.Unlock() + }) + + 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) + } + idView := srv.Handler().Snapshot()["EH-SESSION"] + if idView.VehicleID != "RFID-ABCD" || idView.VehicleIDSource != "rfid" { + t.Errorf("vehicle identity not captured: %+v", idView) + } + idMu.Lock() + if len(identified) != 1 || identified[0] != [3]string{"EH-SESSION", "RFID-ABCD", "rfid"} { + t.Errorf("vehicleIdentified callback: got %v", identified) + } + idMu.Unlock() + + 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) +} + +// A charger no loadpoint names may connect and is visible in Snapshot, but +// nothing it reports may reach telemetry — otherwise any device holding the +// shared basic-auth secret could fabricate EV load and steer dispatch. +func TestPendingChargerIsQuarantinedFromTelemetry(t *testing.T) { + tel := telemetry.NewStore() + port, srv := startServer(t, tel, "adopted-charger") + defer srv.Stop() + + cp := ocpp16.NewChargePoint("intruder", 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("EvilCo", "FakeCharger"); err != nil { + t.Fatalf("boot: %v", err) + } + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusCharging); err != nil { + t.Fatalf("status: %v", err) + } + 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) + } + + // Wait until the handler has demonstrably processed the meter values — + // the snapshot shows the power — so the nil telemetry check below is a + // real assertion, not a race won by asserting too early. + deadline := time.Now().Add(2 * time.Second) + var view ChargerView + for time.Now().Before(deadline) { + view = srv.Handler().Snapshot()["intruder"] + if view.PowerW == 7200 { + break + } + time.Sleep(50 * time.Millisecond) + } + if view.PowerW != 7200 { + t.Fatalf("snapshot never showed the meter value: %+v", view) + } + if !view.Pending { + t.Errorf("expected pending=true for an unadopted charger, got %+v", view) + } + if r := tel.Get("intruder", telemetry.DerEV); r != nil { + t.Errorf("pending charger leaked into telemetry: %+v", r) + } + + if srv.Handler().isApproved("intruder") { + t.Error("intruder must not be approved") + } + if !srv.Handler().isApproved("adopted-charger") { + t.Error("adopted-charger should be approved") + } + + // Hot adoption: loadpoints hot-reload and the config applier re-calls + // SetApprovedIDs, so naming the id admits the very next reading — no + // reconnect, no restart. + srv.Handler().SetApprovedIDs([]string{"intruder"}) + if _, err := cp.MeterValues(1, mv); err != nil { + t.Fatalf("meter values after adopt: %v", err) + } + deadline = time.Now().Add(2 * time.Second) + var r *telemetry.DerReading + for time.Now().Before(deadline) { + r = tel.Get("intruder", telemetry.DerEV) + if r != nil && r.RawW == 7200 { + break + } + time.Sleep(50 * time.Millisecond) + } + if r == nil || r.RawW != 7200 { + t.Fatalf("adopted charger should reach telemetry, got %+v", r) + } + + // Revocation pushes a zero synchronously so the last power figure + // cannot linger in the DerEV sum, and the charger is pending again. + srv.Handler().SetApprovedIDs(nil) + if r := tel.Get("intruder", telemetry.DerEV); r == nil || r.RawW != 0 { + t.Fatalf("revocation should zero the reading, got %+v", r) + } + if v := srv.Handler().Snapshot()["intruder"]; !v.Pending { + t.Errorf("revoked charger should be pending, got %+v", v) + } +} + +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 000000000..20a129a5a --- /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() +} diff --git a/web/index.html b/web/index.html index 84292e588..39cdffa69 100644 --- a/web/index.html +++ b/web/index.html @@ -93,7 +93,7 @@

Settings