diff --git a/.changeset/chargers-tab-ocpp-panel.md b/.changeset/chargers-tab-ocpp-panel.md new file mode 100644 index 00000000..fc440894 --- /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 00000000..955b0242 --- /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 00000000..e1d37ca7 --- /dev/null +++ b/.changeset/ocpp-central-system-restore.md @@ -0,0 +1,31 @@ +--- +"ftw": minor +--- + +Add OCPP 1.6J support so EV chargers connect to FTW directly instead of through +a vendor cloud. An OCPP charger needs no driver: the protocol is vendor-neutral, +so one server in core handles every charger that speaks it. + +Chargers dial FTW rather than the other way round, so there is nothing to add +under `drivers:`. A charge point becomes a device on its first BootNotification, +keyed by the last segment of the URL it connected to, and dispatch treats it +like any other EV reading. + +This reinstates `go/internal/ocpp`, retired as unused in #578, and wires it into +the process behind a new `ocpp` config section. It matters because Charge Amps +has no FTW driver at all and every current model speaks OCPP, while Easee and +Zaptec can be commissioned once through their vendor portal and then run with no +cloud in the runtime path. + +FTW throttles, pauses and resumes an OCPP charger like any other EV charger. +Every command is a current limit rather than a remote start or stop, because +`RemoteStopTransaction` is unreliable on Charge Amps hardware — units +acknowledge the stop and resume charging on their own, while a 0 A charging +profile is honoured consistently and keeps the session meter intact across a +pause. Below the IEC 61851 minimum of 6 A the charger is told 0 A rather than +being rounded up to current the site fuse was not asked to carry. + +The server is off by default. Enabling it requires a username and password, and +FTW refuses to start without them: the OCPP library builds its listen address +from the port alone, so the socket is reachable on every interface and basic +auth is the only gate. Keep the port closed at your router. diff --git a/.changeset/ocpp-pending-quarantine.md b/.changeset/ocpp-pending-quarantine.md new file mode 100644 index 00000000..8d2b2f54 --- /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 00000000..59740198 --- /dev/null +++ b/.changeset/ocpp-v201-support.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +Serve OCPP 2.0.1 alongside 1.6J, so newer chargers connect without a driver too. + +Each version listens on its own port. A charger picks its dialect during the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so a single port cannot serve both. +Set `ocpp.port_v201` to enable 2.0.1; leaving it unset keeps 1.6J only. + +Only the message encoding differs. Both dialects share one charger map, one +telemetry path and one control path, so a 2.0.1 charger is metered, throttled +and paused exactly like a 1.6 one, and dispatch cannot tell them apart. + +2.0.1 restructures the messages more than the names suggest: StartTransaction +and StopTransaction collapse into a single TransactionEvent, transaction ids +become strings, connector status loses its charging meaning, and meter samples +arrive inside transaction events as well as on their own. The new handler +normalises all of that back to the same charger state. + +OCPP 2.1 is not supported. No production-grade Go implementation of it exists: +the library FTW uses covers 1.6 and 2.0.1 and has no 2.1 support, and the Go +projects that do claim 2.1 are early-stage validators and emulators rather than +servers. Adding it later is one more handler and one more listener; the +version-neutral core does not change. diff --git a/.changeset/vehicle-profiles.md b/.changeset/vehicle-profiles.md new file mode 100644 index 00000000..d596d828 --- /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 9c2bd240..9ef1073d 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: @@ -200,6 +210,7 @@ metadata are the detailed reference. - [Operations and recovery](docs/operations.md) - [Full backup and safe restore](docs/backup-and-restore.md) - [Writing a driver](docs/writing-a-driver.md) +- [OCPP chargers (no driver needed)](docs/ocpp.md) - [Device driver catalog](https://srcfl.github.io/device-drivers/) — every supported device and the evidence behind it - [Self-update and release channels](docs/self-update.md) - [Home Assistant](docs/ha-integration.md) diff --git a/config.example.yaml b/config.example.yaml index 6ec593cf..2818828e 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -171,6 +171,29 @@ caldav: plan_path: /ftw/plan/ # plan_publish_interval_s: 900 # how often the plan calendar is reconciled +# Built-in OCPP Central System, speaking 1.6J and 2.0.1. EV chargers that speak +# OCPP connect to FTW directly — there is no driver to write and nothing to add +# under `drivers:`. +# A charger appears as a device on its first BootNotification, keyed by the last +# segment of the URL it dialled: ws://:8887/garage-left +# +# Each version needs its own port: a charger picks its dialect in the WebSocket +# handshake, so one listener cannot serve both. 2.1 is not supported — no +# production-grade Go implementation of it exists yet. +# +# Credentials are required when enabled, and FTW refuses to start without them: +# the OCPP library builds its listen address from the port alone, so the socket +# is reachable on every interface and basic auth is the only gate. Keep the port +# closed at your router. See docs/ocpp.md. +ocpp: + enabled: false + port: 8887 # OCPP 1.6J + # port_v201: 8888 # OCPP 2.0.1; omit to disable + path: / + username: ftw + password: "" # required when enabled; use a long random string + heartbeat_interval_s: 60 + # Persistent state (SQLite) state: path: state.db diff --git a/docs/ocpp.md b/docs/ocpp.md new file mode 100644 index 00000000..4e7e070d --- /dev/null +++ b/docs/ocpp.md @@ -0,0 +1,315 @@ +# 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. + +## Protocol versions + +| Version | Status | Port | +|---|---|---| +| 1.6J | supported | `port`, default 8887 | +| 2.0.1 | supported | `port_v201`, off unless set | +| 2.1 | not yet — see below | — | + +**Each version needs its own port.** A charger picks its dialect in the +WebSocket handshake, before any message is sent, and the underlying library +keeps one message handler per listener — so one port cannot serve both. Point +each charger at the port matching what it speaks. + +Everything above the protocol is shared. Both dialects land in the same charger +map, produce the same telemetry, and take the same commands, so a 2.0.1 charger +is throttled and paused exactly like a 1.6 one and nothing downstream knows the +difference. + +### On OCPP 2.1 + +2.1 is not supported, because no production-grade Go implementation of it +exists. `lorenzodonini/ocpp-go`, the library FTW uses and the only mature option +at 367 stars, implements 1.6 and 2.0.1 and has no 2.1 support. The Go projects +that do claim 2.1 are all early — single-digit stars, and mostly message +validators or emulators rather than servers. + +This is not a blocker in practice. Every charger on the bench speaks 1.6J only, +and 2.0.1 is what current hardware is migrating to. + +Adding 2.1 later means writing one more handler file and one more listener. The +version-neutral core — charger state, telemetry mapping, control semantics — +does not change. + +## Where the code comes from + +The protocol layer is [`github.com/lorenzodonini/ocpp-go`](https://github.com/lorenzodonini/ocpp-go) +v0.19.0, under the MIT license. It is an ordinary Go module dependency, pinned in +`go/go.mod` and checksum-verified through `go/go.sum`. Nothing in FTW is copied +or forked from it. + +| Layer | Owner | +|---|---| +| WebSocket transport, OCPP-J framing, message types, schema validation | ocpp-go | +| Handlers, telemetry mapping, control semantics, safety clamps | FTW, `go/internal/ocpp` | + +That split is the first thing to check when something misbehaves. A malformed +message or a dropped connection is upstream; a wrong power figure or a wrong +current limit is ours. + +Upstream health, as of this writing: 367 stars, MIT, no release since August +2025, and it describes its own 2.0.1 support as "examples working, but will need +more real-world testing". Treat the 2.0.1 path here as less proven than 1.6J +regardless of FTW's own tests. + +If FTW ever needs a fix upstream will not take, the move is a `srcfl/ocpp-go` +fork plus a `replace` directive in `go.mod` — still an ordinary module. A git +submodule is not an option: `go build` resolves dependencies through `go.mod` +and the module cache, so a submodule checkout would be inert unless paired with +that same `replace`, while additionally breaking `go install` and any clone +made without `--recurse-submodules`. To pin the source inside this repository +instead, the Go-native answer is `go mod vendor`, which commits the dependency +tree under `vendor/` and is understood by the toolchain without extra flags. + +## Enabling the server + +OCPP is off by default. Add an `ocpp` section: + +```yaml +ocpp: + enabled: true + port: 8887 # OCPP 1.6J, default 8887 + port_v201: 8888 # OCPP 2.0.1; omit or 0 to disable + path: / # default / + username: ftw + password: + heartbeat_interval_s: 60 +``` + +**Credentials are mandatory.** FTW refuses to start with `enabled: true` and an +empty username or password. That is deliberate, and the reason is below. + +## Security: the listener is on every interface + +The OCPP library builds its listen address from the port alone, so the socket +binds to every interface the host has. There is no bind-address setting, and +there is no TLS on this path yet. + +On a Raspberry Pi with one LAN connection that is usually fine. It is not fine +if the host also has a public interface or a permissive port forward. + +So: + +- Keep the port closed at your router. Never forward it from the internet. +- Treat the password as a real secret; it is the only gate in front of the + server. +- Basic auth over `ws://` sends the credential unencrypted. Anyone who can sniff + your LAN can read it. + +Behind the password sits a second gate: a charge point no charger entry names +stays pending, outside telemetry and dispatch (see above). A stolen password +gets an attacker a row in the Chargers table, not influence over the site. +What it does not stop is impersonation — a device that knows the password *and* +an adopted charger's id can still pose as it, which is what per-charger +credentials and TLS would fix. + +Credentials being required is a mitigation, not a fix. Binding to one interface +needs a change to the upstream library. + +## Pointing a charger at FTW + +The backend URL is always `ws://:`. What differs +is how you reach the charger to set it. + +**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. + +## 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_pct: 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. With + ISO 15118 hardware, `NotifyEVChargingNeeds` can additionally state the + energy the car actually wants; consuming that is future work, tracked with + OCPP 2.1 in issue #835. + +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. + +## Current limits + +- **No TLS**, and the listener cannot be pinned to one interface. +- Chargers that also have a native protocol may work better through a driver. + Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is + the option when you want the cloud out of the loop. + +## When you still need a driver + +Only for chargers that do not speak OCPP, or where a vendor protocol exposes +something OCPP does not. The Ambibox V2G / InterControl ambiCHARGE is the +example on the bench: it is DC-coupled and bidirectional, runs over MQTT, and +uses the `ambibox_v2x` driver. + +See [writing-a-driver.md](writing-a-driver.md) for that path. diff --git a/docs/writing-a-driver.md b/docs/writing-a-driver.md index a0cdad33..4a0db9f0 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 ece918d2..ec459479 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -58,6 +58,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" @@ -745,6 +746,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 @@ -894,6 +901,19 @@ 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) + } + // Notifications: rebuild the provider from fresh config // (handles the cold-start case where the initial config // had no notifications: block and notifProvider was nil), @@ -1153,6 +1173,100 @@ func main() { slog.Info("caldav started", "listen", cfg.CalDAV.ListenAddr(), "url", cfg.CalDAV.URL, "calendar", cfg.CalDAV.CalendarPath) } + // ---- Start OCPP Central System, 1.6J and 2.0.1 (optional) ---- + // Chargers dial us, so there is nothing to add to cfg.Drivers and no Lua + // driver involved. A charge point 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) + } + } + srv, err := ocpp.Start(ctx, &ocpp.Config{ + Enabled: cfg.OCPP.Enabled, + Port: cfg.OCPP.Port, + PortV201: cfg.OCPP.PortV201, + Path: cfg.OCPP.Path, + Username: cfg.OCPP.Username, + Password: cfg.OCPP.Password, + HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + ApprovedIDs: approved, + }, 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. + 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.TargetSoCPct > 0 { + lpMgr.SetTarget(lpID, vehicle.TargetSoCPct, 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_pct", vehicle.TargetSoCPct) + }) + 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 { @@ -1562,7 +1676,20 @@ func main() { RequestActive: reqActive, }, true } - lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, reg.Send) + // An OCPP charge point is not in the driver registry — it connected to + // us rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the + // Lua driver registry. Loadpoints stay unaware of the difference. + send := reg.Send + if ocppSrv != nil { + send = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, send) // 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 @@ -2191,6 +2318,7 @@ func main() { LoadModel: loadSvc, Loadpoints: lpMgr, LoadpointCtrl: lpController, + OCPPChargers: ocppChargersFn, CalDAV: calSvc, HA: haBridge, Registry: reg, diff --git a/go/go.mod b/go/go.mod index 12433157..bca21706 100644 --- a/go/go.mod +++ b/go/go.mod @@ -12,6 +12,7 @@ require ( github.com/goburrow/serial v0.1.0 github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 + github.com/lorenzodonini/ocpp-go v0.19.0 github.com/mochi-mqtt/server/v2 v2.7.9 github.com/parquet-go/parquet-go v0.29.0 github.com/shirou/gopsutil/v4 v4.26.3 @@ -28,11 +29,15 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/ebitengine/purego v0.10.0 // indirect github.com/go-ole/go-ole v1.2.6 // indirect + github.com/go-playground/locales v0.12.1 // indirect + github.com/go-playground/universal-translator v0.16.0 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect github.com/go-webauthn/x v0.2.6 // indirect github.com/golang-jwt/jwt/v5 v5.3.1 // indirect github.com/google/go-tpm v0.9.8 // indirect + github.com/gorilla/mux v1.8.1 // indirect github.com/klauspost/compress v1.17.11 // indirect + github.com/leodido/go-urn v1.1.0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/ncruces/go-strftime v1.0.0 // indirect @@ -41,6 +46,7 @@ require ( github.com/philhofer/fwd v1.2.0 // indirect github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/relvacode/iso8601 v1.6.0 // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/rs/xid v1.4.0 // indirect github.com/teambition/rrule-go v1.8.2 // indirect @@ -53,7 +59,7 @@ require ( golang.org/x/sync v0.19.0 // indirect golang.org/x/sys v0.46.0 // indirect google.golang.org/protobuf v1.34.2 // indirect - gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect + gopkg.in/go-playground/validator.v9 v9.30.0 // indirect modernc.org/libc v1.70.0 // indirect modernc.org/mathutil v1.7.1 // indirect modernc.org/memory v1.11.0 // indirect diff --git a/go/go.sum b/go/go.sum index 96f0b704..91fe4e9a 100644 --- a/go/go.sum +++ b/go/go.sum @@ -1,11 +1,16 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/Shopify/toxiproxy v2.1.4+incompatible h1:TKdv8HiTLgE5wdJuEML90aBgNWsokNbMijUGhmcoBJc= +github.com/Shopify/toxiproxy v2.1.4+incompatible/go.mod h1:OXgGpZ6Cli1/URJOF1DMxUHB2q5Ap20/P/eIdh4G0pI= github.com/alecthomas/assert/v2 v2.10.0 h1:jjRCHsj6hBJhkmhznrCzoNpbA3zqy0fYiUcYZP/GkPY= github.com/alecthomas/assert/v2 v2.10.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/andybalholm/brotli v1.1.1 h1:PR2pgnyFznKEugtsUo0xLdDop5SKXd5Qf5ysW+7XdTA= github.com/andybalholm/brotli v1.1.1/go.mod h1:05ib4cKhjx3OQYUY22hTVd34Bc8upXjOLL2rKwwZBoA= +github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA= +github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -26,6 +31,10 @@ github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= +github.com/go-playground/locales v0.12.1 h1:2FITxuFt/xuCNP1Acdhv62OzaCiviiE4kotfhkmOqEc= +github.com/go-playground/locales v0.12.1/go.mod h1:IUMDtCfWo/w/mtMfIE/IG2K+Ey3ygWanZIBtBW0W2TM= +github.com/go-playground/universal-translator v0.16.0 h1:X++omBR/4cE2MNg91AoC3rmGrCjJ8eAeUP/K/EKx4DM= +github.com/go-playground/universal-translator v0.16.0/go.mod h1:1AnU7NaIRDWWzGEKwgtJRd2xk99HeFyHw3yid4rvQIY= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk= @@ -47,6 +56,8 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= @@ -57,10 +68,18 @@ github.com/jinzhu/copier v0.3.5 h1:GlvfUwHk62RokgqVNvYsku0TATCF7bAHVwEXoBh3iJg= github.com/jinzhu/copier v0.3.5/go.mod h1:DfbEm0FYsaqBcKcFuvmOZb218JkPGtvSHsKg8S8hyyg= github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc= github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0= +github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.1.0 h1:Sm1gr51B1kKyfD2BlRcLSiEkffoG96g6TPv6eRoEiB8= +github.com/leodido/go-urn v1.1.0/go.mod h1:+cyI34gQWZcE1eQU7NVgKkkzdXDQHr1dBMtdAPozLkw= +github.com/lorenzodonini/ocpp-go v0.19.0 h1:THNriVV3bUWkGzaIkDeytcgoeBRjoN9ezPHsddIDfgc= +github.com/lorenzodonini/ocpp-go v0.19.0/go.mod h1:2kcukDdhui4u730VfnYVWuwzDLgw+mBRGDir/QAyBhg= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= @@ -83,6 +102,8 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/relvacode/iso8601 v1.6.0 h1:eFXUhMJN3Gz8Rcq82f9DTMW0svjtAVuIEULglM7QHTU= +github.com/relvacode/iso8601 v1.6.0/go.mod h1:FlNp+jz+TXpyRqgmM7tnzHHzBnz776kmAH2h3sZCn0I= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= @@ -93,6 +114,14 @@ github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfx github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/simonvetter/modbus v1.6.4 h1:E03lBz/JftDza/+Ue+vxwkNZ/WW1xiqyFCUQ4NhqHn0= github.com/simonvetter/modbus v1.6.4/go.mod h1:hh90ZaTaPLcK2REj6/fpTbiV0J6S7GWmd8q+GVRObPw= +github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/teambition/rrule-go v1.8.2 h1:lIjpjvWTj9fFUZCmuoVDrKVOtdiyzbzc93qTmRVe/J8= @@ -123,8 +152,10 @@ golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20220804214406-8e32c043e418/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= @@ -136,6 +167,11 @@ google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWn gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/go-playground/assert.v1 v1.2.1 h1:xoYuJVE7KT85PYWrN730RguIQO0ePzVRfFMXadIrXTM= +gopkg.in/go-playground/assert.v1 v1.2.1/go.mod h1:9RXL0bg/zibRAgZUYszZSwO/z8Y/a8bDuhia5mkpMnE= +gopkg.in/go-playground/validator.v9 v9.30.0 h1:Wk0Z37oBmKj9/n+tPyBHZmeL19LaCoK3Qq48VwYENss= +gopkg.in/go-playground/validator.v9 v9.30.0/go.mod h1:+c9/zcJMFNgbLvly1L1V+PpxWdVbfP1avr/N00E2vyQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 18e5aaa3..a1db24b3 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -46,6 +46,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" @@ -154,6 +155,11 @@ 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 + // Optional: CalDAV calendar-constraints client (#498). Nil when the // feature is disabled; GET /api/caldav/status then reports disabled. CalDAV *calendar.Service @@ -399,6 +405,11 @@ func (s *Server) routes() { s.handle("GET /api/logs", Local, s.handleGlobalLogs) s.handle("GET /api/support/dump", Local, s.handleSupportDump) s.handle("GET /api/support/report", Local, s.handleSupportReport) + // Local, not Read: the list carries vehicle_id, which on 1.6 is the + // RFID idTag the card presented — the token that authorizes a charge at + // the charger. Its only caller is the settings page, which is Local + // already, so nothing is lost by not handing card numbers to a viewer. + s.handle("GET /api/ocpp/chargers", Local, s.handleOCPPChargers) s.handle("POST /api/drivers/{name}/control", Actuate, s.handleDriverControl) s.handle("DELETE /api/drivers/{name}/control", Actuate, s.handleDriverControlRelease) s.handle("POST /api/drivers/{name}/restart", Configure, s.handleDriverRestart) diff --git a/go/internal/api/api_ocpp.go b/go/internal/api/api_ocpp.go new file mode 100644 index 00000000..39373ec6 --- /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 00000000..7deb0ce9 --- /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 52bc2dda..b0233e6b 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 a090b59c..5f51c9ef 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -37,12 +37,64 @@ 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"` +} + +// OCPP configures the built-in OCPP Central System, which serves 1.6J and +// 2.0.1. Chargers connect to us, so there is no driver and no per-charger +// config entry — a charge point appears as a device the moment it sends its +// first BootNotification, keyed by the identity segment of the URL it dialled. +// +// Each version needs its own port: a charger picks its dialect during the +// WebSocket handshake, and the OCPP library keeps one message handler per +// listener, so a single port cannot serve both. +// +// Disabled by default, and enabling it requires credentials. The listener +// cannot be restricted to one interface: the OCPP library builds its own +// listen address from the port alone, so the socket is reachable on every +// interface the host has. Basic auth is the only thing standing in front of +// it, which is why an empty Username or Password is rejected rather than +// silently accepted. +type OCPP struct { + Enabled bool `yaml:"enabled" json:"enabled"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` + PortV201 int `yaml:"port_v201,omitempty" json:"port_v201,omitempty"` + Path string `yaml:"path,omitempty" json:"path,omitempty"` + Username string `yaml:"username,omitempty" json:"username,omitempty"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` +} + +// Validate rejects an enabled server that would accept anonymous charge +// points. A nil or disabled section is fine — OCPP is opt-in. +func (o *OCPP) Validate() error { + if o == nil || !o.Enabled { + return nil + } + if o.Username == "" || o.Password == "" { + return errors.New("ocpp: username and password are required when enabled, because the listener cannot be bound to a single interface") + } + if o.Port < 0 || o.Port > 65535 { + return fmt.Errorf("ocpp.port must be between 0 and 65535, got %d", o.Port) + } + if o.PortV201 < 0 || o.PortV201 > 65535 { + return fmt.Errorf("ocpp.port_v201 must be between 0 and 65535, got %d", o.PortV201) + } + // Each version needs its own listener, so they cannot share a port. + if o.PortV201 > 0 && o.PortV201 == o.Port { + return fmt.Errorf("ocpp.port_v201 must differ from ocpp.port, both are %d", o.Port) + } + if o.HeartbeatIntervalS < 0 { + return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) + } + return nil } // AppLink enables the outbound connection the FTW app reaches this box @@ -255,13 +307,22 @@ 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"` - 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"` + 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 @@ -272,6 +333,87 @@ 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"` + // TargetSoCPct > 0 sets a charge target for the session, 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. + 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.TargetSoCPct < 0 || v.TargetSoCPct > 100 { + return fmt.Errorf("vehicle %q: target_soc_pct must be within 0..100", 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 @@ -1137,6 +1279,13 @@ func (c Config) MaskSecrets() Config { cp.Password = "" out.HomeAssistant = &cp } + // The OCPP password is the only thing standing in front of a listener that + // is reachable on every interface, so it must never leave over the API. + if out.OCPP != nil { + cp := *out.OCPP + cp.Password = "" + out.OCPP = &cp + } if out.Price != nil { cp := *out.Price cp.APIKey = "" @@ -1209,6 +1358,12 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { if incoming.CalDAV != nil && existing.CalDAV != nil && incoming.CalDAV.Password == "" { incoming.CalDAV.Password = existing.CalDAV.Password } + // Masked out on the way to the UI, so an unchanged password comes back + // empty. Without this a save from the settings tab would blank it, and an + // enabled server would then fail validation on the next reload. + if incoming.OCPP != nil && existing.OCPP != nil && incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password } @@ -1633,6 +1788,12 @@ func (c *Config) Validate() error { if err := c.CalDAV.Validate(); err != nil { return err } + if err := c.OCPP.Validate(); err != nil { + return err + } + if err := c.validateVehicles(); err != nil { + return err + } if err := c.FleetPing.Validate(); err != nil { return err } diff --git a/go/internal/config/example_config_test.go b/go/internal/config/example_config_test.go new file mode 100644 index 00000000..c7d9e042 --- /dev/null +++ b/go/internal/config/example_config_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "os" + "testing" +) + +func TestExampleConfigParsesOCPP(t *testing.T) { + data, err := os.ReadFile("../../../config.example.yaml") + if err != nil { + t.Fatalf("read example: %v", err) + } + c, err := Parse(data, ".") + if err != nil { + t.Fatalf("parse example config: %v", err) + } + if c.OCPP == nil { + t.Fatal("example config has no ocpp section") + } + if c.OCPP.Enabled { + t.Error("example config must ship with ocpp disabled") + } + if c.OCPP.Port != 8887 { + t.Errorf("port: got %d want 8887", c.OCPP.Port) + } + if err := c.Validate(); err != nil { + t.Fatalf("example config must validate: %v", err) + } + t.Logf("ocpp parsed: enabled=%v port=%d path=%q heartbeat=%d", + c.OCPP.Enabled, c.OCPP.Port, c.OCPP.Path, c.OCPP.HeartbeatIntervalS) +} diff --git a/go/internal/config/ocpp_test.go b/go/internal/config/ocpp_test.go new file mode 100644 index 00000000..abe403e3 --- /dev/null +++ b/go/internal/config/ocpp_test.go @@ -0,0 +1,144 @@ +package config + +import "testing" + +// The OCPP listener cannot be pinned to one interface — the library builds its +// listen address from the port alone. Basic auth is therefore the only thing +// between an enabled server and any host that can route to it, so an enabled +// section without credentials has to fail rather than start. +func TestOCPPValidate(t *testing.T) { + tests := []struct { + name string + ocpp *OCPP + wantErr bool + }{ + { + name: "absent section is fine", + ocpp: nil, + }, + { + name: "disabled without credentials is fine", + ocpp: &OCPP{Enabled: false}, + }, + { + name: "enabled without credentials is rejected", + ocpp: &OCPP{Enabled: true}, + wantErr: true, + }, + { + name: "enabled with only a username is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw"}, + wantErr: true, + }, + { + name: "enabled with only a password is rejected", + ocpp: &OCPP{Enabled: true, Password: "secret"}, + wantErr: true, + }, + { + name: "enabled with both is accepted", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret"}, + }, + { + name: "port out of range is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", Port: 70000}, + wantErr: true, + }, + { + name: "negative heartbeat is rejected", + ocpp: &OCPP{Enabled: true, Username: "ftw", Password: "secret", HeartbeatIntervalS: -1}, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.ocpp.Validate() + if tt.wantErr && err == nil { + t.Fatal("expected an error, got nil") + } + if !tt.wantErr && err != nil { + t.Fatalf("expected no error, got %v", err) + } + }) + } +} + +// The OCPP password is the only gate in front of a listener reachable on every +// interface. It must never be served over /api/config, and a settings save that +// returns the masked (empty) value must not wipe it. +func TestOCPPPasswordIsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "the-real-secret", + }} + + masked := stored.MaskSecrets() + if masked.OCPP == nil { + t.Fatal("masked config lost the ocpp section") + } + if masked.OCPP.Password != "" { + t.Errorf("password leaked through MaskSecrets: %q", masked.OCPP.Password) + } + if masked.OCPP.Username != "ftw" { + t.Errorf("username should survive masking, got %q", masked.OCPP.Username) + } + // Masking must not mutate the original. + if stored.OCPP.Password != "the-real-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Password) + } + + // The UI round-trips the masked config back on save. + incoming := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "", + }} + incoming.PreserveMaskedSecrets(stored) + if incoming.OCPP.Password != "the-real-secret" { + t.Errorf("password not preserved on save, got %q", incoming.OCPP.Password) + } + + // A genuinely new password must still win. + changed := &Config{OCPP: &OCPP{ + Enabled: true, + Username: "ftw", + Password: "a-new-secret", + }} + changed.PreserveMaskedSecrets(stored) + if changed.OCPP.Password != "a-new-secret" { + t.Errorf("new password was overwritten, got %q", changed.OCPP.Password) + } +} + +// A disabled or absent OCPP section must not stop the rest of the config from +// validating, and an enabled one without credentials must take the whole +// config down with it rather than being skipped. +func TestConfigValidateCoversOCPP(t *testing.T) { + // SmoothingAlpha and MaxAmps are normally filled in by defaults, which this + // test does not run; set them so the only thing under test is OCPP. + base := func() *Config { + return &Config{ + Site: Site{SmoothingAlpha: 0.3}, + Fuse: Fuse{MaxAmps: 20, Phases: 3, Voltage: 230}, + } + } + + c := base() + if err := c.Validate(); err != nil { + t.Fatalf("config without an ocpp section should validate, got %v", err) + } + + c = base() + c.OCPP = &OCPP{Enabled: true} + if err := c.Validate(); err == nil { + t.Fatal("config with an enabled, credential-less ocpp section should fail") + } + + c = base() + c.OCPP = &OCPP{Enabled: true, Username: "ftw", Password: "secret"} + if err := c.Validate(); err != nil { + t.Fatalf("config with a credentialed ocpp section should validate, got %v", err) + } +} diff --git a/go/internal/config/vehicles_test.go b/go/internal/config/vehicles_test.go new file mode 100644 index 00000000..61f9a22a --- /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, TargetSoCPct: 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", TargetSoCPct: 150}}}).validateVehicles(); err == nil { + t.Fatal("target_soc_pct > 100 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 472fe0ad..5f2f17d7 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -140,6 +140,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. @@ -239,6 +242,14 @@ type loadpointRuntime struct { // every plug-in transition (prev !pluggedIn → now pluggedIn). sessionPluginSoCPct 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 + // 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. @@ -318,6 +329,16 @@ func (m *Manager) Load(cfgs []Config) { lp.notRequestingSince = existing.notRequestingSince lp.sessionComplete = existing.sessionComplete lp.socSource = existing.socSource + lp.vehicleName = existing.vehicleName + if existing.vehicleName != "" { + // 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) @@ -436,6 +457,13 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.notRequestingSince = time.Time{} lp.sessionComplete = false lp.socSource = "" + if lp.vehicleName != "" { + // 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.baseCapacityWh = 0 + } } lp.pluggedIn = pluggedIn lp.currentPowerW = powerW @@ -614,6 +642,31 @@ func (m *Manager) HydrateSurplusOnly(load func(id string) (bool, bool)) { // from there. Only applies while plugged in; no-op otherwise. // // Returns false for unknown IDs or when the loadpoint is unplugged. +// 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.baseCapacityWh = lp.VehicleCapacityWh + } + lp.vehicleName = vehicleName + if capacityWh > 0 { + lp.VehicleCapacityWh = capacityWh + } + lp.updatedAtMs = m.now().UnixMilli() + return true +} + func (m *Manager) SetCurrentSoC(id string, socPct float64) bool { m.mu.Lock() defer m.mu.Unlock() @@ -711,6 +764,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/vehicle_profile_test.go b/go/internal/loadpoint/vehicle_profile_test.go new file mode 100644 index 00000000..e53e3c25 --- /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, PluginSoCPct: 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.CurrentSoCPct < 79 || st.CurrentSoCPct > 81 { + t.Fatalf("SoC should follow the applied 40 kWh capacity, got %v", st.CurrentSoCPct) + } + + // Hot-reload mid-session: the identified car survives. + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoCPct: 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/capabilities.go b/go/internal/ocpp/capabilities.go new file mode 100644 index 00000000..2a3d641b --- /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 00000000..0f2471a2 --- /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/config.go b/go/internal/ocpp/config.go new file mode 100644 index 00000000..6c914d33 --- /dev/null +++ b/go/internal/ocpp/config.go @@ -0,0 +1,47 @@ +package ocpp + +// Config controls the OCPP 1.6J Central System. +// +// Bind to LAN-only addresses by default — there is no TLS in Phase 1. +// Charge points connect via ws://:/. 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. +// +// NOTE: Bind is advisory-only — the ocpp-go library does not expose a +// bind-address parameter, so the listener currently binds to 0.0.0.0 +// regardless of this field. See the TODO in server.go. +type Config struct { + Enabled bool `yaml:"enabled"` + Bind string `yaml:"bind"` + Port int `yaml:"port"` + PortV201 int `yaml:"port_v201"` + Path string `yaml:"path"` + Username string `yaml:"username"` + Password string `yaml:"password"` + HeartbeatIntervalS int `yaml:"heartbeat_interval_s"` + + // 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:"-"` +} + +// Defaults fills in any unset fields with safe values. +func (c *Config) Defaults() { + if c.Bind == "" { + c.Bind = "0.0.0.0" + } + if c.Port == 0 { + c.Port = 8887 + } + if c.Path == "" { + c.Path = "/" + } + if c.HeartbeatIntervalS == 0 { + c.HeartbeatIntervalS = 60 + } +} diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go new file mode 100644 index 00000000..1e04937a --- /dev/null +++ b/go/internal/ocpp/control.go @@ -0,0 +1,372 @@ +package ocpp + +// Control for OCPP chargers. +// +// Command has the same shape as drivers.Registry.Send, so the loadpoint +// controller dispatches to an OCPP charger without knowing it is not a Lua +// driver. The command vocabulary is the one every EV driver already implements +// — ev_set_current, ev_pause, ev_start, ev_resume — so nothing upstream of here +// needs a special case. +// +// Everything is expressed as a current limit rather than a remote start/stop. +// That is deliberate: RemoteStopTransaction is unreliable in the field on +// Charge Amps hardware, where units acknowledge the stop and return to charging +// on their own. A charging profile of 0 A is honoured consistently, so pausing +// is "allow zero amps" rather than "end the transaction", and resuming raises +// the limit again. It also leaves the transaction open, so the session meter +// keeps accumulating across a pause instead of being split in two. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" +) + +const ( + // IEC 61851 does not allow a duty cycle below 6 A. Charging is either off + // or at least this much; anything between is refused by the vehicle or + // leaves it drawing an unpredictable current. + minChargeAmps = 6.0 + + // Fallback electrical assumptions, used only when the command omits them. + // A command from the loadpoint controller carries the real site values. + defaultVoltage = 230.0 + defaultPhases = 3 + + // Ceiling applied when a command asks to resume without saying how fast + // and we have never set a limit for this charger. + defaultMaxAmps = 16.0 + + // How long to wait for a charger to confirm a profile before giving up. + // Control runs on a tick; a command that has not landed by now is better + // reported as failed than left blocking the loop. + commandTimeout = 10 * time.Second + + // Charge-point-wide default profile. Connector 0 means "every connector", + // which avoids depending on per-connector ids — those are unreliable on + // dual-socket units such as the Charge Amps Aura. + allConnectors = 0 + + // The 2.0.1 equivalent: EVSE 0 addresses the whole charging station. + allEVSEs = 0 + + // A single, stable profile id and stack level means each new limit + // replaces the previous one instead of stacking on top of it. + ftwProfileID = 1 + // 2.0.1 schedules carry their own id; one stable id keeps replacement + // semantics identical to 1.6. + ftwScheduleID = 1 + ftwStackLevel = 0 + scheduleStartS = 0 +) + +// command is the JSON payload the loadpoint controller sends to EV drivers. +// Only the fields that affect a current limit are read here. +type command struct { + Action string `json:"action"` + PowerW float64 `json:"power_w"` + Voltage float64 `json:"voltage"` + SitePhases int `json:"site_phases"` + MaxAmpsPerPhase float64 `json:"max_amps_per_phase"` + PhaseMode string `json:"phase_mode"` +} + +// ErrNotConnected is returned when a command targets a charge point that is not +// currently connected. Callers use it to tell "wrong name" apart from "charger +// is offline right now". +var ErrNotConnected = errors.New("ocpp: charger not connected") + +// Command applies an EV control command to a connected charge point. The +// signature matches drivers.Registry.Send so it can back a loadpoint +// SenderFunc directly. +func (s *Server) Command(ctx context.Context, id string, payload []byte) error { + if s == nil || s.cs == nil { + return errors.New("ocpp: server not running") + } + if !s.handler.IsOnline(id) { + return fmt.Errorf("%w: %s", ErrNotConnected, id) + } + + var c command + if err := json.Unmarshal(payload, &c); err != nil { + return fmt.Errorf("ocpp: bad command payload for %s: %w", id, err) + } + + switch c.Action { + case "init", "deinit": + // Lifecycle hooks that only mean something to a Lua VM. + return nil + + case "ev_set_current": + return s.setLimit(ctx, id, c.amps(), c.numberPhases()) + + case "ev_pause": + // Phase count is meaningless at zero amps. + return s.setLimit(ctx, id, 0, nil) + + case "ev_start", "ev_resume": + // A resume without a rate means "as fast as previously allowed". + amps := c.amps() + if amps <= 0 { + amps = s.handler.LastAmps(id, c.ceiling()) + } + return s.setLimit(ctx, id, amps, c.numberPhases()) + + default: + return fmt.Errorf("ocpp: unknown action %q for %s", c.Action, id) + } +} + +// ceiling is the highest per-phase current this command permits. +func (c command) ceiling() float64 { + if c.MaxAmpsPerPhase > 0 { + return c.MaxAmpsPerPhase + } + return defaultMaxAmps +} + +// amps converts the requested site power into a per-phase current limit. +// +// Below the IEC minimum the result is zero rather than the minimum: when the +// allocator has less than 6 A of headroom to give, rounding up would draw +// current the site fuse was not asked to carry. Refusing to charge is the safe +// direction of error. +func (c command) amps() float64 { + if c.PowerW <= 0 { + return 0 + } + + voltage := c.Voltage + if voltage <= 0 { + voltage = defaultVoltage + } + phases := c.SitePhases + if phases <= 0 { + phases = defaultPhases + } + if c.PhaseMode == "1p" { + phases = 1 + } + + amps := c.PowerW / (voltage * float64(phases)) + if ceiling := c.ceiling(); amps > ceiling { + amps = ceiling + } + if amps < minChargeAmps { + return 0 + } + return amps +} + +// numberPhases is what to declare in the schedule period, or nil to let the +// charger decide. Only a pinned single-phase command is worth stating. +func (c command) numberPhases() *int { + if c.PhaseMode == "1p" { + n := 1 + return &n + } + return nil +} + +// setLimit installs a charge-point-wide default profile capping the per-phase +// current, and blocks until the charger confirms it, the context ends, or the +// command times out. +func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPhases *int) error { + if amps < 0 { + amps = 0 + } + + // Buffered: the library's callback must never block if we have already + // stopped waiting. + done := make(chan profileResult, 1) + + // The two versions describe the same intent with different types, so the + // request is built per dialect and the outcome normalised back. + var err error + switch version, _ := s.handler.Version(id); version { + case Version201: + err = s.sendProfileV201(id, amps, numberPhases, done) + default: + err = s.sendProfileV16(id, amps, numberPhases, done) + } + if err != nil { + return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + } + + timeout := time.NewTimer(commandTimeout) + defer timeout.Stop() + + select { + case r := <-done: + if r.err != nil { + return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) + } + if !r.answered { + return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) + } + if !r.accepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.status) + } + // Only a real charging rate is worth remembering. Recording the zero + // from a pause would erase the rate a later resume is supposed to + // restore, and the charger would come back at the fallback ceiling + // instead of where it left off. + if amps > 0 { + s.handler.SetLastAmps(id, amps) + } + slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) + return nil + + case <-ctx.Done(): + return fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) + + case <-timeout.C: + return fmt.Errorf("ocpp: %s did not confirm charging profile within %s", id, commandTimeout) + } +} + +// profileResult is a version-neutral answer to a charging profile request, so +// the waiting code above does not need to know which dialect produced it. +type profileResult struct { + answered bool + accepted bool + status string + err error +} + +// sendProfileV16 issues the limit as an OCPP 1.6 TxDefaultProfile. +func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + period := types.NewChargingSchedulePeriod(scheduleStartS, amps) + // Declared only when the loadpoint pinned single-phase charging. Left + // unset otherwise so a charger that can switch phases keeps deciding. + period.NumberPhases = numberPhases + schedule := types.NewChargingSchedule(types.ChargingRateUnitAmperes, period) + profile := types.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types.ChargingProfilePurposeTxDefaultProfile, + types.ChargingProfileKindAbsolute, + schedule, + ) + + return s.cs.SetChargingProfile(id, func(conf *smartcharging.SetChargingProfileConfirmation, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging.ChargingProfileStatusAccepted + } + done <- r + }, allConnectors, profile) +} + +// sendProfileV201 issues the same limit as an OCPP 2.0.1 TxDefaultProfile. +// +// 2.0.1 carries a list of schedules rather than one, and each schedule needs +// its own id; a single-entry list with a stable id keeps the meaning identical +// to the 1.6 request. +func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, done chan<- profileResult) error { + if s.csms == nil { + return fmt.Errorf("ocpp: %s speaks %s but no %s listener is configured", id, Version201, Version201) + } + + period := types201.NewChargingSchedulePeriod(scheduleStartS, amps) + period.NumberPhases = numberPhases + schedule := types201.NewChargingSchedule(ftwScheduleID, types201.ChargingRateUnitAmperes, period) + profile := types201.NewChargingProfile( + ftwProfileID, + ftwStackLevel, + types201.ChargingProfilePurposeTxDefaultProfile, + types201.ChargingProfileKindAbsolute, + []types201.ChargingSchedule{*schedule}, + ) + + return s.csms.SetChargingProfile(id, func(conf *smartcharging201.SetChargingProfileResponse, err error) { + r := profileResult{err: err} + if conf != nil { + r.answered = true + r.status = string(conf.Status) + r.accepted = conf.Status == smartcharging201.ChargingProfileStatusAccepted + } + done <- r + }, allEVSEs, profile) +} + +// DefaultMode is what a charger is left in when FTW stops steering it, and +// mirrors the stance every EV driver already takes: hold the last limit. +// +// An EV charger has no autonomous self-consumption mode to fall back to, and +// dropping to zero would strand a driver with an uncharged car because the EMS +// lost contact. Holding the last granted current keeps the site within the +// envelope that was already judged safe. +func (s *Server) DefaultMode(_ context.Context, id string) error { + slog.Info("ocpp: leaving charger at its last granted limit", "charger", id) + return nil +} + +// IsOnline reports whether a charge point currently holds a WebSocket session. +// +// This is deliberately not the same as having a vehicle plugged in. A default +// charging profile is exactly the thing you set on an idle charger, so control +// gates on the session being live, not on the connector being occupied. +func (h *Handler) IsOnline(id string) bool { + if h == nil { + return false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + return ok && s.online +} + +// LastAmps returns the last limit granted to a charger, or fallback if none +// has been set yet. +func (h *Handler) LastAmps(id string, fallback float64) float64 { + if h == nil { + return fallback + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.lastAmps <= 0 { + return fallback + } + return s.lastAmps +} + +// SetLastAmps records an accepted limit so a later resume can restore it. +func (h *Handler) SetLastAmps(id string, amps float64) { + if h == nil { + return + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok { + return + } + s.lastAmps = amps +} + +// Names returns the charge points seen since start, connected or not. main.go +// uses it to decide whether a loadpoint driver name belongs to OCPP. +func (h *Handler) Names() []string { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]string, 0, len(h.chargers)) + for id := range h.chargers { + out = append(out, id) + } + return out +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go new file mode 100644 index 00000000..ee69cfed --- /dev/null +++ b/go/internal/ocpp/control_test.go @@ -0,0 +1,403 @@ +package ocpp + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sync" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeCharger is a charge point that records the charging profiles it is sent +// and answers with a configurable status. +type fakeCharger struct { + mu sync.Mutex + status smartcharging.ChargingProfileStatus + profiles []*smartcharging.SetChargingProfileRequest +} + +func newFakeCharger() *fakeCharger { + return &fakeCharger{status: smartcharging.ChargingProfileStatusAccepted} +} + +func (f *fakeCharger) OnSetChargingProfile(req *smartcharging.SetChargingProfileRequest) (*smartcharging.SetChargingProfileConfirmation, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging.NewSetChargingProfileConfirmation(status), nil +} + +func (f *fakeCharger) OnClearChargingProfile(*smartcharging.ClearChargingProfileRequest) (*smartcharging.ClearChargingProfileConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) OnGetCompositeSchedule(*smartcharging.GetCompositeScheduleRequest) (*smartcharging.GetCompositeScheduleConfirmation, error) { + return nil, errors.New("not used") +} + +func (f *fakeCharger) setStatus(s smartcharging.ChargingProfileStatus) { + f.mu.Lock() + defer f.mu.Unlock() + f.status = s +} + +// lastLimit returns the amp limit from the most recent profile received. +func (f *fakeCharger) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil || p.ChargingProfile.ChargingSchedule == nil { + t.Fatal("charging profile had no schedule") + } + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// lastNumberPhases returns the declared phase count from the most recent +// profile, or nil when the charger was left to decide. +func (f *fakeCharger) lastNumberPhases(t *testing.T) *int { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + periods := p.ChargingProfile.ChargingSchedule.ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].NumberPhases +} + +func (f *fakeCharger) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.profiles) +} + +// connectCharger brings up a charge point against the server and waits until +// the server has registered the session. +// +// The returned stop is idempotent: ocpp-go panics on a second Stop, and the +// cleanup below would otherwise fire after a test that disconnects on purpose. +func connectCharger(t *testing.T, srv *Server, port int, id string) (ocpp16.ChargePoint, *fakeCharger, func()) { + t.Helper() + fake := newFakeCharger() + cp := ocpp16.NewChargePoint(id, nil, nil) + cp.SetSmartChargingHandler(fake) + + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charge point connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cp.Stop) } + t.Cleanup(stop) + + if _, err := cp.BootNotification("Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return cp, fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered charger %s as online", id) + return nil, nil, nil +} + +func mustPayload(t *testing.T, m map[string]any) []byte { + t.Helper() + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + return b +} + +// The whole point of Phase 2: a pause has to arrive as a 0 A limit, not as a +// RemoteStopTransaction, because Charge Amps units resume on their own after a +// remote stop. +func TestPauseSendsZeroAmpLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +func TestSetCurrentConvertsPowerToAmps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Below the IEC 61851 minimum the charger must be told zero, never a rounded-up +// 6 A the site fuse was not asked to carry. +func TestBelowMinimumBecomesZero(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 2000 W over 3 phases at 230 V ≈ 2.9 A per phase — under the minimum. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 2000.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("sub-minimum limit: got %v A, want 0 A", got) + } +} + +func TestMaxAmpsPerPhaseIsRespected(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 22 kW would be 32 A per phase, but the loadpoint allows only 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 22080.0, + "voltage": 230.0, + "site_phases": 3, + "max_amps_per_phase": 16.0, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("clamped limit: got %v A, want 16 A", got) + } +} + +// A pause must not lose the previous rate: resuming without one restores it. +func TestResumeRestoresLastLimit(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + ctx := context.Background() + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Fatalf("pause limit: got %v A, want 0 A", got) + } + + if err := srv.Command(ctx, "garage-left", mustPayload(t, map[string]any{ + "action": "ev_resume", + })); err != nil { + t.Fatalf("resume: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("resumed limit: got %v A, want the previous 10 A", got) + } +} + +func TestRejectedProfileIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the charger rejects the profile") + } +} + +func TestUnknownChargerIsNotConnected(t *testing.T) { + _, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + + err := srv.Command(context.Background(), "nobody-home", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected, got %v", err) + } +} + +// A charger that drops off must not silently swallow commands — control needs +// the error so it can fall back. +func TestDisconnectedChargerRejectsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, _, stop := connectCharger(t, srv, port, "garage-left") + + stop() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) && srv.Handler().IsOnline("garage-left") { + time.Sleep(20 * time.Millisecond) + } + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if !errors.Is(err, ErrNotConnected) { + t.Fatalf("expected ErrNotConnected after disconnect, got %v", err) + } +} + +// init/deinit are Lua lifecycle hooks. They must be accepted and do nothing +// rather than reaching the charger or erroring. +func TestLifecycleActionsAreNoOps(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + for _, action := range []string{"init", "deinit"} { + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": action, + })); err != nil { + t.Errorf("%s: unexpected error %v", action, err) + } + } + if n := fake.count(); n != 0 { + t.Errorf("lifecycle actions sent %d profiles, want 0", n) + } +} + +func TestUnknownActionIsAnError(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "self_destruct", + })) + if err == nil { + t.Fatal("expected an error for an unknown action") + } + if n := fake.count(); n != 0 { + t.Errorf("unknown action sent %d profiles, want 0", n) + } +} + +// Single-phase mode changes the conversion: the same watts land on one phase. +func TestSinglePhaseModeUsesOnePhase(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + // 3680 W on one phase at 230 V = 16 A. + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 3680.0, + "voltage": 230.0, + "site_phases": 3, + "phase_mode": "1p", + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("1p limit: got %v A, want 16 A", got) + } + if got := fake.lastNumberPhases(t); got == nil || *got != 1 { + t.Errorf("1p numberPhases: got %v, want 1", got) + } +} + +// With no phase mode pinned, the phase count is left unset so a charger that +// can switch phases keeps deciding for itself. +func TestThreePhaseLeavesPhaseCountUnset(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + _, fake, _ := connectCharger(t, srv, port, "garage-left") + + err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastNumberPhases(t); got != nil { + t.Errorf("numberPhases: got %v, want unset", *got) + } +} + +// The status handler marks a charger connected; a suspended charger is still +// commandable, which is what lets a paused session be resumed. +func TestSuspendedChargerStillAcceptsCommands(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore()) + defer srv.Stop() + cp, fake, _ := connectCharger(t, srv, port, "garage-left") + + if _, err := cp.StatusNotification(1, core.NoError, core.ChargePointStatusSuspendedEV); err != nil { + t.Fatalf("status: %v", err) + } + + if err := srv.Command(context.Background(), "garage-left", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current on suspended charger: %v", err) + } + if got := fake.lastLimit(t); got != 10 { + t.Errorf("limit: got %v A, want 10 A", got) + } +} diff --git a/go/internal/ocpp/control_v201_test.go b/go/internal/ocpp/control_v201_test.go new file mode 100644 index 00000000..1b0887b9 --- /dev/null +++ b/go/internal/ocpp/control_v201_test.go @@ -0,0 +1,269 @@ +package ocpp + +import ( + "context" + "errors" + "fmt" + "net" + "sync" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// fakeStationV201 is a 2.0.1 charging station that records the charging +// profiles it is sent. +type fakeStationV201 struct { + mu sync.Mutex + status smartcharging201.ChargingProfileStatus + profiles []*smartcharging201.SetChargingProfileRequest +} + +func newFakeStationV201() *fakeStationV201 { + return &fakeStationV201{status: smartcharging201.ChargingProfileStatusAccepted} +} + +func (f *fakeStationV201) OnSetChargingProfile(req *smartcharging201.SetChargingProfileRequest) (*smartcharging201.SetChargingProfileResponse, error) { + f.mu.Lock() + f.profiles = append(f.profiles, req) + status := f.status + f.mu.Unlock() + return smartcharging201.NewSetChargingProfileResponse(status), nil +} + +func (f *fakeStationV201) OnClearChargingProfile(*smartcharging201.ClearChargingProfileRequest) (*smartcharging201.ClearChargingProfileResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetChargingProfiles(*smartcharging201.GetChargingProfilesRequest) (*smartcharging201.GetChargingProfilesResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) OnGetCompositeSchedule(*smartcharging201.GetCompositeScheduleRequest) (*smartcharging201.GetCompositeScheduleResponse, error) { + return nil, errors.New("not used") +} + +func (f *fakeStationV201) lastLimit(t *testing.T) float64 { + t.Helper() + f.mu.Lock() + defer f.mu.Unlock() + if len(f.profiles) == 0 { + t.Fatal("station received no charging profile") + } + p := f.profiles[len(f.profiles)-1] + if p.ChargingProfile == nil { + t.Fatal("request carried no charging profile") + } + if len(p.ChargingProfile.ChargingSchedule) == 0 { + t.Fatal("charging profile carried no schedule") + } + periods := p.ChargingProfile.ChargingSchedule[0].ChargingSchedulePeriod + if len(periods) == 0 { + t.Fatal("charging schedule had no periods") + } + return periods[0].Limit +} + +// startDualServer brings up both listeners on free ports. +func startDualServer(t *testing.T, tel *telemetry.Store) (portV16, portV201 int, srv *Server) { + t.Helper() + portV16 = freePort(t) + portV201 = freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: portV16, + PortV201: portV201, + HeartbeatIntervalS: 60, + } + srv, err := Start(context.Background(), cfg, tel) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + + // Both listeners must be reachable before a client tries to connect. + for _, port := range []int{portV16, portV201} { + deadline := time.Now().Add(2 * time.Second) + bound := false + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + bound = true + break + } + time.Sleep(20 * time.Millisecond) + } + if !bound { + t.Fatalf("listener never bound on port %d", port) + } + } + return portV16, portV201, srv +} + +// connectStationV201 brings up a 2.0.1 charging station and waits for the +// server to register it. +func connectStationV201(t *testing.T, srv *Server, port int, id string) (*fakeStationV201, func()) { + t.Helper() + fake := newFakeStationV201() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(fake) + + if err := cs.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charging station connect: %v", err) + } + var once sync.Once + stop := func() { once.Do(cs.Stop) } + t.Cleanup(stop) + + station := provisioning.ChargingStationType{Model: "Dawn", VendorName: "Charge Amps"} + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, station.Model, station.VendorName); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return fake, stop + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil, nil +} + +// A 2.0.1 station must be steerable exactly like a 1.6 one, and the server has +// to answer it in its own dialect. +func TestV201ChargerAcceptsCurrentLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, ok := srv.Handler().Version("garage-v201"); !ok || v != Version201 { + t.Fatalf("version: got %q ok=%v, want %q", v, ok, Version201) + } + + // 11040 W over 3 phases at 230 V = 16 A per phase. + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })) + if err != nil { + t.Fatalf("set current: %v", err) + } + if got := fake.lastLimit(t); got != 16 { + t.Errorf("limit: got %v A, want 16 A", got) + } +} + +// Pause is a 0 A limit on 2.0.1 too, not a remote stop. +func TestV201PauseSendsZeroAmpLimit(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })); err != nil { + t.Fatalf("pause: %v", err) + } + if got := fake.lastLimit(t); got != 0 { + t.Errorf("pause limit: got %v A, want 0 A", got) + } +} + +// Both dialects served at once, each charger steered in its own, sharing one +// charger map and one telemetry store. +func TestBothVersionsServedSimultaneously(t *testing.T) { + portV16, portV201, srv := startDualServer(t, telemetry.NewStore()) + + _, fake16, _ := connectCharger(t, srv, portV16, "garage-v16") + fake201, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if v, _ := srv.Handler().Version("garage-v16"); v != Version16 { + t.Errorf("v16 charger version: got %q, want %q", v, Version16) + } + if v, _ := srv.Handler().Version("garage-v201"); v != Version201 { + t.Errorf("v201 charger version: got %q, want %q", v, Version201) + } + + ctx := context.Background() + payload := mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 6900.0, // 10 A over 3 phases at 230 V + "voltage": 230.0, + "site_phases": 3, + }) + if err := srv.Command(ctx, "garage-v16", payload); err != nil { + t.Fatalf("v16 set current: %v", err) + } + if err := srv.Command(ctx, "garage-v201", payload); err != nil { + t.Fatalf("v201 set current: %v", err) + } + + if got := fake16.lastLimit(t); got != 10 { + t.Errorf("v16 limit: got %v A, want 10 A", got) + } + if got := fake201.lastLimit(t); got != 10 { + t.Errorf("v201 limit: got %v A, want 10 A", got) + } + + // The two must not have been confused for one another. + snap := srv.Handler().Snapshot() + if len(snap) != 2 { + t.Errorf("expected 2 chargers in the snapshot, got %d: %+v", len(snap), snap) + } +} + +// A rejection from a 2.0.1 station has to surface as an error, same as 1.6. +func TestV201RejectedProfileIsAnError(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + fake.mu.Lock() + fake.status = smartcharging201.ChargingProfileStatusRejected + fake.mu.Unlock() + + err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_pause", + })) + if err == nil { + t.Fatal("expected an error when the station rejects the profile") + } +} + +// Guard the version-neutral core: a 2.0.1 profile must be a single-entry +// schedule list with amps as the rate unit, matching the 1.6 request's meaning. +func TestV201ProfileShape(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + fake, _ := connectStationV201(t, srv, portV201, "garage-v201") + + if err := srv.Command(context.Background(), "garage-v201", mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + })); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + p := fake.profiles[len(fake.profiles)-1].ChargingProfile + if len(p.ChargingSchedule) != 1 { + t.Fatalf("schedules: got %d, want exactly 1", len(p.ChargingSchedule)) + } + if unit := p.ChargingSchedule[0].ChargingRateUnit; unit != types201.ChargingRateUnitAmperes { + t.Errorf("rate unit: got %q, want %q", unit, types201.ChargingRateUnitAmperes) + } + if p.ChargingProfilePurpose != types201.ChargingProfilePurposeTxDefaultProfile { + t.Errorf("purpose: got %q, want TxDefaultProfile", p.ChargingProfilePurpose) + } +} diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go new file mode 100644 index 00000000..d615a43b --- /dev/null +++ b/go/internal/ocpp/handlers.go @@ -0,0 +1,545 @@ +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) + + // 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. + vendor string + model 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 +} + +// 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, + Pending: !h.approved[id], + VehicleID: s.vehicleID, + VehicleIDSource: s.vehicleIDSource, + FeatureProfiles: s.featureProfiles, + } + if s.steerable != nil { + b := *s.steerable + v.Steerable = &b + } + 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"` + // 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"` +} + +// 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) { + slog.Info("OCPP boot", + "charger", id, + "vendor", req.ChargePointVendor, + "model", req.ChargePointModel, + "fw", req.FirmwareVersion) + s := h.state(id) + h.mu.Lock() + s.vendor = req.ChargePointVendor + s.model = req.ChargePointModel + h.mu.Unlock() + 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 00000000..bea9b280 --- /dev/null +++ b/go/internal/ocpp/handlers_v201.go @@ -0,0 +1,263 @@ +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 + h.mu.Unlock() + h.setVersion(id, Version201) + 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/server.go b/go/internal/ocpp/server.go new file mode 100644 index 00000000..b369ceb3 --- /dev/null +++ b/go/internal/ocpp/server.go @@ -0,0 +1,227 @@ +// 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" + "sync" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Server is a running OCPP Central System, serving one listener per enabled +// protocol version. +// +// Each version needs its own port. The OCPP library's ws.Server keeps a single +// message handler, so one listener cannot dispatch both dialects, and a charger +// picks its dialect in the WebSocket handshake before any message is sent. +type Server struct { + cfg *Config + cs ocpp16.CentralSystem + csms ocpp201.CSMS + handler *Handler + // done closes when the 1.6 listener goroutine exits; doneV201 likewise for + // 2.0.1. A nil channel means that version was not enabled. + done chan struct{} + doneV201 chan struct{} + stopOnce sync.Once +} + +// Start brings up the OCPP CS on the configured bind:port. Returns +// immediately once the listener is up; the WebSocket loop runs in its own +// goroutine until ctx is cancelled or Stop() is called. +// +// The returned Server is the handle for shutdown — main.go is expected to +// call Stop() during graceful drain. +func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, error) { + if cfg == nil { + return nil, errors.New("ocpp: nil config") + } + if tel == nil { + return nil, errors.New("ocpp: nil telemetry store") + } + cfg.Defaults() + + wsServer := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + + cs := ocpp16.NewCentralSystem(nil, wsServer) + h := NewHandler(tel, cfg.HeartbeatIntervalS) + 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 := ws.NewServer() + if cfg.Username != "" || cfg.Password != "" { + u, p := cfg.Username, cfg.Password + wsServer201.SetBasicAuthHandler(func(user, pass string) bool { + return user == u && pass == p + }) + } + h201 := &handlerV201{Handler: h} + csms := ocpp201.NewCSMS(nil, wsServer201) + csms.SetProvisioningHandler(h201) + csms.SetAvailabilityHandler(h201) + csms.SetTransactionsHandler(h201) + csms.SetMeterHandler(h201) + csms.SetAuthorizationHandler(h201) + csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { + h.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, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", cfg.Username != "") + 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", + "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, + "basic_auth", cfg.Username != "") + // TODO: cfg.Bind is not honored here. The ocpp-go library's + // CentralSystem.Start(port, path) and ws.Server.Start(port, path) + // only accept a port — there is no SetAddr or bind-address parameter. + // To support bind-address natively we would need to either: + // (a) upstream a PR to ocpp-go adding a SetListenAddr method, or + // (b) create our own net.Listener bound to cfg.Bind:cfg.Port and + // serve the ws.Server's http.Handler on it. + // For now cfg.Bind is advisory-only (documented in Config). + // cs.Start blocks until cs.Stop is called. + s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) + }() + go func() { + <-ctx.Done() + s.Stop() + }() + return s, nil +} + +// Stop closes the WebSocket server and waits for the listener goroutine to exit. +// A 5-second timeout prevents deadlock if the listener goroutine is stuck. +func (s *Server) Stop() { + if s == nil || s.cs == nil { + return + } + s.stopOnce.Do(func() { + s.cs.Stop() + if s.csms != nil { + s.csms.Stop() + } + }) + select { + case <-s.done: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version16) + } + if s.doneV201 != nil { + select { + case <-s.doneV201: + case <-time.After(5 * time.Second): + slog.Warn("ocpp: shutdown timeout — forcing close", "version", Version201) + } + } +} + +// Handler exposes per-charger state for tests + introspection. +func (s *Server) Handler() *Handler { return s.handler } + +// Port is the port the listener actually took, after defaults were applied. +// Callers configuring an unset port need this to log or display the real value. +func (s *Server) Port() int { + if s == nil || s.cfg == nil { + return 0 + } + return s.cfg.Port +} + +// Path is the URL prefix charge points connect to, after defaults were applied. +// A charger dials , and that identity becomes its device key. +func (s *Server) Path() string { + if s == nil || s.cfg == nil { + return "" + } + return s.cfg.Path +} diff --git a/go/internal/ocpp/server_test.go b/go/internal/ocpp/server_test.go new file mode 100644 index 00000000..c5c385ed --- /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 00000000..20a129a5 --- /dev/null +++ b/go/internal/ocpp/version.go @@ -0,0 +1,67 @@ +package ocpp + +// OCPP version handling. +// +// A charge point picks its dialect during the WebSocket handshake, via the +// subprotocol header — "ocpp1.6", "ocpp2.0.1". The library's ws.Server keeps a +// single message handler, so one listener serves exactly one version and each +// enabled version gets its own port. Which port a charger dialled is therefore +// what tells us how to talk back to it. +// +// Everything above this file is version-neutral: chargers land in the same +// state map, produce the same DerEV telemetry, and take the same commands. Only +// the message encoding differs, which is what the per-version handlers own. + +// Version is an OCPP protocol version FTW can serve. +type Version string + +const ( + // Version16 is OCPP 1.6J. Every charger on the market speaks it, and every + // charger currently on the bench speaks only it. + Version16 Version = "1.6" + + // Version201 is OCPP 2.0.1. Newer hardware and the version the industry is + // migrating to. + Version201 Version = "2.0.1" +) + +// String makes Version printable in logs and errors. +func (v Version) String() string { return string(v) } + +// Valid reports whether this is a version FTW can serve. Used by config +// validation so a typo fails at startup rather than silently serving nothing. +func (v Version) Valid() bool { + switch v { + case Version16, Version201: + return true + default: + return false + } +} + +// Version returns the OCPP dialect a charge point connected with, and whether +// it has been seen at all. +func (h *Handler) Version(id string) (Version, bool) { + if h == nil { + return "", false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.version == "" { + return "", false + } + return s.version, true +} + +// setVersion records the dialect a charge point connected with. Called from +// each version's connect callback, where the listener identity is known. +func (h *Handler) setVersion(id string, v Version) { + if h == nil { + return + } + s := h.state(id) + h.mu.Lock() + s.version = v + h.mu.Unlock() +} diff --git a/web/index.html b/web/index.html index 504274bf..9a501fe1 100644 --- a/web/index.html +++ b/web/index.html @@ -93,7 +93,7 @@

Settings