diff --git a/.changeset/ocpp-charger-interop.md b/.changeset/ocpp-charger-interop.md new file mode 100644 index 000000000..37a61c11f --- /dev/null +++ b/.changeset/ocpp-charger-interop.md @@ -0,0 +1,30 @@ +--- +"ftw": patch +--- + +Three fixes from running the OCPP central system against Sourceful's device +simulator. Each one let FTW report a limit it had not actually imposed, or +refuse a control that should have worked. + +**Charging profiles are sent as Relative, not Absolute.** FTW's schedule is a +single period at second 0 with no end — "hold this limit until I say +otherwise". Absolute expresses that only with a `startSchedule` timestamp, and +while the specification says an absolute schedule without one is relative to +the start of charging anyway, a charger that parses the missing timestamp +strictly finds no valid start, treats the profile as not yet active, and +answers **Accepted** while charging on at full rate. Relative carries no +timestamp, so there is nothing to misparse — and nothing that depends on the +charger's clock agreeing with ours. + +**A charger that refuses a charge-point-wide profile is retried on connector +1.** OCPP 1.6 permits a `TxDefaultProfile` on connector 0 — it is how a profile +applies to every connector — but some chargers read the connector-0 rule as +`ChargePointMaxProfile`-only and reject it. Rejecting means no limit at all, so +one retry on the first connector is the difference between a charger FTW steers +and one it can only meter. + +**Manual EV controls reach an OCPP charger.** Pause, Resume, Force start and +set-current posted to `/api/ev/command` went straight to the Lua driver +registry, which an OCPP charge point is not in — it dialled us rather than +being dialled. They failed with `driver "" not found` while automatic +dispatch steered the same charger correctly. diff --git a/.changeset/ocpp-charging-needs.md b/.changeset/ocpp-charging-needs.md new file mode 100644 index 000000000..c066a3b13 --- /dev/null +++ b/.changeset/ocpp-charging-needs.md @@ -0,0 +1,21 @@ +--- +"ftw": minor +--- + +FTW now listens to what the car itself asks for. On an ISO 15118 session an +OCPP 2.0.1 charger forwards the vehicle's own `NotifyEVChargingNeeds` — +the energy it wants, when it expects to leave, and on DC its battery capacity +and present state of charge. Core takes that as the session's truth: the +reported capacity replaces the configured `vehicle_capacity_wh` (measured beats +an operator's estimate of the car that usually parks here), the reported SoC +re-anchors the session estimate, and the two together with the requested energy +derive the target the planner sizes on. A departure time the car states becomes +the loadpoint's target time, and one it does not state never erases the +operator's own. Everything is session-scoped and reverts on plug-out, like an +identified vehicle profile. The report is visible on `GET /api/ocpp/chargers` +as `charging_needs`, and quarantine still applies — a pending charge point's +needs are shown but never reach a loadpoint. + +An AC session states energy without a battery size, so no target fraction is +derived from it; guessing one would feed the planner a number the car never +claimed. diff --git a/.changeset/ocpp-durable-device.md b/.changeset/ocpp-durable-device.md new file mode 100644 index 000000000..0a81496b2 --- /dev/null +++ b/.changeset/ocpp-durable-device.md @@ -0,0 +1,24 @@ +--- +"ftw": minor +--- + +An adopted OCPP charger is now a device like any other. It gets a row in +`/api/devices` and under Settings → Devices, keyed on the vendor and serial +from its `BootNotification` rather than on the name it dialled with — that +name is one an installer typed and the charger's own web page can change, so +persistent state keyed on it would not survive a re-commissioning. Rename a +charger and the row follows it. A charger that reports no serial falls back to +the dialled name, recorded as an endpoint so it reads as stable-until-changed. +Pending chargers get no row: a device row says this hardware is part of the +site, and quarantine says an unadopted charge point is not. + +`GET /api/ocpp/chargers` now also reports each charger's `serial` and +`firmware`, and OCPP 1.6's deprecated `chargeBoxSerialNumber` is read when the +current field is empty — shipped firmware disagrees about which to fill, and +losing it loses the only stable identity some chargers ever report. + +The OCPP server's own settings — on/off, bind address, both ports, path, +username and password — are editable under Settings → Chargers instead of +only in `config.yaml`. TLS paths and per-charger credentials stay in the file: +they are host filesystem paths and one secret per charger, set once at +commissioning. diff --git a/.changeset/ocpp-listener-hardening.md b/.changeset/ocpp-listener-hardening.md new file mode 100644 index 000000000..2ee16b04d --- /dev/null +++ b/.changeset/ocpp-listener-hardening.md @@ -0,0 +1,26 @@ +--- +"ftw": minor +--- + +The OCPP listener can now be pinned to one interface, served over TLS, and +given a credential per charger. + +`ocpp.bind` finally does something. The library builds its listen address from +the port alone, so the socket is unavoidably open on every interface; FTW now +refuses the WebSocket handshake for a connection that arrived on any other +address. That is an access control rather than a smaller attack surface — the +port still answers a scan — and the docs say so. + +`ocpp.tls` serves `wss://` instead of `ws://`, ending the plaintext basic auth +anyone on the LAN could sniff. `client_ca_file` additionally requires every +charge point to present a certificate signed by that CA (OCPP 2.0.1 security +profile 3). Half a TLS section is refused at startup rather than quietly +serving plaintext. + +`ocpp.chargers` gives a named charge point a password of its own. On OCPP the +basic-auth username is the charge point identity, so a listed charger must +present both, and the shared password stops being enough to connect under its +name — the impersonation hole the pending-charger quarantine could not close. +It is opt-in per charger; anything unlisted keeps using the shared credential. +Per-charger passwords are masked out of `GET /api/config` and survive a +settings save, matched by charger id rather than position. diff --git a/config.example.yaml b/config.example.yaml index aae739ea9..d8dd35f22 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -184,19 +184,35 @@ caldav: # # Credentials are required when enabled, and FTW refuses to start without them: # the OCPP library builds its listen address from the port alone, so the socket -# is reachable on every interface and basic auth is the only gate. Keep the port -# closed at your router. See docs/ocpp.md. +# is open on every interface whatever bind says — bind refuses the handshake for +# a connection that arrived elsewhere, which controls access without closing the +# port. Keep it closed at your router. See docs/ocpp.md. # Each version needs its own port: a charger picks its dialect in the WebSocket # handshake, so one listener cannot serve both. 2.1 is not supported — no # production-grade Go implementation of it exists yet. ocpp: enabled: false + # bind: 192.168.1.10 # refuse chargers reaching the box any other + # way; omit for every interface port: 8887 # OCPP 1.6J # port_v201: 8888 # OCPP 2.0.1; omit to disable path: / username: ftw password: "" # required when enabled; use a long random string heartbeat_interval_s: 60 + # Serve wss:// instead of ws://. client_ca_file additionally requires each + # charger to present a certificate signed by that CA — OCPP 2.0.1 security + # profile 3, and the only identity here that cannot be copied between devices. + # tls: + # cert_file: /etc/ftw/ocpp/server.crt + # key_file: /etc/ftw/ocpp/server.key + # client_ca_file: /etc/ftw/ocpp/charger-ca.crt + # Give a charger a credential of its own, so the shared password above stops + # being enough to connect under its name. The id is what the charger dials + # with, and the same string a loadpoint's driver_name adopts. + # chargers: + # - id: garage + # password: "" # a long random string, different per charger # Persistent state (SQLite) state: diff --git a/docs/ocpp.md b/docs/ocpp.md index 6010fed3d..a278e9dc2 100644 --- a/docs/ocpp.md +++ b/docs/ocpp.md @@ -45,7 +45,20 @@ turns *seen* into *trusted*. Once adopted it behaves like any other EV reading: `MeterValues` and `StatusNotification` become telemetry, and dispatch stops the home battery -discharging into an active EV charge. +discharging into an active EV charge. It also gets a row in `/api/devices` +alongside the driver-backed hardware, under **Settings → Devices**. + +That row is keyed on the vendor and serial from `BootNotification`, not on the +name above — a name an installer typed and the charger's own web page can +change is not hardware identity, and state keyed on it would not survive a +re-commissioning. Rename a charger and its row follows it; the persistent +state stays attached to the box on the wall. A charger that reports no serial +(plenty do not) falls back to the dialled name, recorded as an endpoint so it +reads as what it is: stable only until someone changes it. + +Pending chargers get no row. A device row says this hardware is part of the +site, and quarantine says an unadopted charge point is not; it gets one on the +save that adopts it. ## Protocol versions @@ -128,32 +141,87 @@ ocpp: **Credentials are mandatory.** FTW refuses to start with `enabled: true` and an empty username or password. That is deliberate, and the reason is below. -## Security: the listener is on every interface +## Security: the socket is on every interface The OCPP library builds its listen address from the port alone, so the socket -binds to every interface the host has. There is no bind-address setting, and -there is no TLS on this path yet. +is open on every interface the host has and nothing FTW can configure changes +that. `bind` therefore works one layer up: a connection that arrived on any +other address is refused at the WebSocket handshake, before it can speak OCPP. -On a Raspberry Pi with one LAN connection that is usually fine. It is not fine -if the host also has a public interface or a permissive port forward. +That is an access control, not a smaller attack surface. A port scan still +finds the port open on every interface; what it cannot do is talk to it. -So: +```yaml +ocpp: + bind: 192.168.1.10 # only chargers reaching the box this way +``` + +On a Raspberry Pi with one LAN connection the default (every interface) is +usually fine. Set `bind` when the host also has a VPN interface, a second NIC, +or a public one. + +Whatever you set: - Keep the port closed at your router. Never forward it from the internet. -- Treat the password as a real secret; it is the only gate in front of the - server. -- Basic auth over `ws://` sends the credential unencrypted. Anyone who can sniff - your LAN can read it. +- Treat the password as a real secret. -Behind the password sits a second gate: a charge point no charger entry names -stays pending, outside telemetry and dispatch (see above). A stolen password -gets an attacker a row in the Chargers table, not influence over the site. -What it does not stop is impersonation — a device that knows the password *and* -an adopted charger's id can still pose as it, which is what per-charger -credentials and TLS would fix. +### The four gates -Credentials being required is a mitigation, not a fix. Binding to one interface -needs a change to the upstream library. +1. **Basic auth.** Required — an enabled server without a username and + password is refused at startup. +2. **Identity binding.** A charger listed under `ocpp.chargers` has a password + of its own and must present it under its own name. This is what closes + impersonation: without it, identity is client-chosen and shared, so a device + that knows the password *and* an adopted charger's id can pose as it. +3. **Bind.** As above. +4. **Quarantine.** A charge point no charger entry names stays pending, outside + telemetry and dispatch. A stolen password gets an attacker a row in the + Chargers table, not influence over the site. + +### Per-charger credentials + +```yaml +ocpp: + username: ftw + password: "the-shared-one" + chargers: + - id: garage + password: "a-different-long-random-string" +``` + +The `id` is what the charger dials with — the last segment of its URL, and the +same string a charger entry adopts. On OCPP the basic-auth username *is* that +identity, so a listed charger presents `garage` / its own password, and the +shared credential no longer buys its name. + +This is opt-in per charger: anything not listed keeps using the shared +username and password, so adding one entry does not lock the others out. A +charger you have not listed can still be impersonated by something holding the +shared password — list the ones that matter. + +### TLS + +```yaml +ocpp: + tls: + cert_file: /etc/ftw/ocpp/server.crt + key_file: /etc/ftw/ocpp/server.key + client_ca_file: /etc/ftw/ocpp/charger-ca.crt # optional +``` + +Chargers then dial `wss://` instead of `ws://`. Without it, basic auth over +`ws://` sends the credential unencrypted and anyone who can sniff your LAN can +read it. + +`client_ca_file` additionally requires every charge point to present a +certificate signed by that CA — OCPP 2.0.1 security profile 3. It is the +strongest identity available here: unlike a password, it cannot be copied out +of one charger's configuration and replayed by another device unless the +private key was copied too. + +Half a TLS section is refused at startup rather than quietly serving `ws://`. +An operator who asked for `wss://` and silently got plaintext would have no way +to tell the link was never encrypted. ## Pointing a charger at FTW @@ -253,6 +321,28 @@ would strand a driver with an uncharged car because the EMS went down, and the last limit was already judged safe for the site. This matches what every EV driver in FTW already does. +### How the profile is shaped, and why + +The limit goes out as a `TxDefaultProfile` with one schedule period at second +zero and no end: *hold this until I send another*. Two details of that are +load-bearing, and both come from chargers disagreeing with the specification +in ways that fail silently: + +- **The profile kind is `Relative`, not `Absolute`.** An absolute schedule + states when it starts; FTW's has no start, and the specification says an + absolute schedule without one is relative to the start of charging anyway. + A charger that instead reads the missing timestamp as "not valid yet" + answers **Accepted** and charges on at full rate — the worst failure + available here, because FTW logs a limit it never imposed. Relative carries + no timestamp to misread, and does not depend on the charger's clock agreeing + with ours. +- **Connector 0, then connector 1.** A profile on connector 0 applies to every + connector, which avoids depending on per-connector ids — unreliable on + dual-socket units such as the Charge Amps Aura. Some chargers read the + connector-0 rule as `ChargePointMaxProfile`-only and reject it, so a refusal + is retried once on connector 1. Refusing both is reported as an error: a + charger FTW cannot steer must not look like one it can. + ## One capacity, several cars: vehicle profiles `vehicle_capacity_wh` on a charger entry describes the **one** car the charger @@ -288,19 +378,48 @@ What "identifies" means depends on the dialect: card, not the car — profiles work when each card lives permanently in one car. - **OCPP 2.0.1**: idTokens can name the actual vehicle — `MacAddress` - (autocharge) or `eMAID` (ISO 15118 Plug & Charge) — no card involved. With - ISO 15118 hardware, `NotifyEVChargingNeeds` can additionally state the - energy the car actually wants; consuming that is future work, tracked with - OCPP 2.1 in issue #835. + (autocharge) or `eMAID` (ISO 15118 Plug & Charge) — no card involved. A wrong or missing capacity skews planning accuracy only, never safety — the car's own BMS always protects it. An unprofiled car larger than the configured capacity makes the SoC estimate rise too fast, so a target charge can stop early; correct the SoC in the dashboard EV modal, or use Force start. +## When the car speaks for itself + +With ISO 15118 hardware on OCPP 2.0.1 the car states its own needs, and the +charger forwards them as `NotifyEVChargingNeeds`. That outranks every figure +above: a profile and `vehicle_capacity_wh` are both an operator's estimate of +the car that usually parks here, this is the car actually plugged in. + +What FTW takes from it, for the session only: + +| The car says | FTW does | +|---|---| +| battery capacity (DC) | replaces the session capacity, over a profile's too | +| present state of charge (DC) | re-anchors the session SoC estimate | +| energy requested | with the two above, derives the target the planner fills to | +| departure time | becomes the loadpoint's target time | + +All of it reverts on plug-out, exactly like a profile. A departure time the car +does not state never erases one the operator set. + +An **AC** session states energy without a battery size, so there is no fraction +to derive and the target is left alone — a guess there would feed the planner a +number the car never claimed. A departure time still applies. + +The last report is shown in the Chargers tab and on `GET /api/ocpp/chargers` as +`charging_needs`. Quarantine applies as everywhere else: a pending charge +point's needs are visible so you can see what asked, and reach no loadpoint. + +Most chargers never send this. It needs ISO 15118 on both the charger and the +car; without it, profiles and `vehicle_capacity_wh` remain the whole story. + ## Current limits -- **No TLS**, and the listener cannot be pinned to one interface. +- **The socket cannot be pinned to one interface.** `bind` refuses the + handshake instead, so the port stays open on every interface even when only + one is served. - Chargers that also have a native protocol may work better through a driver. Easee over Modbus and Zaptec over its cloud API already have drivers; OCPP is the option when you want the cloud out of the loop. diff --git a/go/cmd/ftw/main.go b/go/cmd/ftw/main.go index d34eff81b..d4b9a5876 100644 --- a/go/cmd/ftw/main.go +++ b/go/cmd/ftw/main.go @@ -1036,6 +1036,10 @@ func main() { } } ocppSrv.Handler().SetApprovedIDs(approved) + // A charger adopted by this save booted long ago and will not + // boot again just because we changed our mind, so its device + // row has to be written here rather than waiting for one. + registerOCPPDevices(st, ocppSrv) } // Notifications: rebuild the provider from fresh config @@ -1319,16 +1323,26 @@ func main() { approved = append(approved, lp.DriverName) } } - srv, err := ocpp.Start(ctx, &ocpp.Config{ + ocppCfg := &ocpp.Config{ Enabled: cfg.OCPP.Enabled, + Bind: cfg.OCPP.Bind, Port: cfg.OCPP.Port, PortV201: cfg.OCPP.PortV201, Path: cfg.OCPP.Path, Username: cfg.OCPP.Username, Password: cfg.OCPP.Password, HeartbeatIntervalS: cfg.OCPP.HeartbeatIntervalS, + ChargerSecrets: cfg.OCPP.ChargerSecrets(), ApprovedIDs: approved, - }, tel) + } + if t := cfg.OCPP.TLS; t != nil { + ocppCfg.TLS = &ocpp.TLSConfig{ + CertFile: t.CertFile, + KeyFile: t.KeyFile, + ClientCAFile: t.ClientCAFile, + } + } + srv, err := ocpp.Start(ctx, ocppCfg, tel) if err != nil { // A charger that cannot reach us is a missing device, not a // broken site, so keep the rest of the process running. @@ -1343,6 +1357,11 @@ func main() { // profile's charging policy. An identity matching no profile // changes nothing (the visitor default); it still shows in the // Chargers panel so the operator can paste it into a profile. + // An adopted charger becomes a device the moment it says what + // it is, keyed on vendor+serial like any driver-backed one. + ocppSrv.Handler().SetIdentityReported(func(ident ocpp.ChargerIdentity) { + registerOCPPDevice(st, ident) + }) ocppSrv.Handler().SetVehicleIdentified(func(chargerID, vehicleID, source string) { cfgMu.RLock() lpID := "" @@ -1383,6 +1402,60 @@ func main() { "surplus_only", vehicle.SurplusOnly, "target_soc", vehicle.TargetSoC) }) + // Charging needs: on an ISO 15118 session the car states what + // it wants — energy, departure, and on DC its own capacity and + // state of charge. That is the car actually plugged in rather + // than an operator's estimate of it, so it takes precedence for + // the session and reverts on plug-out with everything else. + ocppSrv.Handler().SetChargingNeeds(func(chargerID string, needs ocpp.ChargingNeeds) { + cfgMu.RLock() + lpID := "" + for _, lp := range cfg.Loadpoints { + if lp.DriverName == chargerID { + lpID = lp.ID + break + } + } + cfgMu.RUnlock() + if lpID == "" { + return + } + // Capacity first: the SoC anchor below divides delivered + // energy by it, so anchoring against a stale capacity would + // re-base the session estimate on the wrong battery. + if needs.CapacityWh > 0 { + lpMgr.SetSessionCapacityWh(lpID, needs.CapacityWh) + } + if needs.PresentSoC != nil { + lpMgr.AnchorVehicleSoC(lpID, *needs.PresentSoC) + } + target, haveTarget := needs.TargetSoC() + switch { + case haveTarget: + // A departure the car did not state must not erase one + // the operator did. + when := needs.DepartureTime + if when.IsZero() { + if st, ok := lpMgr.State(lpID); ok { + when = st.TargetTime + } + } + lpMgr.SetTarget(lpID, target, when) + case !needs.DepartureTime.IsZero(): + // AC states energy without a battery size, so there is + // no fraction to derive — but the deadline is still the + // car's, and it belongs on the operator's own target. + if st, ok := lpMgr.State(lpID); ok && st.TargetSoC > 0 { + lpMgr.SetTarget(lpID, st.TargetSoC, needs.DepartureTime) + } + } + slog.Info("ocpp: charging needs applied", + "charger", chargerID, "lp", lpID, + "mode", needs.TransferMode, "energy_wh", needs.EnergyWh, + "capacity_wh", needs.CapacityWh, + "departure", needs.DepartureTime, + "target_soc", target, "target_derived", haveTarget) + }) slog.Info("ocpp: central system started", "port", ocppSrv.Port(), "port_v201", cfg.OCPP.PortV201, @@ -1755,6 +1828,25 @@ func main() { // driver_failure_default.go. actuation := newDriverActuationTracker(tel) + // An OCPP charge point is not in the driver registry — it connected to us + // rather than being dialled — so route by name: if an online charger + // answers to it, command it over OCPP, otherwise fall through to the Lua + // driver registry. Everything above stays unaware of the difference. + // + // Hoisted out of the loadpoint controller below because the API needs the + // same routing: the dashboard's Pause / Resume / Force start post to + // /api/ev/command, and sending those straight to the registry finds no + // driver for a charger that has none. + evSend := reg.Send + if ocppSrv != nil { + evSend = func(ctx context.Context, name string, payload []byte) error { + if ocppSrv.Handler().IsOnline(name) { + return ocppSrv.Command(ctx, name, payload) + } + return reg.Send(ctx, name, payload) + } + } + // ---- EV loadpoint controller ---- // loadpoint.Controller owns per-tick EV dispatch, including the // energy-allocation contract, snapping and phase transitions. @@ -1802,20 +1894,9 @@ func main() { RequestActive: reqActive, }, true } - // 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) + // evSend routes OCPP chargers past the driver registry; loadpoints + // stay unaware of the difference. + lpController = loadpoint.NewController(lpMgr, planAdapter, telAdapter, evSend) // A charger that answers every poll and refuses every setpoint is // the storage bug of #800 on the EV wire: it holds its last // current and the plan keeps counting the load. Only the periodic @@ -2486,6 +2567,7 @@ func main() { Loadpoints: lpMgr, LoadpointCtrl: lpController, OCPPChargers: ocppChargersFn, + EVSend: evSend, CalDAV: calSvc, HA: haBridge, Registry: reg, @@ -3368,6 +3450,49 @@ func doRolloff(ctx context.Context, st *state.Store, coldDir string) { } } +// registerOCPPDevice writes one charge point's row in the device registry. +// +// A charger is not in the driver registry — it dialled us, so there is no +// driver, no endpoint we chose and no HostEnv identity to read. What it does +// have is a BootNotification, and vendor+serial out of that is a +// hardware-stable key exactly like a driver's. The name it dialled with is +// not: an installer typed it and the charger's own web page can change it, so +// it is recorded as the endpoint and only becomes the key when the charger +// reports no serial at all. +func registerOCPPDevice(st *state.Store, ident ocpp.ChargerIdentity) { + if st == nil || ident.ID == "" { + return + } + dev := state.Device{ + DriverName: ident.ID, + Make: ident.Vendor, + Serial: ident.Serial, + Endpoint: "ocpp://" + ident.ID, + } + id, err := st.RegisterDevice(dev) + if err != nil { + slog.Warn("ocpp: could not register charger as a device", + "charger", ident.ID, "err", err) + return + } + slog.Info("ocpp: charger registered as a device", + "charger", ident.ID, "device_id", id, + "vendor", ident.Vendor, "model", ident.Model, "serial", ident.Serial) +} + +// registerOCPPDevices catches up every adopted charger that has already +// booted. Adoption usually happens long after a BootNotification — an +// operator sees a pending charger in the UI and binds it to a loadpoint — and +// the charger will not boot again just because we changed our mind about it. +func registerOCPPDevices(st *state.Store, srv *ocpp.Server) { + if st == nil || srv == nil { + return + } + for _, ident := range srv.Handler().Identities() { + registerOCPPDevice(st, ident) + } +} + // registerAllDevices snapshots the identity HostEnv has gathered for each // running driver and upserts a row in the devices table. Idempotent. // Called periodically because some drivers (notably MQTT) only learn their diff --git a/go/cmd/ftw/ocpp_devices_test.go b/go/cmd/ftw/ocpp_devices_test.go new file mode 100644 index 000000000..ca36ed905 --- /dev/null +++ b/go/cmd/ftw/ocpp_devices_test.go @@ -0,0 +1,102 @@ +package main + +import ( + "path/filepath" + "testing" + + "github.com/srcfl/ftw/go/internal/ocpp" + "github.com/srcfl/ftw/go/internal/state" +) + +func openTestStore(t *testing.T) *state.Store { + t.Helper() + st, err := state.Open(filepath.Join(t.TempDir(), "state.db")) + if err != nil { + t.Fatalf("open state: %v", err) + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +// A charge point earns its device row on vendor+serial, like every other +// device. The name it dialled with is not identity — an installer typed it and +// the charger's own web page can change it — so it is recorded as the endpoint +// and only becomes the key when the charger reports no serial at all. +func TestOCPPDeviceIsKeyedOnHardwareNotTheDialledName(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{ + ID: "garage", + Vendor: "Charge Amps", + Model: "Halo", + Serial: "CA-0001", + }) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 1 { + t.Fatalf("got %d devices, want 1", len(devs)) + } + d := devs[0] + if d.DeviceID != "charge amps:CA-0001" { + t.Errorf("device_id: got %q, want it keyed on vendor+serial", d.DeviceID) + } + if d.DriverName != "garage" { + t.Errorf("driver_name: got %q, want the id the charger dialled with", d.DriverName) + } + if d.Endpoint != "ocpp://garage" { + t.Errorf("endpoint: got %q, want ocpp://garage", d.Endpoint) + } + + // Re-commissioned under a new name: same hardware, same row. + registerOCPPDevice(st, ocpp.ChargerIdentity{ + ID: "carport", + Vendor: "Charge Amps", + Serial: "CA-0001", + }) + devs, _ = st.AllDevices() + if len(devs) != 1 { + t.Fatalf("renaming a charger created a second device row: %+v", devs) + } + if devs[0].DriverName != "carport" { + t.Errorf("driver_name should follow the rename, got %q", devs[0].DriverName) + } +} + +// Plenty of chargers report no serial. They still deserve a row — the name +// they dialled with is stable enough to be an endpoint, which is exactly how +// the registry treats an address that only holds until someone changes it. +func TestOCPPDeviceFallsBackToTheDialledName(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{ID: "garage", Vendor: "Easee"}) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 1 { + t.Fatalf("got %d devices, want 1", len(devs)) + } + if devs[0].DeviceID != "ep:ocpp://garage" { + t.Errorf("device_id: got %q, want the endpoint fallback", devs[0].DeviceID) + } +} + +// An empty identity has nothing to key on and must not create a row. +func TestOCPPDeviceIgnoresAnEmptyIdentity(t *testing.T) { + st := openTestStore(t) + + registerOCPPDevice(st, ocpp.ChargerIdentity{}) + registerOCPPDevice(nil, ocpp.ChargerIdentity{ID: "garage"}) + + devs, err := st.AllDevices() + if err != nil { + t.Fatalf("AllDevices: %v", err) + } + if len(devs) != 0 { + t.Fatalf("got %d devices, want none: %+v", len(devs), devs) + } +} diff --git a/go/internal/api/api.go b/go/internal/api/api.go index 198a8a90c..a0fab26cc 100644 --- a/go/internal/api/api.go +++ b/go/internal/api/api.go @@ -166,6 +166,13 @@ type Deps struct { // failed to start; the endpoint then reports an empty list. OCPPChargers func() map[string]ocpp.ChargerView + // EVSend delivers a command to an EV charger by name, routing an OCPP + // charge point past the driver registry it is not in. Nil falls back to + // Registry.Send, which is correct for a build with no OCPP server and + // wrong for one with a charger that has no driver — the dashboard's + // Pause / Resume / Force start would find no such driver and fail. + EVSend func(ctx context.Context, name string, payload []byte) error + // Optional: CalDAV calendar-constraints client (#498). Nil when the // feature is disabled; GET /api/caldav/status then reports disabled. CalDAV *calendar.Service @@ -3058,7 +3065,10 @@ func (s *Server) handleEVCommand(w http.ResponseWriter, r *http.Request) { return } } - if s.deps.Registry == nil { + // An OCPP-only site has chargers and no Lua driver behind them, so the + // registry being absent is not by itself a reason to refuse: EVSend can + // still deliver. Both missing is. + if s.deps.Registry == nil && s.deps.EVSend == nil { writeJSON(w, 503, map[string]string{"error": "driver registry not available"}) return } @@ -3076,13 +3086,24 @@ func (s *Server) handleEVCommand(w http.ResponseWriter, r *http.Request) { applyManualEVHold(s.deps, driverName, req.Action) } payload, _ := json.Marshal(map[string]any{"action": req.Action}) - if err := s.deps.Registry.Send(r.Context(), driverName, payload); err != nil { + if err := s.sendEV(r.Context(), driverName, payload); err != nil { writeJSON(w, 500, map[string]string{"error": err.Error()}) return } writeJSON(w, 200, map[string]string{"status": "ok"}) } +// sendEV delivers an EV command, routing an OCPP charge point past the driver +// registry it is not in. Without this an OCPP charger answers telemetry and +// automatic dispatch but refuses every manual control on the dashboard, which +// reads as the charger being broken rather than unrouted. +func (s *Server) sendEV(ctx context.Context, driverName string, payload []byte) error { + if s.deps.EVSend != nil { + return s.deps.EVSend(ctx, driverName, payload) + } + return s.deps.Registry.Send(ctx, driverName, payload) +} + var validV2XActions = map[string]bool{ "v2x_set_power": true, "v2x_stop": true, diff --git a/go/internal/api/api_ev_ocpp_test.go b/go/internal/api/api_ev_ocpp_test.go new file mode 100644 index 000000000..73406f5c2 --- /dev/null +++ b/go/internal/api/api_ev_ocpp_test.go @@ -0,0 +1,82 @@ +package api + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// An OCPP charge point has no Lua driver — it dialled us rather than being +// dialled — so it is not in the driver registry. Sending the dashboard's +// Pause / Resume / Force start straight to the registry finds no such driver +// and fails, which reads as the charger being broken rather than unrouted. +// +// Found by running the branch against Sourceful's device simulator: automatic +// dispatch steered the charger and every manual control returned +// `driver "garage" not found`. +func TestEVCommandReachesAChargerWithNoDriver(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("garage", telemetry.DerEV, 0, nil, nil) + + var mu sync.Mutex + var gotName string + var gotAction string + + srv := New(&Deps{ + Tel: tel, + // Registry deliberately nil: this is a site whose only charger is an + // OCPP one, so there is no Lua driver registry to fall back to. + EVSend: func(_ context.Context, name string, payload []byte) error { + var cmd struct { + Action string `json:"action"` + } + _ = json.Unmarshal(payload, &cmd) + mu.Lock() + gotName, gotAction = name, cmd.Action + mu.Unlock() + return nil + }, + }) + + req := httptest.NewRequest(http.MethodPost, "/api/ev/command", + strings.NewReader(`{"action":"ev_pause","driver":"garage"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusOK { + t.Fatalf("got status %d, want 200 (body: %s)", rr.Code, rr.Body.String()) + } + mu.Lock() + defer mu.Unlock() + if gotName != "garage" { + t.Errorf("command went to %q, want garage", gotName) + } + if gotAction != "ev_pause" { + t.Errorf("action: got %q, want ev_pause", gotAction) + } +} + +// With neither route available the endpoint still refuses rather than +// pretending the command landed. +func TestEVCommandWithNoRouteIsRefused(t *testing.T) { + tel := telemetry.NewStore() + tel.Update("garage", telemetry.DerEV, 0, nil, nil) + srv := New(&Deps{Tel: tel}) + + req := httptest.NewRequest(http.MethodPost, "/api/ev/command", + strings.NewReader(`{"action":"ev_pause","driver":"garage"}`)) + req.Header.Set("Content-Type", "application/json") + rr := httptest.NewRecorder() + srv.Handler().ServeHTTP(rr, req) + + if rr.Code != http.StatusServiceUnavailable { + t.Fatalf("got status %d, want 503 (body: %s)", rr.Code, rr.Body.String()) + } +} diff --git a/go/internal/config/config.go b/go/internal/config/config.go index 3b064791c..8611a4fbf 100644 --- a/go/internal/config/config.go +++ b/go/internal/config/config.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "math" + "net" "net/url" "os" "path/filepath" @@ -58,20 +59,62 @@ type Config struct { // appears as a device the moment it sends its first BootNotification, keyed by // the identity segment of the URL it dialled. // -// Disabled by default, and enabling it requires credentials. The 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. +// Disabled by default, and enabling it requires credentials. The socket cannot +// be restricted to one interface: the OCPP library builds its own listen +// address from the port alone, so it is open on every interface the host has. +// Bind therefore refuses the handshake for a connection that arrived +// elsewhere, which controls access without shrinking the attack surface — an +// empty Username or Password is still rejected rather than silently accepted. type OCPP struct { - Enabled bool `yaml:"enabled" json:"enabled"` + Enabled bool `yaml:"enabled" json:"enabled"` + + // Bind is the address chargers are served on. Empty or 0.0.0.0 accepts + // every interface, which is the default and what a flat home LAN wants. + // Set it to one LAN address to refuse chargers reaching the box any + // other way — over a VPN interface, say, or a second NIC. + Bind string `yaml:"bind,omitempty" json:"bind,omitempty"` + Port int `yaml:"port,omitempty" json:"port,omitempty"` PortV201 int `yaml:"port_v201,omitempty" json:"port_v201,omitempty"` Path string `yaml:"path,omitempty" json:"path,omitempty"` Username string `yaml:"username,omitempty" json:"username,omitempty"` Password string `yaml:"password,omitempty" json:"password,omitempty"` HeartbeatIntervalS int `yaml:"heartbeat_interval_s,omitempty" json:"heartbeat_interval_s,omitempty"` + + // TLS serves wss:// instead of ws://. Optional, and worth the + // certificate management on any site where the charger and the box are + // not on the same trusted wire. + TLS *OCPPTLS `yaml:"tls,omitempty" json:"tls,omitempty"` + + // Chargers gives named charge points a credential of their own, so the + // shared password stops being enough to claim their identity. Optional + // and per charger: anything not listed keeps using Username/Password. + Chargers []OCPPCharger `yaml:"chargers,omitempty" json:"chargers,omitempty"` +} + +// OCPPTLS points at the certificate the OCPP listener presents, and optionally +// at the CA that signs the charge points allowed to connect. +type OCPPTLS struct { + CertFile string `yaml:"cert_file,omitempty" json:"cert_file,omitempty"` + KeyFile string `yaml:"key_file,omitempty" json:"key_file,omitempty"` + + // ClientCAFile turns on mutual TLS: every charge point must present a + // certificate signed by this CA. That is OCPP 2.0.1 security profile 3, + // and the only identity here that cannot be copied out of one charger's + // configuration and replayed by another device. + ClientCAFile string `yaml:"client_ca_file,omitempty" json:"client_ca_file,omitempty"` +} + +// OCPPCharger is one charge point's own credential. +// +// The ID is the identity the charger dials with — the last segment of its URL, +// and the same string a loadpoint's driver_name uses to adopt it. On OCPP the +// basic-auth username is that identity, so a charger listed here must present +// both, and a device holding only the shared password can no longer connect +// under its name. +type OCPPCharger struct { + ID string `yaml:"id" json:"id"` + Password string `yaml:"password,omitempty" json:"password,omitempty"` } // Validate rejects an enabled server that would accept anonymous charge @@ -96,9 +139,67 @@ func (o *OCPP) Validate() error { if o.HeartbeatIntervalS < 0 { return fmt.Errorf("ocpp.heartbeat_interval_s must be >= 0, got %d", o.HeartbeatIntervalS) } + // A bind address that does not parse would silently fall back to "every + // interface" — the opposite of what the operator asked for. + if o.Bind != "" && net.ParseIP(o.Bind) == nil { + return fmt.Errorf("ocpp.bind must be an IP address, got %q", o.Bind) + } + if err := o.TLS.validate(); err != nil { + return err + } + seen := make(map[string]bool, len(o.Chargers)) + for i, c := range o.Chargers { + if c.ID == "" { + return fmt.Errorf("ocpp.chargers[%d].id is required", i) + } + if seen[c.ID] { + return fmt.Errorf("ocpp.chargers has two entries for %q", c.ID) + } + seen[c.ID] = true + // An entry with no password would quietly fall back to the shared + // one, leaving the operator believing this charger was pinned to a + // credential of its own. + if c.Password == "" { + return fmt.Errorf("ocpp.chargers[%d] (%s) needs a password; remove the entry to use the shared one", i, c.ID) + } + } return nil } +// validate checks the TLS section without touching the filesystem — the paths +// are read when the listener starts, which is where a missing file is +// reported. A nil section means plaintext ws://, which is the default. +func (t *OCPPTLS) validate() error { + if t == nil { + return nil + } + if t.CertFile == "" && t.KeyFile == "" { + if t.ClientCAFile != "" { + return errors.New("ocpp.tls.client_ca_file needs cert_file and key_file: client certificates are only verified on a TLS listener") + } + return nil + } + if t.CertFile == "" || t.KeyFile == "" { + return errors.New("ocpp.tls needs both cert_file and key_file") + } + return nil +} + +// ChargerSecrets is the per-charger credential map, keyed by charge point +// identity. Empty when none are configured. +func (o *OCPP) ChargerSecrets() map[string]string { + if o == nil || len(o.Chargers) == 0 { + return nil + } + out := make(map[string]string, len(o.Chargers)) + for _, c := range o.Chargers { + if c.ID != "" && c.Password != "" { + out[c.ID] = c.Password + } + } + return out +} + // AppLink controls the outbound connection the FTW app reaches this box // through. It defaults on when the section is absent; an explicit false is the // opt-out. One switch is enough because the relay is content-blind and fixed @@ -1336,9 +1437,20 @@ func (c Config) MaskSecrets() Config { } // The OCPP password is the only thing standing in front of a listener that // is reachable on every interface, so it must never leave over the API. + // The per-charger passwords are the same secret with a narrower blast + // radius, and the slice has to be copied rather than blanked in place — + // the struct copy above shares its backing array with the live config. if out.OCPP != nil { cp := *out.OCPP cp.Password = "" + if len(cp.Chargers) > 0 { + chargers := make([]OCPPCharger, len(cp.Chargers)) + for i, c := range cp.Chargers { + c.Password = "" + chargers[i] = c + } + cp.Chargers = chargers + } out.OCPP = &cp } if out.Price != nil { @@ -1416,8 +1528,23 @@ func (incoming *Config) PreserveMaskedSecrets(existing *Config) { // 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.OCPP != nil && existing.OCPP != nil { + if incoming.OCPP.Password == "" { + incoming.OCPP.Password = existing.OCPP.Password + } + // Per-charger passwords are masked the same way, and are matched by + // charger id rather than position: the settings UI can reorder the + // list or drop an entry, and restoring by index would then hand one + // charger another's credential. + if len(incoming.OCPP.Chargers) > 0 { + stored := existing.OCPP.ChargerSecrets() + for i := range incoming.OCPP.Chargers { + c := &incoming.OCPP.Chargers[i] + if c.Password == "" { + c.Password = stored[c.ID] + } + } + } } if incoming.HomeAssistant != nil && existing.HomeAssistant != nil && incoming.HomeAssistant.Password == "" { incoming.HomeAssistant.Password = existing.HomeAssistant.Password diff --git a/go/internal/config/ocpp_auth_test.go b/go/internal/config/ocpp_auth_test.go new file mode 100644 index 000000000..e9583ae37 --- /dev/null +++ b/go/internal/config/ocpp_auth_test.go @@ -0,0 +1,177 @@ +package config + +import "testing" + +func enabledOCPP(mut func(o *OCPP)) *OCPP { + o := &OCPP{Enabled: true, Username: "ftw", Password: "shared-secret"} + if mut != nil { + mut(o) + } + return o +} + +func TestOCPPBindValidation(t *testing.T) { + tests := []struct { + name string + bind string + wantErr bool + }{ + {name: "empty means every interface", bind: ""}, + {name: "unspecified", bind: "0.0.0.0"}, + {name: "a LAN address", bind: "192.168.1.10"}, + {name: "IPv6", bind: "::1"}, + // A hostname would silently fall back to "every interface", which is + // the opposite of what the operator asked for. + {name: "hostname", bind: "ftw.local", wantErr: true}, + {name: "address with a port", bind: "192.168.1.10:8887", wantErr: true}, + {name: "nonsense", bind: "everywhere", wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := enabledOCPP(func(o *OCPP) { o.Bind = tc.bind }).Validate() + if tc.wantErr && err == nil { + t.Errorf("bind %q accepted, want rejected", tc.bind) + } + if !tc.wantErr && err != nil { + t.Errorf("bind %q rejected: %v", tc.bind, err) + } + }) + } +} + +func TestOCPPTLSValidation(t *testing.T) { + tests := []struct { + name string + tls *OCPPTLS + wantErr bool + }{ + {name: "absent"}, + {name: "empty section"}, + {name: "cert and key", tls: &OCPPTLS{CertFile: "c.pem", KeyFile: "k.pem"}}, + {name: "mutual TLS", tls: &OCPPTLS{CertFile: "c.pem", KeyFile: "k.pem", ClientCAFile: "ca.pem"}}, + {name: "cert without key", tls: &OCPPTLS{CertFile: "c.pem"}, wantErr: true}, + {name: "key without cert", tls: &OCPPTLS{KeyFile: "k.pem"}, wantErr: true}, + // Client certificates are only verified on a TLS listener, so this + // would look like mutual TLS and be plaintext. + {name: "client CA alone", tls: &OCPPTLS{ClientCAFile: "ca.pem"}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + o := enabledOCPP(func(o *OCPP) { o.TLS = tc.tls }) + if tc.name == "empty section" { + o.TLS = &OCPPTLS{} + } + err := o.Validate() + if tc.wantErr && err == nil { + t.Errorf("%+v accepted, want rejected", tc.tls) + } + if !tc.wantErr && err != nil { + t.Errorf("%+v rejected: %v", tc.tls, err) + } + }) + } +} + +func TestOCPPPerChargerValidation(t *testing.T) { + tests := []struct { + name string + chargers []OCPPCharger + wantErr bool + }{ + {name: "none"}, + {name: "one", chargers: []OCPPCharger{{ID: "garage", Password: "x"}}}, + {name: "two", chargers: []OCPPCharger{{ID: "garage", Password: "x"}, {ID: "carport", Password: "y"}}}, + {name: "no id", chargers: []OCPPCharger{{Password: "x"}}, wantErr: true}, + // An entry with no password would quietly fall back to the shared + // one while the operator believes it is pinned. + {name: "no password", chargers: []OCPPCharger{{ID: "garage"}}, wantErr: true}, + {name: "duplicate id", chargers: []OCPPCharger{{ID: "garage", Password: "x"}, {ID: "garage", Password: "y"}}, wantErr: true}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + err := enabledOCPP(func(o *OCPP) { o.Chargers = tc.chargers }).Validate() + if tc.wantErr && err == nil { + t.Errorf("%+v accepted, want rejected", tc.chargers) + } + if !tc.wantErr && err != nil { + t.Errorf("%+v rejected: %v", tc.chargers, err) + } + }) + } +} + +// Per-charger passwords are the same secret as the shared one with a narrower +// blast radius, so they get the same treatment: never served, never wiped by a +// settings save that returns the masked value. +func TestOCPPPerChargerPasswordsMaskedAndPreserved(t *testing.T) { + stored := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{ + {ID: "garage", Password: "garage-secret"}, + {ID: "carport", Password: "carport-secret"}, + } + })} + + masked := stored.MaskSecrets() + for i, c := range masked.OCPP.Chargers { + if c.Password != "" { + t.Errorf("charger %d (%s) leaked its password: %q", i, c.ID, c.Password) + } + if c.ID == "" { + t.Errorf("charger %d lost its id to masking", i) + } + } + // The struct copy shares its backing array with the live config, so this + // is the assertion that catches blanking in place. + if stored.OCPP.Chargers[0].Password != "garage-secret" { + t.Errorf("MaskSecrets mutated the source config: %q", stored.OCPP.Chargers[0].Password) + } + + // A save that round-trips the masked values must not wipe them, and must + // match by id — the settings UI may reorder or drop entries, and + // restoring by position would hand one charger another's credential. + incoming := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{ + {ID: "carport", Password: ""}, + {ID: "garage", Password: ""}, + } + })} + incoming.PreserveMaskedSecrets(stored) + if got := incoming.OCPP.Chargers[0].Password; got != "carport-secret" { + t.Errorf("carport got %q, want carport-secret", got) + } + if got := incoming.OCPP.Chargers[1].Password; got != "garage-secret" { + t.Errorf("garage got %q, want garage-secret", got) + } + + // A genuinely new password still wins. + changed := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "garage", Password: "rotated"}} + })} + changed.PreserveMaskedSecrets(stored) + if got := changed.OCPP.Chargers[0].Password; got != "rotated" { + t.Errorf("rotated password not kept, got %q", got) + } + + // A charger with no stored secret stays empty rather than inheriting one. + fresh := &Config{OCPP: enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "driveway", Password: ""}} + })} + fresh.PreserveMaskedSecrets(stored) + if got := fresh.OCPP.Chargers[0].Password; got != "" { + t.Errorf("an unknown charger inherited a secret: %q", got) + } +} + +func TestOCPPChargerSecrets(t *testing.T) { + var nilOCPP *OCPP + if got := nilOCPP.ChargerSecrets(); got != nil { + t.Errorf("nil section: got %v, want nil", got) + } + o := enabledOCPP(func(o *OCPP) { + o.Chargers = []OCPPCharger{{ID: "garage", Password: "x"}} + }) + secrets := o.ChargerSecrets() + if len(secrets) != 1 || secrets["garage"] != "x" { + t.Errorf("got %v, want one garage entry", secrets) + } +} diff --git a/go/internal/loadpoint/loadpoint.go b/go/internal/loadpoint/loadpoint.go index 5abb6a45d..9882320c2 100644 --- a/go/internal/loadpoint/loadpoint.go +++ b/go/internal/loadpoint/loadpoint.go @@ -316,6 +316,14 @@ type loadpointRuntime struct { vehicleName string baseCapacityWh float64 + // capacityFromCar is set when the vehicle itself reported its battery + // capacity (OCPP 2.0.1 NotifyEVChargingNeeds), which outranks both the + // configured value and a profile's — one is measured, the others are + // an operator's estimate of the car that usually parks here. It shares + // baseCapacityWh with vehicleName: whichever arrives first snapshots + // the configured capacity, and plug-out restores it either way. + capacityFromCar bool + // schedule carries the operator's persistent intent. Empty when // none is set. Survives config hot-reload because Load() copies // it across from the previous runtime row. @@ -458,7 +466,8 @@ func (m *Manager) Load(cfgs []Config) { lp.stoppedSince = existing.stoppedSince lp.steadyRunArmed = existing.steadyRunArmed lp.vehicleName = existing.vehicleName - if existing.vehicleName != "" { + lp.capacityFromCar = existing.capacityFromCar + if existing.vehicleName != "" || existing.capacityFromCar { // An identified car survives config hot-reload: keep the // session's applied capacity, but re-base the plug-out // restore on the NEW config's value. @@ -590,11 +599,12 @@ func (m *Manager) Observe(id string, pluggedIn bool, powerW, deliveredWh float64 lp.notRequestingSince = time.Time{} lp.sessionComplete = false lp.socSource = "" - if lp.vehicleName != "" { + if lp.vehicleName != "" || lp.capacityFromCar { // The identified car left with its session — the next one may // be different, so restore the loadpoint's own capacity. lp.VehicleCapacityWh = lp.baseCapacityWh lp.vehicleName = "" + lp.capacityFromCar = false lp.baseCapacityWh = 0 } } @@ -821,17 +831,48 @@ func (m *Manager) ApplyVehicleProfile(id, vehicleName string, capacityWh float64 if !ok { return false } - if lp.vehicleName == "" { + if lp.vehicleName == "" && !lp.capacityFromCar { lp.baseCapacityWh = lp.VehicleCapacityWh } lp.vehicleName = vehicleName - if capacityWh > 0 { + // A capacity the car measured for this session outranks the profile's + // configured guess, whichever arrived first. + if capacityWh > 0 && !lp.capacityFromCar { lp.VehicleCapacityWh = capacityWh } lp.updatedAtMs = m.now().UnixMilli() return true } +// SetSessionCapacityWh overrides the vehicle capacity for the rest of the +// session with a figure the car itself reported — OCPP 2.0.1 +// NotifyEVChargingNeeds carries the EV's own battery capacity. +// +// Measured outranks configured, so this wins over both vehicle_capacity_wh and +// a vehicle profile applied for the same session, in either order. It shares +// the profile's session scope: plug-out restores the loadpoint's own value, +// because the next car may be a different one. +// +// Returns false for an unknown loadpoint id or a non-positive capacity. +func (m *Manager) SetSessionCapacityWh(id string, capacityWh float64) bool { + if capacityWh <= 0 { + return false + } + m.mu.Lock() + defer m.mu.Unlock() + lp, ok := m.byID[id] + if !ok { + return false + } + if lp.vehicleName == "" && !lp.capacityFromCar { + lp.baseCapacityWh = lp.VehicleCapacityWh + } + lp.capacityFromCar = true + lp.VehicleCapacityWh = capacityWh + lp.updatedAtMs = m.now().UnixMilli() + return true +} + // SetCurrentSoC lets an operator correct the inferred vehicle SoC // mid-session. Chargers like Easee don't report the vehicle's actual // BMS state, so the manager defaults to diff --git a/go/internal/loadpoint/session_capacity_test.go b/go/internal/loadpoint/session_capacity_test.go new file mode 100644 index 000000000..f01b68fdd --- /dev/null +++ b/go/internal/loadpoint/session_capacity_test.go @@ -0,0 +1,90 @@ +package loadpoint + +import "testing" + +// A capacity the car itself reported (OCPP 2.0.1 NotifyEVChargingNeeds) is +// session-scoped exactly like a vehicle profile: it steers SoC inference while +// the car is plugged in, survives a config hot-reload, and reverts on plug-out +// because the next car may be a different one. +func TestSetSessionCapacitySessionScoped(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + if m.SetSessionCapacityWh("nope", 40000) { + t.Fatal("unknown loadpoint should not apply") + } + if m.SetSessionCapacityWh("a", 0) { + t.Fatal("a car that reported no capacity must not zero the loadpoint's own") + } + if !m.SetSessionCapacityWh("a", 40000) { + t.Fatal("apply failed") + } + + // 20 kWh delivered on a 40 kWh car = +50 points from the 30 % anchor. + // With the configured 60 kWh it would only be +33. + m.Observe("a", true, 7000, 20000, true) + st, _ := m.State("a") + if st.CurrentSoC < 0.79 || st.CurrentSoC > 0.81 { + t.Fatalf("SoC should follow the reported 40 kWh capacity, got %v", st.CurrentSoC) + } + + // Hot-reload mid-session keeps the car's figure, not the config's. + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + cfgs := m.Configs() + if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 40000 { + t.Fatalf("reported capacity should survive config reload, got %+v", cfgs) + } + + // Plug-out restores the loadpoint's own capacity. + m.Observe("a", false, 0, 0, false) + cfgs = m.Configs() + if len(cfgs) != 1 || cfgs[0].VehicleCapacityWh != 60000 { + t.Fatalf("capacity should restore to the configured 60000, got %+v", cfgs) + } +} + +// Measured outranks configured, whichever order the two arrive in: a vehicle +// profile is an operator's estimate of the car that usually parks here, the +// NotifyEVChargingNeeds figure is the car that is actually plugged in. +func TestReportedCapacityOutranksProfile(t *testing.T) { + t.Run("profile first", func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + m.ApplyVehicleProfile("a", "Leaf", 40000) + m.SetSessionCapacityWh("a", 77000) + + if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { + t.Fatalf("the car's own capacity should win, got %v", got) + } + // Plug-out still restores the configured value, not the profile's. + m.Observe("a", false, 0, 0, false) + if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { + t.Fatalf("plug-out should restore the configured 60000, got %v", got) + } + }) + + t.Run("car first", func(t *testing.T) { + m := NewManager() + m.Load([]Config{{ID: "a", VehicleCapacityWh: 60000, PluginSoC: 0.30}}) + m.Observe("a", true, 7000, 0, true) + + m.SetSessionCapacityWh("a", 77000) + m.ApplyVehicleProfile("a", "Leaf", 40000) + + if got := m.Configs()[0].VehicleCapacityWh; got != 77000 { + t.Fatalf("a later profile must not overwrite the car's own capacity, got %v", got) + } + // The profile still names the car for the UI. + st, _ := m.State("a") + if st.VehicleName != "Leaf" { + t.Fatalf("vehicle name should still apply, got %+v", st) + } + m.Observe("a", false, 0, 0, false) + if got := m.Configs()[0].VehicleCapacityWh; got != 60000 { + t.Fatalf("plug-out should restore the configured 60000, got %v", got) + } + }) +} diff --git a/go/internal/ocpp/auth.go b/go/internal/ocpp/auth.go new file mode 100644 index 000000000..d14966c22 --- /dev/null +++ b/go/internal/ocpp/auth.go @@ -0,0 +1,183 @@ +package ocpp + +// Who may speak, as whom, and from where. +// +// Three gates sit in front of a charge point, and they answer different +// questions. Basic auth proves knowledge of a secret. The identity binding +// below proves the connection is entitled to the identity it claims. The +// quarantine in handlers.go decides whether an authenticated, entitled charger +// is part of this site at all. +// +// The library hands us two callbacks. SetBasicAuthHandler sees the credential +// but not the identity; SetCheckClientHandler sees the identity and the whole +// HTTP request — including the credential, the local address the connection +// landed on, and any client certificate. Everything that needs both therefore +// happens in checkClient, which the library calls immediately after basic auth +// and before the WebSocket upgrade. + +import ( + "crypto/subtle" + "log/slog" + "net" + "net/http" +) + +// authorizer holds the credentials and the interface restriction for one +// listener. The zero value authorizes everything, which is what an OCPP +// section with no username configured asks for. +type authorizer struct { + // sharedUser and sharedPass are the site-wide credential. Every charge + // point without one of its own uses it. + sharedUser string + sharedPass string + + // perCharger maps a charge point identity to its own password. A + // charger listed here must present that password AND connect under + // that identity — see checkClient. This is what makes an adopted + // charger un-impersonable by anything holding only the shared secret. + perCharger map[string]string + + // bindIP is the address the operator asked the listener to serve on. + // Nil, unspecified (0.0.0.0 / ::) means every interface. + bindIP net.IP +} + +// newAuthorizer builds the gate for a listener from config. +func newAuthorizer(cfg *Config) *authorizer { + a := &authorizer{ + sharedUser: cfg.Username, + sharedPass: cfg.Password, + } + if len(cfg.ChargerSecrets) > 0 { + a.perCharger = make(map[string]string, len(cfg.ChargerSecrets)) + for id, pass := range cfg.ChargerSecrets { + if id != "" && pass != "" { + a.perCharger[id] = pass + } + } + } + if ip := net.ParseIP(cfg.Bind); ip != nil && !ip.IsUnspecified() { + a.bindIP = ip + } + return a +} + +// requiresCredential reports whether any credential is configured at all. +// +// The library treats a registered basic-auth handler as "credentials are +// mandatory" and answers 401 to a charger that sends none, so the handler must +// stay unregistered when nothing is configured — otherwise enabling OCPP with +// no username would lock out every charger instead of admitting them all. +func (a *authorizer) requiresCredential() bool { + if a == nil { + return false + } + return a.sharedUser != "" || a.sharedPass != "" || len(a.perCharger) > 0 +} + +// secretEqual compares in constant time so a wrong password cannot be found +// one character at a time. +func secretEqual(got, want string) bool { + return subtle.ConstantTimeCompare([]byte(got), []byte(want)) == 1 +} + +// basicAuth is the first gate: does this connection know a secret we issued? +// +// It cannot yet tell whether the credential belongs to the identity being +// claimed — the library does not pass the URL here — so it accepts any +// credential we recognise and leaves the binding to checkClient. On OCPP the +// basic-auth username is the charge point identity, which is why a per-charger +// secret is looked up by username. +func (a *authorizer) basicAuth(user, pass string) bool { + if a == nil { + return true + } + if secret, ok := a.perCharger[user]; ok { + return secretEqual(pass, secret) + } + if a.sharedUser == "" && a.sharedPass == "" { + // No credential configured. Validation refuses this for an enabled + // server, so it is reachable only in tests. + return true + } + // Both halves compare in constant time. The username is as much of a + // secret as the password on a listener the socket layer will not pin, + // and comparing it with == would leak it one character at a time. + userOK := secretEqual(user, a.sharedUser) + passOK := secretEqual(pass, a.sharedPass) + return userOK && passOK +} + +// checkClient is the second gate, and the one that closes impersonation. +// +// A charge point picks its own identity — it is the last segment of the URL it +// dialled — so "it authenticated" has never proved which device it is. Where a +// charger has its own credential, this requires the connection to present that +// exact credential under that exact identity: the shared password no longer +// buys an attacker an adopted charger's name, only a pending row. +// +// It also enforces the configured bind address. The library builds its listen +// address from the port alone, so the socket itself is unavoidably on every +// interface; refusing the handshake here is what actually stops a charger from +// talking to us over one the operator did not offer. A port scan still sees an +// open port — this is an access control, not a smaller attack surface. +func (a *authorizer) checkClient(id string, r *http.Request) bool { + if a == nil { + return true + } + if !a.allowedLocalAddr(r) { + slog.Warn("ocpp: refused a charge point that arrived on an address the server is not offered on", + "charger", id, "bind", a.bindIP.String(), "arrived_on", localAddr(r)) + return false + } + secret, hasOwn := a.perCharger[id] + if !hasOwn { + return true + } + user, pass, ok := r.BasicAuth() + if !ok || user != id || !secretEqual(pass, secret) { + slog.Warn("ocpp: refused a connection claiming a charger that has its own credential", + "charger", id, "presented_user", user) + return false + } + return true +} + +// allowedLocalAddr reports whether the connection landed on the interface the +// operator asked for. Always true when no specific bind address is set. +func (a *authorizer) allowedLocalAddr(r *http.Request) bool { + if a.bindIP == nil { + return true + } + addr := localAddr(r) + if addr == "" { + // No local address on the request context means we cannot tell, and + // refusing every connection is worse than the status quo ante. + return true + } + host, _, err := net.SplitHostPort(addr) + if err != nil { + host = addr + } + ip := net.ParseIP(host) + if ip == nil { + return true + } + // Equal treats an IPv4-mapped IPv6 address as the IPv4 it maps to, which + // is how a dual-stack listener reports an IPv4 connection. + return a.bindIP.Equal(ip) +} + +// localAddr is the address on this host that the connection arrived at, which +// net/http puts on every request's context. +func localAddr(r *http.Request) string { + if r == nil { + return "" + } + v := r.Context().Value(http.LocalAddrContextKey) + addr, ok := v.(net.Addr) + if !ok || addr == nil { + return "" + } + return addr.String() +} diff --git a/go/internal/ocpp/auth_test.go b/go/internal/ocpp/auth_test.go new file mode 100644 index 000000000..b09bc4a6a --- /dev/null +++ b/go/internal/ocpp/auth_test.go @@ -0,0 +1,234 @@ +package ocpp + +import ( + "context" + "fmt" + "net" + "net/http" + "net/http/httptest" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ws" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// requestFrom builds the request the library hands checkClient: basic auth in +// the header, and the local address net/http puts on every connection context. +func requestFrom(t *testing.T, user, pass, arrivedOn string) *http.Request { + t.Helper() + r := httptest.NewRequest(http.MethodGet, "/garage", nil) + if user != "" || pass != "" { + r.SetBasicAuth(user, pass) + } + if arrivedOn != "" { + addr, err := net.ResolveTCPAddr("tcp", arrivedOn) + if err != nil { + t.Fatalf("resolve %q: %v", arrivedOn, err) + } + ctx := context.WithValue(r.Context(), http.LocalAddrContextKey, addr) + r = r.WithContext(ctx) + } + return r +} + +// A charger with a credential of its own cannot be impersonated by something +// holding only the shared password. That is the whole point of the feature: +// identity is client-chosen, so authenticating has never proved which device +// is on the other end. +func TestPerChargerCredentialBlocksImpersonation(t *testing.T) { + a := newAuthorizer(&Config{ + Username: "ftw", + Password: "shared-secret", + ChargerSecrets: map[string]string{"garage": "garage-only-secret"}, + }) + + t.Run("its own credential is accepted", func(t *testing.T) { + if !a.basicAuth("garage", "garage-only-secret") { + t.Error("basic auth rejected the charger's own credential") + } + if !a.checkClient("garage", requestFrom(t, "garage", "garage-only-secret", "")) { + t.Error("checkClient rejected the charger's own credential") + } + }) + + t.Run("the shared credential no longer buys its name", func(t *testing.T) { + // Basic auth passes — the shared secret is real — and the identity + // binding is what refuses. + if !a.basicAuth("ftw", "shared-secret") { + t.Fatal("the shared credential should still authenticate") + } + if a.checkClient("garage", requestFrom(t, "ftw", "shared-secret", "")) { + t.Error("the shared password claimed a charger that has its own credential") + } + }) + + t.Run("its own password under another username is refused", func(t *testing.T) { + if a.checkClient("garage", requestFrom(t, "ftw", "garage-only-secret", "")) { + t.Error("accepted the charger's password presented under another identity") + } + }) + + t.Run("a wrong password for its own name is refused", func(t *testing.T) { + if a.basicAuth("garage", "shared-secret") { + t.Error("a charger with its own credential fell back to the shared one") + } + if a.checkClient("garage", requestFrom(t, "garage", "nope", "")) { + t.Error("accepted a wrong password") + } + }) + + t.Run("chargers without one keep using the shared credential", func(t *testing.T) { + if !a.basicAuth("ftw", "shared-secret") { + t.Error("shared credential rejected") + } + if !a.checkClient("carport", requestFrom(t, "ftw", "shared-secret", "")) { + t.Error("a charger with no credential of its own should use the shared one") + } + }) +} + +// Bind is enforced at the handshake because the socket cannot be pinned: the +// library builds its listen address from the port alone. +func TestBindAddressIsEnforcedAtTheHandshake(t *testing.T) { + tests := []struct { + name string + bind string + arrivedOn string + want bool + }{ + {name: "same address", bind: "192.168.1.10", arrivedOn: "192.168.1.10:8887", want: true}, + {name: "another interface", bind: "192.168.1.10", arrivedOn: "10.8.0.1:8887", want: false}, + {name: "loopback when bound to the LAN", bind: "192.168.1.10", arrivedOn: "127.0.0.1:8887", want: false}, + {name: "unspecified accepts anything", bind: "0.0.0.0", arrivedOn: "10.8.0.1:8887", want: true}, + {name: "empty accepts anything", bind: "", arrivedOn: "10.8.0.1:8887", want: true}, + // A dual-stack listener reports an IPv4 connection as v4-mapped v6. + {name: "v4-mapped v6 matches its v4", bind: "192.168.1.10", arrivedOn: "[::ffff:192.168.1.10]:8887", want: true}, + // Not knowing where it landed must not lock every charger out. + {name: "no local address is allowed", bind: "192.168.1.10", arrivedOn: "", want: true}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + a := newAuthorizer(&Config{Bind: tc.bind}) + got := a.checkClient("garage", requestFrom(t, "", "", tc.arrivedOn)) + if got != tc.want { + t.Errorf("checkClient on %s with bind %q: got %v, want %v", + tc.arrivedOn, tc.bind, got, tc.want) + } + }) + } +} + +// With no credentials configured the basic-auth handler must stay unregistered +// — the library answers 401 to a charger that sends none whenever a handler +// exists, so registering it would lock out every charger instead of admitting +// them all. +func TestNoCredentialsMeansNoBasicAuthHandler(t *testing.T) { + if newAuthorizer(&Config{}).requiresCredential() { + t.Error("an OCPP section with no credentials should not demand one") + } + if !newAuthorizer(&Config{Username: "ftw", Password: "x"}).requiresCredential() { + t.Error("a shared credential should be demanded") + } + if !newAuthorizer(&Config{ChargerSecrets: map[string]string{"garage": "x"}}).requiresCredential() { + t.Error("a per-charger credential should be demanded") + } +} + +// TLS has to fail loudly. An operator who asked for wss:// and silently got +// ws:// would have no way to tell the link was never encrypted. +func TestTLSMisconfigurationRefusesToStart(t *testing.T) { + tests := []struct { + name string + tls *TLSConfig + }{ + {name: "cert without key", tls: &TLSConfig{CertFile: "cert.pem"}}, + {name: "key without cert", tls: &TLSConfig{KeyFile: "key.pem"}}, + {name: "cert file missing", tls: &TLSConfig{CertFile: "no-such-cert.pem", KeyFile: "no-such-key.pem"}}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + cfg := &Config{Enabled: true, Bind: "127.0.0.1", Port: freePort(t), TLS: tc.tls} + if _, err := Start(context.Background(), cfg, telemetry.NewStore()); err == nil { + t.Fatal("a broken TLS config started anyway, serving plaintext") + } + }) + } +} + +func TestSchemeFollowsTLS(t *testing.T) { + if got := (&Config{}).Scheme(); got != "ws" { + t.Errorf("plaintext scheme: got %q, want ws", got) + } + cfg := &Config{TLS: &TLSConfig{CertFile: "c.pem", KeyFile: "k.pem"}} + if got := cfg.Scheme(); got != "wss" { + t.Errorf("TLS scheme: got %q, want wss", got) + } +} + +// End to end over a real connection: the library must actually consult both +// gates, in the order that makes the identity binding effective. +func TestPerChargerCredentialOverTheWire(t *testing.T) { + port := freePort(t) + cfg := &Config{ + Enabled: true, + Bind: "127.0.0.1", + Port: port, + HeartbeatIntervalS: 60, + Username: "ftw", + Password: "shared-secret", + ChargerSecrets: map[string]string{"garage": "garage-only-secret"}, + ApprovedIDs: []string{"garage"}, + } + srv, err := Start(context.Background(), cfg, telemetry.NewStore()) + if err != nil { + t.Fatalf("start: %v", err) + } + t.Cleanup(srv.Stop) + waitForListener(t, port) + + connect := func(t *testing.T, id, user, pass string) error { + t.Helper() + client := ws.NewClient() + client.SetBasicAuth(user, pass) + cp := ocpp16.NewChargePoint(id, nil, client) + err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)) + if err == nil { + t.Cleanup(cp.Stop) + } + return err + } + + if err := connect(t, "garage", "garage", "garage-only-secret"); err != nil { + t.Fatalf("the charger's own credential was refused: %v", err) + } + if err := connect(t, "garage-impostor", "ftw", "shared-secret"); err != nil { + t.Fatalf("a charger without its own credential should still connect: %v", err) + } + // The gate this test exists for. It also catches the library detail that + // makes it fragile: ocppj.Server.Start replaces the connection check the + // ws.Server was given, so a gate registered on the raw server is silently + // discarded and every impersonation attempt succeeds. + if err := connect(t, "garage", "ftw", "shared-secret"); err == nil { + t.Fatal("the shared password connected as a charger that has its own credential") + } +} + +// waitForListener blocks until the port accepts, so a client never races the +// listener goroutine. +func waitForListener(t *testing.T, port int) { + t.Helper() + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + c, err := net.DialTimeout("tcp", fmt.Sprintf("127.0.0.1:%d", port), 100*time.Millisecond) + if err == nil { + c.Close() + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server did not bind on port %d within deadline", port) +} diff --git a/go/internal/ocpp/charging_needs.go b/go/internal/ocpp/charging_needs.go new file mode 100644 index 000000000..3a2a76dd7 --- /dev/null +++ b/go/internal/ocpp/charging_needs.go @@ -0,0 +1,235 @@ +package ocpp + +// NotifyEVChargingNeeds — what the car asked for. +// +// During an ISO 15118 charge-parameter discovery the EV states its own needs, +// and the charging station forwards them to us as NotifyEVChargingNeeds. This +// is the vehicle speaking rather than the charger or the operator: the energy +// it wants, when it expects to leave, and on DC also its battery capacity and +// present state of charge. +// +// That outranks configuration. Both `vehicle_capacity_wh` and a vehicle +// profile are an operator's estimate of the car that usually parks here; this +// is the car actually plugged in, for this session. Where the message carries +// a figure it replaces the configured one for the session and reverts on +// plug-out, exactly like an identified vehicle profile. +// +// Quarantine applies as everywhere else: a pending charge point's needs are +// recorded and visible in the API so an operator can see what asked, but the +// callback that reaches a loadpoint never fires for it. +// +// Units follow the core convention — energy in Wh, SoC as a 0-1 fraction. The +// wire carries Wh and whole percent, converted here at the boundary. + +import ( + "log/slog" + "time" + + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + + "github.com/srcfl/ftw/go/internal/units" +) + +// ChargingNeeds is one NotifyEVChargingNeeds report in core units. +// +// Only TransferMode is mandatory on the wire, so a zero field means "the car +// did not say", never "zero". The two SoC fields are pointers because there a +// genuine zero is meaningful — an empty battery is a real state of charge. +type ChargingNeeds struct { + // TransferMode is the energy transfer the car asked for: + // AC_single_phase, AC_two_phase, AC_three_phase or DC. + TransferMode string `json:"transfer_mode,omitempty"` + + // EnergyWh is the energy requested, including preconditioning. + EnergyWh float64 `json:"energy_wh,omitempty"` + + // DepartureTime is when the EV estimates it will leave. Zero when the + // car did not say, which is the common case on AC. + DepartureTime time.Time `json:"departure_time,omitempty"` + + // PresentSoC and FullSoC are 0-1 fractions: where the battery is now, + // and where the car considers it full. DC sessions only — the AC + // parameter set has no SoC at all. + PresentSoC *float64 `json:"present_soc,omitempty"` + FullSoC *float64 `json:"full_soc,omitempty"` + + // CapacityWh is the car's own battery capacity. DC sessions only. + CapacityWh float64 `json:"capacity_wh,omitempty"` + + // MaxCurrentA, MaxVoltageV and MaxPowerW are the car's own ceilings. + // They are recorded but never used to raise a limit — control clamps + // down from the loadpoint's rating, never up from the car's claim. + MaxCurrentA float64 `json:"max_current_a,omitempty"` + MaxVoltageV float64 `json:"max_voltage_v,omitempty"` + MaxPowerW float64 `json:"max_power_w,omitempty"` + + // EVSEID is the EVSE the needs apply to. A home charger has one. + EVSEID int `json:"evse_id,omitempty"` + + // ReceivedAt is when we took the report, so a stale departure time is + // recognisable as stale rather than read as current intent. + ReceivedAt time.Time `json:"received_at,omitempty"` +} + +// TargetSoC derives the state of charge this session should aim for: where the +// battery is now, plus the energy the car asked for, over its own capacity. +// +// Reports false unless the car gave all three. An AC session states energy +// alone, and energy alone says nothing about a fraction of a battery whose +// size is unknown — guessing one there would feed the planner a number the car +// never claimed. Capped at FullSoC when the car named one, otherwise at 1. +func (n ChargingNeeds) TargetSoC() (float64, bool) { + if n.PresentSoC == nil || n.CapacityWh <= 0 || n.EnergyWh <= 0 { + return 0, false + } + ceiling := 1.0 + if n.FullSoC != nil && *n.FullSoC > 0 { + ceiling = *n.FullSoC + } + target := *n.PresentSoC + n.EnergyWh/n.CapacityWh + if target > ceiling { + target = ceiling + } + return units.ClampFraction(target), true +} + +// chargingNeedsFrom converts a wire request into core units. +func chargingNeedsFrom(req *smartcharging.NotifyEVChargingNeedsRequest, now time.Time) ChargingNeeds { + n := ChargingNeeds{ + TransferMode: string(req.ChargingNeeds.RequestedEnergyTransfer), + EVSEID: req.EvseID, + ReceivedAt: now, + } + if dt := req.ChargingNeeds.DepartureTime; dt != nil { + n.DepartureTime = dt.Time + } + if ac := req.ChargingNeeds.ACChargingParameters; ac != nil { + n.EnergyWh = float64(ac.EnergyAmount) + n.MaxCurrentA = float64(ac.EVMaxCurrent) + n.MaxVoltageV = float64(ac.EVMaxVoltage) + } + if dc := req.ChargingNeeds.DCChargingParameters; dc != nil { + n.MaxCurrentA = float64(dc.EVMaxCurrent) + n.MaxVoltageV = float64(dc.EVMaxVoltage) + if dc.EnergyAmount != nil { + n.EnergyWh = float64(*dc.EnergyAmount) + } + if dc.EVMaxPower != nil { + n.MaxPowerW = float64(*dc.EVMaxPower) + } + if dc.EVEnergyCapacity != nil { + n.CapacityWh = float64(*dc.EVEnergyCapacity) + } + if dc.StateOfCharge != nil { + f := units.ClampFraction(float64(*dc.StateOfCharge) / 100.0) + n.PresentSoC = &f + } + if dc.FullSoC != nil { + f := units.ClampFraction(float64(*dc.FullSoC) / 100.0) + n.FullSoC = &f + } + } + return n +} + +// noteChargingNeeds records a report and, for an adopted charger, hands it to +// the loadpoint layer. +// +// Unlike a vehicle identity this fires on every report rather than only on +// change: the car is allowed to revise what it wants mid-session — a departure +// time moves, a preconditioning estimate is refined — and the latest statement +// is the one the planner should size on. +func (h *Handler) noteChargingNeeds(id string, n ChargingNeeds) { + h.mu.Lock() + s := h.chargersLocked(id) + s.needs = &n + fn := h.chargingNeeds + approved := h.approved[id] + h.mu.Unlock() + if approved && fn != nil { + fn(id, n) + } +} + +// SetChargingNeeds registers the callback fired when an adopted charger +// reports what its car asked for. Wired by main.go to the loadpoint bound to +// that charger. Pending chargers never reach it. +func (h *Handler) SetChargingNeeds(fn func(chargerID string, needs ChargingNeeds)) { + if h == nil { + return + } + h.mu.Lock() + h.chargingNeeds = fn + h.mu.Unlock() +} + +// ChargingNeeds returns the last report from a charger, if any. +func (h *Handler) ChargingNeeds(id string) (ChargingNeeds, bool) { + if h == nil { + return ChargingNeeds{}, false + } + h.mu.Lock() + defer h.mu.Unlock() + s, ok := h.chargers[id] + if !ok || s.needs == nil { + return ChargingNeeds{}, false + } + return *s.needs, true +} + +// ---- smartcharging.CSMSHandler ---- + +// OnNotifyEVChargingNeeds takes the car's stated needs. +// +// Accepted means we processed the message, not that we can meet the request — +// the spec is explicit about that, and the schedule we send back is whatever +// the planner works out on its own tick. +func (h *handlerV201) OnNotifyEVChargingNeeds(id string, req *smartcharging.NotifyEVChargingNeedsRequest) (*smartcharging.NotifyEVChargingNeedsResponse, error) { + if req == nil { + return smartcharging.NewNotifyEVChargingNeedsResponse(smartcharging.EVChargingNeedsStatusRejected), nil + } + n := chargingNeedsFrom(req, time.Now()) + + slog.Info("OCPP charging needs", + "charger", id, "version", Version201, + "evse", n.EVSEID, "mode", n.TransferMode, + "energy_wh", n.EnergyWh, "capacity_wh", n.CapacityWh, + "departure", n.DepartureTime) + + h.noteChargingNeeds(id, n) + h.telSuccess(id) + return smartcharging.NewNotifyEVChargingNeedsResponse(smartcharging.EVChargingNeedsStatusAccepted), nil +} + +// OnNotifyEVChargingSchedule carries the schedule the EV worked out for +// itself. FTW plans centrally against site power, price and PV, so this is +// acknowledged and dropped — accepting it costs nothing and refusing would +// make the charger retry forever. +func (h *handlerV201) OnNotifyEVChargingSchedule(id string, _ *smartcharging.NotifyEVChargingScheduleRequest) (*smartcharging.NotifyEVChargingScheduleResponse, error) { + h.telSuccess(id) + return smartcharging.NewNotifyEVChargingScheduleResponse(types201.GenericStatusAccepted), nil +} + +// OnNotifyChargingLimit reports a limit imposed by something other than us — +// a local load-management box. Acknowledged and dropped: the charger enforces +// it whatever we send, and our own commands are already clamped below the +// loadpoint's rating. +func (h *handlerV201) OnNotifyChargingLimit(id string, _ *smartcharging.NotifyChargingLimitRequest) (*smartcharging.NotifyChargingLimitResponse, error) { + h.telSuccess(id) + return smartcharging.NewNotifyChargingLimitResponse(), nil +} + +// OnClearedChargingLimit is the other half of that: the external limit is +// gone. Acknowledged and dropped for the same reason. +func (h *handlerV201) OnClearedChargingLimit(id string, _ *smartcharging.ClearedChargingLimitRequest) (*smartcharging.ClearedChargingLimitResponse, error) { + h.telSuccess(id) + return smartcharging.NewClearedChargingLimitResponse(), nil +} + +// OnReportChargingProfiles answers a GetChargingProfiles we never send. +// Acknowledged and dropped. +func (h *handlerV201) OnReportChargingProfiles(id string, _ *smartcharging.ReportChargingProfilesRequest) (*smartcharging.ReportChargingProfilesResponse, error) { + h.telSuccess(id) + return smartcharging.NewReportChargingProfilesResponse(), nil +} diff --git a/go/internal/ocpp/charging_needs_test.go b/go/internal/ocpp/charging_needs_test.go new file mode 100644 index 000000000..97d8d4c82 --- /dev/null +++ b/go/internal/ocpp/charging_needs_test.go @@ -0,0 +1,296 @@ +package ocpp + +import ( + "fmt" + "math" + "sync" + "testing" + "time" + + ocpp201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1" + "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/provisioning" + smartcharging201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/smartcharging" + types201 "github.com/lorenzodonini/ocpp-go/ocpp2.0.1/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +func intp(v int) *int { return &v } + +// nearly compares derived fractions, which come out of a division and so do +// not land on the exact decimal the case is written with. +func nearly(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +// connectStationForNeeds is connectStationV201 with the station itself handed +// back, so a test can send station-initiated messages rather than only receive +// the ones the CSMS pushes. +func connectStationForNeeds(t *testing.T, srv *Server, port int, id string) ocpp201.ChargingStation { + t.Helper() + cs := ocpp201.NewChargingStation(id, nil, nil) + cs.SetSmartChargingHandler(newFakeStationV201()) + + if err := cs.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("charging station connect: %v", err) + } + var once sync.Once + t.Cleanup(func() { once.Do(cs.Stop) }) + + if _, err := cs.BootNotification(provisioning.BootReasonPowerUp, "Dawn", "Charge Amps"); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if srv.Handler().IsOnline(id) { + return cs + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("server never registered station %s as online", id) + return nil +} + +// The wire speaks Wh and whole percent; core speaks Wh and 0-1 fractions. +// A DC report carries every field, so this is where a scale slip would show. +func TestChargingNeedsFromDCConvertsUnits(t *testing.T) { + departure := time.Date(2026, 8, 29, 7, 30, 0, 0, time.UTC) + req := smartcharging201.NewNotifyEVChargingNeedsRequest(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeDC, + DepartureTime: types201.NewDateTime(departure), + DCChargingParameters: &smartcharging201.DCChargingParameters{ + EVMaxCurrent: 125, + EVMaxVoltage: 400, + EnergyAmount: intp(30000), + EVMaxPower: intp(50000), + StateOfCharge: intp(20), + EVEnergyCapacity: intp(75000), + FullSoC: intp(90), + }, + }) + + now := time.Date(2026, 8, 29, 6, 0, 0, 0, time.UTC) + n := chargingNeedsFrom(req, now) + + if n.TransferMode != "DC" { + t.Errorf("transfer mode: got %q, want DC", n.TransferMode) + } + if n.EnergyWh != 30000 { + t.Errorf("energy: got %v Wh, want 30000", n.EnergyWh) + } + if n.CapacityWh != 75000 { + t.Errorf("capacity: got %v Wh, want 75000", n.CapacityWh) + } + if n.MaxPowerW != 50000 { + t.Errorf("max power: got %v W, want 50000", n.MaxPowerW) + } + if n.PresentSoC == nil || *n.PresentSoC != 0.20 { + t.Errorf("present SoC: got %v, want the 0-1 fraction 0.20", n.PresentSoC) + } + if n.FullSoC == nil || *n.FullSoC != 0.90 { + t.Errorf("full SoC: got %v, want the 0-1 fraction 0.90", n.FullSoC) + } + if !n.DepartureTime.Equal(departure) { + t.Errorf("departure: got %v, want %v", n.DepartureTime, departure) + } + if !n.ReceivedAt.Equal(now) { + t.Errorf("received at: got %v, want %v", n.ReceivedAt, now) + } + if n.EVSEID != 1 { + t.Errorf("evse: got %d, want 1", n.EVSEID) + } +} + +// AC states energy and current, and has no SoC or capacity at all. +func TestChargingNeedsFromACHasNoSoC(t *testing.T) { + req := smartcharging201.NewNotifyEVChargingNeedsRequest(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeAC3Phase, + ACChargingParameters: &smartcharging201.ACChargingParameters{ + EnergyAmount: 12000, + EVMinCurrent: 6, + EVMaxCurrent: 16, + EVMaxVoltage: 230, + }, + }) + + n := chargingNeedsFrom(req, time.Now()) + + if n.EnergyWh != 12000 { + t.Errorf("energy: got %v Wh, want 12000", n.EnergyWh) + } + if n.MaxCurrentA != 16 { + t.Errorf("max current: got %v A, want 16", n.MaxCurrentA) + } + if n.PresentSoC != nil || n.CapacityWh != 0 { + t.Errorf("AC carries no SoC or capacity, got soc=%v capacity=%v", n.PresentSoC, n.CapacityWh) + } + if _, ok := n.TargetSoC(); ok { + t.Error("derived a target SoC from energy alone — a fraction of an unknown battery") + } +} + +func TestChargingNeedsTargetSoC(t *testing.T) { + soc := func(f float64) *float64 { return &f } + + tests := []struct { + name string + needs ChargingNeeds + want float64 + ok bool + }{ + { + name: "present plus requested over capacity", + needs: ChargingNeeds{PresentSoC: soc(0.20), EnergyWh: 30000, CapacityWh: 75000}, + want: 0.60, + ok: true, + }, + { + name: "capped at the car's own full SoC", + needs: ChargingNeeds{PresentSoC: soc(0.50), EnergyWh: 60000, CapacityWh: 75000, FullSoC: soc(0.80)}, + want: 0.80, + ok: true, + }, + { + name: "capped at 1 when the car named no full SoC", + needs: ChargingNeeds{PresentSoC: soc(0.50), EnergyWh: 60000, CapacityWh: 75000}, + want: 1.0, + ok: true, + }, + { + name: "no capacity means no fraction", + needs: ChargingNeeds{PresentSoC: soc(0.20), EnergyWh: 30000}, + ok: false, + }, + { + name: "no present SoC means no fraction", + needs: ChargingNeeds{EnergyWh: 30000, CapacityWh: 75000}, + ok: false, + }, + { + name: "no energy request means nothing to add", + needs: ChargingNeeds{PresentSoC: soc(0.20), CapacityWh: 75000}, + ok: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got, ok := tc.needs.TargetSoC() + if ok != tc.ok { + t.Fatalf("ok: got %v, want %v", ok, tc.ok) + } + if ok && !nearly(got, tc.want) { + t.Errorf("target SoC: got %v, want %v", got, tc.want) + } + }) + } +} + +// Quarantine covers charging needs like everything else: a charge point no +// loadpoint names may state what it wants, and nothing acts on it. +func TestPendingChargerChargingNeedsNeverReachLoadpoint(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var fired []string + h.SetChargingNeeds(func(chargerID string, _ ChargingNeeds) { + fired = append(fired, chargerID) + }) + + needs := ChargingNeeds{TransferMode: "DC", EnergyWh: 30000} + h.noteChargingNeeds("intruder", needs) + h.noteChargingNeeds("garage", needs) + + if len(fired) != 1 || fired[0] != "garage" { + t.Fatalf("callback fired for %v, want only the adopted charger", fired) + } + // Recorded either way — the operator has to be able to see what asked. + if _, ok := h.ChargingNeeds("intruder"); !ok { + t.Error("a pending charger's needs should still be visible in the API") + } +} + +// The car may revise what it wants mid-session, so every report fires — +// unlike a vehicle identity, which fires only when it changes. +func TestChargingNeedsFireOnEveryReport(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var got []float64 + h.SetChargingNeeds(func(_ string, n ChargingNeeds) { + got = append(got, n.EnergyWh) + }) + + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 30000}) + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 30000}) + h.noteChargingNeeds("garage", ChargingNeeds{EnergyWh: 18000}) + + if len(got) != 3 { + t.Fatalf("fired %d times for 3 reports: %v", len(got), got) + } + last, ok := h.ChargingNeeds("garage") + if !ok || last.EnergyWh != 18000 { + t.Errorf("stored needs: got %v ok=%v, want the latest 18000 Wh", last.EnergyWh, ok) + } +} + +// End to end over a real 2.0.1 connection. Without SetSmartChargingHandler the +// CSMS rejects the message as unsupported, so this is what proves the handler +// is actually registered rather than merely written. +func TestV201StationReportsChargingNeeds(t *testing.T) { + _, portV201, srv := startDualServer(t, telemetry.NewStore()) + srv.Handler().SetApprovedIDs([]string{"garage-needs"}) + + applied := make(chan ChargingNeeds, 1) + srv.Handler().SetChargingNeeds(func(_ string, n ChargingNeeds) { + select { + case applied <- n: + default: + } + }) + + station := connectStationForNeeds(t, srv, portV201, "garage-needs") + + departure := time.Now().Add(8 * time.Hour).UTC().Truncate(time.Second) + resp, err := station.NotifyEVChargingNeeds(1, smartcharging201.ChargingNeeds{ + RequestedEnergyTransfer: smartcharging201.EnergyTransferModeDC, + DepartureTime: types201.NewDateTime(departure), + DCChargingParameters: &smartcharging201.DCChargingParameters{ + EVMaxCurrent: 125, + EVMaxVoltage: 400, + EnergyAmount: intp(30000), + StateOfCharge: intp(20), + EVEnergyCapacity: intp(75000), + }, + }) + if err != nil { + t.Fatalf("NotifyEVChargingNeeds: %v", err) + } + if resp.Status != smartcharging201.EVChargingNeedsStatusAccepted { + t.Fatalf("status: got %v, want Accepted", resp.Status) + } + + select { + case n := <-applied: + if n.CapacityWh != 75000 { + t.Errorf("capacity: got %v Wh, want 75000", n.CapacityWh) + } + target, ok := n.TargetSoC() + if !ok || !nearly(target, 0.60) { + t.Errorf("target SoC: got %v ok=%v, want 0.60", target, ok) + } + if !n.DepartureTime.Equal(departure) { + t.Errorf("departure: got %v, want %v", n.DepartureTime, departure) + } + case <-time.After(2 * time.Second): + t.Fatal("charging needs never reached the loadpoint callback") + } + + // And it is visible in the snapshot the Chargers panel renders. + view, ok := srv.Handler().Snapshot()["garage-needs"] + if !ok || view.ChargingNeeds == nil { + t.Fatal("snapshot carried no charging needs") + } + if view.ChargingNeeds.EnergyWh != 30000 { + t.Errorf("snapshot energy: got %v Wh, want 30000", view.ChargingNeeds.EnergyWh) + } +} diff --git a/go/internal/ocpp/config.go b/go/internal/ocpp/config.go index 6c914d334..7324a8a12 100644 --- a/go/internal/ocpp/config.go +++ b/go/internal/ocpp/config.go @@ -1,19 +1,32 @@ 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. +import ( + "crypto/tls" + "crypto/x509" + "errors" + "fmt" + "os" +) + +// Config controls the OCPP Central System. // -// 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. +// Charge points connect to ws://:/, or wss:// when TLS +// is configured. When a loadpoint names that chargerId (see ApprovedIDs) it +// becomes the driver name in telemetry.Store and shows up in /api/devices and +// /api/status.drivers; otherwise the charger stays pending and appears only in +// /api/ocpp/chargers. type Config struct { - Enabled bool `yaml:"enabled"` - Bind string `yaml:"bind"` + Enabled bool `yaml:"enabled"` + + // Bind is the address the listener is offered on. The OCPP library + // builds its own listen address from the port alone, so the socket is + // unavoidably open on every interface; what this does is refuse the + // WebSocket handshake for any connection that arrived somewhere else + // (see authorizer.checkClient). That is an access control, not a + // smaller attack surface — a port scan still finds the port. Empty or + // unspecified (0.0.0.0 / ::) accepts every interface. + Bind string `yaml:"bind"` + Port int `yaml:"port"` PortV201 int `yaml:"port_v201"` Path string `yaml:"path"` @@ -21,6 +34,19 @@ type Config struct { Password string `yaml:"password"` HeartbeatIntervalS int `yaml:"heartbeat_interval_s"` + // TLS, when set, serves wss:// instead of ws://. Optional client + // certificate verification implements OCPP 2.0.1 security profile 3, + // where the certificate — not a shared password — is what identifies a + // charge point. + TLS *TLSConfig `yaml:"tls"` + + // ChargerSecrets maps a charge point identity to a password of its own. + // A charger listed here must present that password under that identity, + // so the shared password no longer buys an attacker its name. Derived by + // the caller from the config's per-charger entries, never read from YAML + // here. + ChargerSecrets map[string]string `yaml:"-"` + // ApprovedIDs is the set of charger identities that are part of the site // — the ids loadpoints name in config. Derived by the caller, never read // from YAML. A charger connecting under any other identity is accepted at @@ -30,6 +56,67 @@ type Config struct { ApprovedIDs []string `yaml:"-"` } +// TLSConfig points at the certificate this server presents, and optionally at +// the CA that signs the charge points allowed to connect. +type TLSConfig struct { + CertFile string `yaml:"cert_file"` + KeyFile string `yaml:"key_file"` + + // ClientCAFile, when set, requires every charge point to present a + // certificate signed by this CA and rejects the handshake otherwise. + // That is a far stronger identity than a shared password: it cannot be + // copied out of one charger's config and replayed by another device + // unless its private key was copied too. + ClientCAFile string `yaml:"client_ca_file"` +} + +// Enabled reports whether TLS is fully configured. Both files are required — +// a certificate without its key cannot serve. +func (t *TLSConfig) Enabled() bool { + return t != nil && t.CertFile != "" && t.KeyFile != "" +} + +// configured reports whether the operator asked for TLS at all, however +// incompletely. Enabled answers "can we serve it"; this answers "were we meant +// to", and the gap between the two is a misconfiguration that must fail loudly +// rather than quietly fall back to ws://. +func (t *TLSConfig) configured() bool { + return t != nil && (t.CertFile != "" || t.KeyFile != "" || t.ClientCAFile != "") +} + +// serverTLS builds the tls.Config for the listener, reading the client CA from +// disk when one is configured. +// +// Returns an error rather than falling back to plaintext: an operator who +// asked for TLS and got ws:// because a path was wrong would have no way to +// tell, and would believe the link was encrypted. +func (t *TLSConfig) serverTLS() (*tls.Config, error) { + if !t.Enabled() { + return nil, errors.New("ocpp: tls needs both cert_file and key_file") + } + if _, err := os.Stat(t.CertFile); err != nil { + return nil, fmt.Errorf("ocpp: tls cert_file: %w", err) + } + if _, err := os.Stat(t.KeyFile); err != nil { + return nil, fmt.Errorf("ocpp: tls key_file: %w", err) + } + cfg := &tls.Config{MinVersion: tls.VersionTLS12} + if t.ClientCAFile == "" { + return cfg, nil + } + pem, err := os.ReadFile(t.ClientCAFile) + if err != nil { + return nil, fmt.Errorf("ocpp: tls client_ca_file: %w", err) + } + pool := x509.NewCertPool() + if !pool.AppendCertsFromPEM(pem) { + return nil, fmt.Errorf("ocpp: tls client_ca_file %s contains no certificate", t.ClientCAFile) + } + cfg.ClientAuth = tls.RequireAndVerifyClientCert + cfg.ClientCAs = pool + return cfg, nil +} + // Defaults fills in any unset fields with safe values. func (c *Config) Defaults() { if c.Bind == "" { @@ -45,3 +132,11 @@ func (c *Config) Defaults() { c.HeartbeatIntervalS = 60 } } + +// Scheme is the URL scheme charge points must dial, for logs and the UI. +func (c *Config) Scheme() string { + if c != nil && c.TLS.Enabled() { + return "wss" + } + return "ws" +} diff --git a/go/internal/ocpp/control.go b/go/internal/ocpp/control.go index 1e04937ae..2e39ae3c9 100644 --- a/go/internal/ocpp/control.go +++ b/go/internal/ocpp/control.go @@ -58,6 +58,13 @@ const ( // The 2.0.1 equivalent: EVSE 0 addresses the whole charging station. allEVSEs = 0 + // Where a limit goes when the charger refuses a charge-point-wide one. + // OCPP 1.6 permits a TxDefaultProfile on connector 0 — it is how a + // profile is applied to every connector — but some chargers read the + // connector-0 rule as ChargePointMaxProfile-only and reject it. On a + // single-socket unit connector 1 means the same thing. + firstConnector = 1 + // A single, stable profile id and stack level means each new limit // replaces the previous one instead of stacking on top of it. ftwProfileID = 1 @@ -68,6 +75,26 @@ const ( scheduleStartS = 0 ) +// The profile is Relative, not Absolute. +// +// FTW's schedule has one period at second 0 and no end: "hold this limit until +// I send another". Absolute expresses that only with a startSchedule +// timestamp, and the specification says an absolute schedule with none is +// relative to the start of charging anyway — so the two spellings mean the +// same thing here, and only one of them can be misread. +// +// It is misread in practice. A charger that parses the missing timestamp +// strictly finds no valid start, treats the profile as not yet active, and +// answers Accepted while charging on at full rate. That is the worst failure +// this layer has: FTW logs a limit it never imposed, and the planner counts +// energy the site is not saving. Relative needs no timestamp, so there is +// nothing to misparse — and it does not depend on the charger's clock +// agreeing with ours, which on EV chargers is not a safe assumption. +const ( + profileKind16 = types.ChargingProfileKindRelative + profileKind201 = types201.ChargingProfileKindRelative +) + // command is the JSON payload the loadpoint controller sends to EV drivers. // Only the fields that affect a current limit are read here. type command struct { @@ -184,6 +211,53 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh amps = 0 } + r, err := s.attemptLimit(ctx, id, amps, numberPhases, allConnectors) + if err != nil { + return err + } + // A charger that answers Rejected to a charge-point-wide profile usually + // reads OCPP 1.6 as allowing connector 0 for ChargePointMaxProfile alone. + // The specification does permit a TxDefaultProfile there — it is how a + // profile is applied to every connector — but a charger that disagrees + // otherwise accepts no limit at all, which is a charger FTW meters and + // cannot steer. Retrying on the first connector costs one message and + // covers the single-socket units this matters for. + if r.answered && !r.accepted { + slog.Info("ocpp: charger refused a charge-point-wide profile, retrying on connector 1", + "charger", id, "status", r.status) + retry, retryErr := s.attemptLimit(ctx, id, amps, numberPhases, firstConnector) + if retryErr != nil { + return retryErr + } + r = retry + } + + if r.err != nil { + return fmt.Errorf("ocpp: %s rejected charging profile: %w", id, r.err) + } + if !r.answered { + return fmt.Errorf("ocpp: %s returned no charging profile confirmation", id) + } + if !r.accepted { + return fmt.Errorf("ocpp: %s answered %s to charging profile", id, r.status) + } + // Only a real charging rate is worth remembering. Recording the zero + // from a pause would erase the rate a later resume is supposed to + // restore, and the charger would come back at the fallback ceiling + // instead of where it left off. + if amps > 0 { + s.handler.SetLastAmps(id, amps) + } + slog.Info("ocpp: charging limit applied", "charger", id, "amps", amps) + return nil +} + +// attemptLimit sends one charging profile and waits for the charger's answer. +// +// The returned profileResult carries the charger's verdict, including a +// refusal; the error is reserved for the cases where no verdict exists — +// transport failure, cancellation, silence. +func (s *Server) attemptLimit(ctx context.Context, id string, amps float64, numberPhases *int, connectorID int) (profileResult, error) { // Buffered: the library's callback must never block if we have already // stopped waiting. done := make(chan profileResult, 1) @@ -193,12 +267,12 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh var err error switch version, _ := s.handler.Version(id); version { case Version201: - err = s.sendProfileV201(id, amps, numberPhases, done) + err = s.sendProfileV201(id, amps, numberPhases, connectorID, done) default: - err = s.sendProfileV16(id, amps, numberPhases, done) + err = s.sendProfileV16(id, amps, numberPhases, connectorID, done) } if err != nil { - return fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) + return profileResult{}, fmt.Errorf("ocpp: send charging profile to %s: %w", id, err) } timeout := time.NewTimer(commandTimeout) @@ -206,30 +280,11 @@ func (s *Server) setLimit(ctx context.Context, id string, amps float64, numberPh 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 - + return r, nil case <-ctx.Done(): - return fmt.Errorf("ocpp: charging profile for %s cancelled: %w", id, ctx.Err()) - + return profileResult{}, 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) + return profileResult{}, fmt.Errorf("ocpp: %s did not confirm charging profile within %s", id, commandTimeout) } } @@ -243,7 +298,7 @@ type profileResult struct { } // sendProfileV16 issues the limit as an OCPP 1.6 TxDefaultProfile. -func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done chan<- profileResult) error { +func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, connectorID int, done chan<- profileResult) error { period := types.NewChargingSchedulePeriod(scheduleStartS, amps) // Declared only when the loadpoint pinned single-phase charging. Left // unset otherwise so a charger that can switch phases keeps deciding. @@ -253,7 +308,7 @@ func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done ftwProfileID, ftwStackLevel, types.ChargingProfilePurposeTxDefaultProfile, - types.ChargingProfileKindAbsolute, + profileKind16, schedule, ) @@ -265,7 +320,7 @@ func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done r.accepted = conf.Status == smartcharging.ChargingProfileStatusAccepted } done <- r - }, allConnectors, profile) + }, connectorID, profile) } // sendProfileV201 issues the same limit as an OCPP 2.0.1 TxDefaultProfile. @@ -273,7 +328,7 @@ func (s *Server) sendProfileV16(id string, amps float64, numberPhases *int, done // 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 { +func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, evseID int, done chan<- profileResult) error { if s.csms == nil { return fmt.Errorf("ocpp: %s speaks %s but no %s listener is configured", id, Version201, Version201) } @@ -285,7 +340,7 @@ func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, don ftwProfileID, ftwStackLevel, types201.ChargingProfilePurposeTxDefaultProfile, - types201.ChargingProfileKindAbsolute, + profileKind201, []types201.ChargingSchedule{*schedule}, ) @@ -297,7 +352,7 @@ func (s *Server) sendProfileV201(id string, amps float64, numberPhases *int, don r.accepted = conf.Status == smartcharging201.ChargingProfileStatusAccepted } done <- r - }, allEVSEs, profile) + }, evseID, profile) } // DefaultMode is what a charger is left in when FTW stops steering it, and diff --git a/go/internal/ocpp/control_interop_test.go b/go/internal/ocpp/control_interop_test.go new file mode 100644 index 000000000..32845ee92 --- /dev/null +++ b/go/internal/ocpp/control_interop_test.go @@ -0,0 +1,114 @@ +package ocpp + +import ( + "context" + "testing" + + "github.com/lorenzodonini/ocpp-go/ocpp1.6/smartcharging" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/types" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// Both cases here were found by running the branch against Sourceful's device +// simulator rather than by reading the specification, which is why they are +// pinned: each one let FTW believe it had imposed a limit it had not. + +// setCurrent11kW is the ordinary dispatch command, at a rate the fixtures' +// chargers accept. +func setCurrent11kW(t *testing.T) []byte { + t.Helper() + return mustPayload(t, map[string]any{ + "action": "ev_set_current", + "power_w": 11040.0, + "voltage": 230.0, + "site_phases": 3, + }) +} + +// The profile must not be Absolute. +// +// FTW's schedule has one period at second 0 and no end. Absolute expresses +// that only with a startSchedule timestamp; the specification says an absolute +// schedule without one is relative to the start of charging anyway, so the two +// spellings mean the same thing — but a charger that parses the missing +// timestamp strictly finds no valid start, treats the profile as not yet +// active, and answers Accepted while charging on at full rate. +func TestChargingProfileIsRelativeSoItCannotBeReadAsNotYetStarted(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err != nil { + t.Fatalf("set current: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) == 0 { + t.Fatal("charger received no charging profile") + } + got := fake.profiles[len(fake.profiles)-1].ChargingProfile + if got.ChargingProfileKind != types.ChargingProfileKindRelative { + t.Errorf("profile kind: got %v, want Relative — Absolute needs a startSchedule FTW does not send", + got.ChargingProfileKind) + } + if got.ChargingSchedule != nil && got.ChargingSchedule.StartSchedule != nil { + t.Error("a relative schedule must carry no startSchedule") + } +} + +// OCPP 1.6 permits a TxDefaultProfile on connector 0 — it is how a profile is +// applied to every connector — but some chargers read the connector-0 rule as +// ChargePointMaxProfile-only and refuse it. Refusing means no limit at all, so +// FTW retries on the first connector rather than leaving the charger unsteered. +func TestChargerRefusingConnectorZeroIsRetriedOnConnectorOne(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + fake.mu.Lock() + fake.statusFn = func(req *smartcharging.SetChargingProfileRequest) smartcharging.ChargingProfileStatus { + if req.ConnectorId == 0 { + return smartcharging.ChargingProfileStatusRejected + } + return smartcharging.ChargingProfileStatusAccepted + } + fake.mu.Unlock() + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err != nil { + t.Fatalf("a charger that only accepts a real connector was left unsteered: %v", err) + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) != 2 { + t.Fatalf("got %d profiles, want the charge-point-wide try then the connector retry", len(fake.profiles)) + } + if fake.profiles[0].ConnectorId != 0 { + t.Errorf("first attempt went to connector %d, want the charge-point-wide 0", fake.profiles[0].ConnectorId) + } + if fake.profiles[1].ConnectorId != 1 { + t.Errorf("retry went to connector %d, want 1", fake.profiles[1].ConnectorId) + } +} + +// A charger that refuses both is a charger FTW cannot steer, and that has to +// surface as an error rather than a silent success. +func TestChargerRefusingEveryConnectorFails(t *testing.T) { + port, srv := startServer(t, telemetry.NewStore(), "garage") + t.Cleanup(srv.Stop) + _, fake, _ := connectCharger(t, srv, port, "garage") + + fake.setStatus(smartcharging.ChargingProfileStatusRejected) + + if err := srv.Command(context.Background(), "garage", setCurrent11kW(t)); err == nil { + t.Fatal("a charger refusing every connector reported success") + } + + fake.mu.Lock() + defer fake.mu.Unlock() + if len(fake.profiles) != 2 { + t.Errorf("got %d attempts, want exactly one retry and no more", len(fake.profiles)) + } +} diff --git a/go/internal/ocpp/control_test.go b/go/internal/ocpp/control_test.go index ee69cfedb..03d746e4f 100644 --- a/go/internal/ocpp/control_test.go +++ b/go/internal/ocpp/control_test.go @@ -22,6 +22,9 @@ type fakeCharger struct { mu sync.Mutex status smartcharging.ChargingProfileStatus profiles []*smartcharging.SetChargingProfileRequest + // statusFn, when set, decides per request instead of status — for + // chargers that accept some profiles and refuse others. + statusFn func(*smartcharging.SetChargingProfileRequest) smartcharging.ChargingProfileStatus } func newFakeCharger() *fakeCharger { @@ -32,6 +35,9 @@ func (f *fakeCharger) OnSetChargingProfile(req *smartcharging.SetChargingProfile f.mu.Lock() f.profiles = append(f.profiles, req) status := f.status + if f.statusFn != nil { + status = f.statusFn(req) + } f.mu.Unlock() return smartcharging.NewSetChargingProfileConfirmation(status), nil } diff --git a/go/internal/ocpp/handlers.go b/go/internal/ocpp/handlers.go index d615a43b6..2a920518e 100644 --- a/go/internal/ocpp/handlers.go +++ b/go/internal/ocpp/handlers.go @@ -37,6 +37,17 @@ type Handler struct { // Pending chargers never fire it: quarantine means no influence. vehicleIdentified func(chargerID, vehicleID, source string) + // chargingNeeds, when set, fires on every NotifyEVChargingNeeds an + // APPROVED charger reports — main.go uses it to size the session on + // what the car asked for (see charging_needs.go). Pending chargers + // never fire it, for the same reason they never fire the one above. + chargingNeeds func(chargerID string, needs ChargingNeeds) + + // identityReported, when set, fires when an APPROVED charger says what + // it is (BootNotification) — main.go writes its row in the device + // registry from it. See identity.go. + identityReported func(ChargerIdentity) + // capabilityProbe, set by the Server, asks a charger which feature // profiles it supports (see capabilities.go). Fired from OnConnect and // OnBootNotification until the charger answers, then never again. @@ -72,8 +83,13 @@ type chargerState struct { 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 + // serial is the hardware-stable half of its identity — what the device + // row is keyed on, when the charger reports one at all. firmware is + // shown next to them and is otherwise unused. + vendor string + model string + serial string + firmware string // vehicleID is the identity presented when the current/last transaction // started: the RFID idTag on 1.6, or a 2.0.1 idToken — where the token // type MacAddress (autocharge) or eMAID (ISO 15118) names the actual @@ -88,6 +104,10 @@ type chargerState struct { // answered, false = telemetry only, true = accepts charging profiles. featureProfiles string steerable *bool + // needs is the last NotifyEVChargingNeeds this charger's car reported + // (2.0.1 only), nil until one arrives. Kept after the session ends so + // the UI can show what the last car asked for. + needs *ChargingNeeds } // NewHandler returns a Handler ready to register with a CentralSystem. @@ -269,6 +289,8 @@ func (h *Handler) Snapshot() map[string]ChargerView { LastAmps: s.lastAmps, Vendor: s.vendor, Model: s.model, + Serial: s.serial, + Firmware: s.firmware, Pending: !h.approved[id], VehicleID: s.vehicleID, VehicleIDSource: s.vehicleIDSource, @@ -278,6 +300,10 @@ func (h *Handler) Snapshot() map[string]ChargerView { b := *s.steerable v.Steerable = &b } + if s.needs != nil { + n := *s.needs + v.ChargingNeeds = &n + } out[id] = v } return out @@ -300,6 +326,11 @@ type ChargerView struct { LastAmps float64 `json:"last_amps,omitempty"` Vendor string `json:"vendor,omitempty"` Model string `json:"model,omitempty"` + // Serial is the hardware-stable half of the charger's identity, and + // what its /api/devices row is keyed on. Empty when the charger + // reports none — plenty do not. + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` // Pending is set when no charger entry (loadpoint) names this id. A // pending charger is visible here but quarantined from the site: its // telemetry is withheld from dispatch until an operator adopts it. @@ -315,6 +346,10 @@ type ChargerView struct { // profiles. FeatureProfiles string `json:"feature_profiles,omitempty"` Steerable *bool `json:"steerable,omitempty"` + // ChargingNeeds is the last thing the car itself asked for, when it + // said (2.0.1 NotifyEVChargingNeeds). Absent on 1.6, which has no such + // message, and on any session the EV did not negotiate. + ChargingNeeds *ChargingNeeds `json:"charging_needs,omitempty"` } // OnConnect / OnDisconnect are wired by the Server to the OCPP library's @@ -355,16 +390,28 @@ func (h *Handler) OnDisconnect(id string) { // ---- core.CentralSystemHandler ---- func (h *Handler) OnBootNotification(id string, req *core.BootNotificationRequest) (*core.BootNotificationConfirmation, error) { + // 1.6 has two serial fields. chargePointSerialNumber is the current + // one; chargeBoxSerialNumber is deprecated in the spec and still what + // a good deal of shipped firmware fills in, so fall back to it rather + // than lose the only hardware-stable identity a charger will give us. + serial := req.ChargePointSerialNumber + if serial == "" { + serial = req.ChargeBoxSerialNumber + } slog.Info("OCPP boot", "charger", id, "vendor", req.ChargePointVendor, "model", req.ChargePointModel, + "serial", serial, "fw", req.FirmwareVersion) s := h.state(id) h.mu.Lock() s.vendor = req.ChargePointVendor s.model = req.ChargePointModel + s.serial = serial + s.firmware = req.FirmwareVersion h.mu.Unlock() + h.noteIdentity(id) h.telSuccess(id) h.maybeProbeCapability(id) return core.NewBootNotificationConfirmation( diff --git a/go/internal/ocpp/handlers_v201.go b/go/internal/ocpp/handlers_v201.go index bea9b280d..5b9e0520f 100644 --- a/go/internal/ocpp/handlers_v201.go +++ b/go/internal/ocpp/handlers_v201.go @@ -56,8 +56,13 @@ func (h *handlerV201) OnBootNotification(id string, req *provisioning.BootNotifi h.mu.Lock() s.vendor = vendor s.model = model + s.serial = serial + if req != nil && req.ChargingStation.FirmwareVersion != "" { + s.firmware = req.ChargingStation.FirmwareVersion + } h.mu.Unlock() h.setVersion(id, Version201) + h.noteIdentity(id) h.telSuccess(id) return provisioning.NewBootNotificationResponse( diff --git a/go/internal/ocpp/identity.go b/go/internal/ocpp/identity.go new file mode 100644 index 000000000..593ff9697 --- /dev/null +++ b/go/internal/ocpp/identity.go @@ -0,0 +1,103 @@ +package ocpp + +// Hardware identity for a driverless device. +// +// Everything else in FTW is a device because config named a driver and the +// driver reported a serial. A charge point is the other way round: it dials +// us, picks its own name, and tells us what it is only in BootNotification. +// +// The name it picked is not identity. It is the last segment of a URL the +// installer typed, it is what a charger entry adopts, and it can be changed on +// the charger's own web page — that makes it a YAML name by another route, and +// persistent state keyed on it would not survive a re-commissioning. The +// vendor and serial from BootNotification are the hardware-stable pair, and +// they are what the device row is keyed on. The URL identity is the fallback +// for a charger that reports no serial at all, recorded as an endpoint so it +// reads as what it is: stable only until someone changes it. + +import "log/slog" + +// ChargerIdentity is what a charge point told us about itself, in the shape +// the device registry wants. +type ChargerIdentity struct { + // ID is the identity the charger dialled with — its driver name in + // telemetry, and what a charger entry adopts. + ID string + // Vendor, Model, Serial and Firmware come from BootNotification. Any of + // them may be empty; chargers vary in what they bother to report. + Vendor string + Model string + Serial string + Firmware string +} + +// Identities returns the adopted chargers that have told us what they are. +// +// Pending chargers are left out on purpose: a device row is a statement that +// this hardware is part of the site, and quarantine says an unadopted charge +// point is not. It is visible in the Chargers panel for an operator to adopt, +// and gets its row on the next boot or config apply after that. +func (h *Handler) Identities() []ChargerIdentity { + if h == nil { + return nil + } + h.mu.Lock() + defer h.mu.Unlock() + out := make([]ChargerIdentity, 0, len(h.chargers)) + for id, s := range h.chargers { + if !h.approved[id] { + continue + } + if s.vendor == "" && s.serial == "" { + // Nothing to key on yet — the charger has connected but not + // booted. Registering now would create a row keyed on the URL + // identity that the real serial could never replace. + continue + } + out = append(out, ChargerIdentity{ + ID: id, + Vendor: s.vendor, + Model: s.model, + Serial: s.serial, + Firmware: s.firmware, + }) + } + return out +} + +// SetIdentityReported registers the callback fired when an adopted charger +// reports what it is. main.go writes the device row from it. +func (h *Handler) SetIdentityReported(fn func(ChargerIdentity)) { + if h == nil { + return + } + h.mu.Lock() + h.identityReported = fn + h.mu.Unlock() +} + +// noteIdentity fires the callback after a BootNotification. Quarantine +// applies: a pending charger's identity is recorded and shown, and never +// becomes a device. +func (h *Handler) noteIdentity(id string) { + h.mu.Lock() + s := h.chargersLocked(id) + ident := ChargerIdentity{ + ID: id, + Vendor: s.vendor, + Model: s.model, + Serial: s.serial, + Firmware: s.firmware, + } + fn := h.identityReported + approved := h.approved[id] + h.mu.Unlock() + if !approved || fn == nil { + return + } + if ident.Vendor == "" && ident.Serial == "" { + slog.Info("ocpp: charger reported neither vendor nor serial — device identity falls back to the name it dialled with", + "charger", id) + } + fn(ident) +} diff --git a/go/internal/ocpp/identity_test.go b/go/internal/ocpp/identity_test.go new file mode 100644 index 000000000..d20c459ad --- /dev/null +++ b/go/internal/ocpp/identity_test.go @@ -0,0 +1,143 @@ +package ocpp + +import ( + "fmt" + "testing" + "time" + + ocpp16 "github.com/lorenzodonini/ocpp-go/ocpp1.6" + "github.com/lorenzodonini/ocpp-go/ocpp1.6/core" + + "github.com/srcfl/ftw/go/internal/telemetry" +) + +// A device row is a statement that this hardware is part of the site, so +// quarantine covers it: a pending charge point's identity is recorded and +// shown, and never becomes a device. +func TestIdentityIsWithheldFromPendingChargers(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + + var reported []ChargerIdentity + h.SetIdentityReported(func(i ChargerIdentity) { + reported = append(reported, i) + }) + + for _, id := range []string{"garage", "intruder"} { + s := h.state(id) + h.mu.Lock() + s.vendor, s.model, s.serial = "Charge Amps", "Halo", "SN-"+id + h.mu.Unlock() + h.noteIdentity(id) + } + + if len(reported) != 1 || reported[0].ID != "garage" { + t.Fatalf("identity reported for %v, want only the adopted charger", reported) + } + if reported[0].Serial != "SN-garage" { + t.Errorf("serial: got %q, want SN-garage", reported[0].Serial) + } + + ids := h.Identities() + if len(ids) != 1 || ids[0].ID != "garage" { + t.Fatalf("Identities returned %v, want only the adopted charger", ids) + } +} + +// Adoption happens long after a BootNotification — an operator binds a pending +// charger to a loadpoint — and the charger will not boot again for it. The +// catch-up path is Identities, so it must return a charger that booted while +// it was still pending. +func TestIdentitiesCatchUpAfterAdoption(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + + s := h.state("garage") + h.mu.Lock() + s.vendor, s.serial = "Easee", "EH123456" + h.mu.Unlock() + h.noteIdentity("garage") + + if got := h.Identities(); len(got) != 0 { + t.Fatalf("a pending charger should have no identity to register, got %v", got) + } + + h.SetApprovedIDs([]string{"garage"}) + got := h.Identities() + if len(got) != 1 || got[0].Serial != "EH123456" { + t.Fatalf("after adoption: got %v, want the charger that already booted", got) + } +} + +// A charger that has connected but not booted has nothing hardware-stable to +// key on. Registering it then would create a row keyed on the name it dialled +// with, which the real serial could never replace. +func TestIdentityWaitsForBoot(t *testing.T) { + h := NewHandler(telemetry.NewStore(), 60) + h.SetApprovedIDs([]string{"garage"}) + h.OnConnect("garage") + + if got := h.Identities(); len(got) != 0 { + t.Fatalf("connected but not booted should yield no identity, got %v", got) + } +} + +// 1.6 has two serial fields and shipped firmware disagrees about which to +// fill, so losing the deprecated one loses the only stable identity some +// chargers ever report. +func TestBootNotificationTakesEitherSerialField(t *testing.T) { + tests := []struct { + name string + props []func(*core.BootNotificationRequest) + want string + }{ + { + name: "chargePointSerialNumber", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { r.ChargePointSerialNumber = "CP-1" }}, + want: "CP-1", + }, + { + name: "deprecated chargeBoxSerialNumber", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { r.ChargeBoxSerialNumber = "BOX-1" }}, + want: "BOX-1", + }, + { + name: "both, the current field wins", + props: []func(*core.BootNotificationRequest){func(r *core.BootNotificationRequest) { + r.ChargePointSerialNumber = "CP-1" + r.ChargeBoxSerialNumber = "BOX-1" + }}, + want: "CP-1", + }, + {name: "neither", want: ""}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + id := "boot-" + tc.name + port, srv := startServer(t, telemetry.NewStore(), id) + t.Cleanup(srv.Stop) + + cp := ocpp16.NewChargePoint(id, nil, nil) + if err := cp.Start(fmt.Sprintf("ws://127.0.0.1:%d", port)); err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(cp.Stop) + + if _, err := cp.BootNotification("Halo", "Charge Amps", tc.props...); err != nil { + t.Fatalf("boot: %v", err) + } + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if v, ok := srv.Handler().Snapshot()[id]; ok && v.Vendor != "" { + if v.Serial != tc.want { + t.Fatalf("serial: got %q, want %q", v.Serial, tc.want) + } + return + } + time.Sleep(20 * time.Millisecond) + } + t.Fatal("charger never appeared in the snapshot") + }) + } +} diff --git a/go/internal/ocpp/server.go b/go/internal/ocpp/server.go index 51ed4c40c..863e102d1 100644 --- a/go/internal/ocpp/server.go +++ b/go/internal/ocpp/server.go @@ -33,10 +33,10 @@ package ocpp import ( "context" - "crypto/subtle" "errors" "fmt" "log/slog" + "net/http" "sync" "time" @@ -80,9 +80,10 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err } cfg.Defaults() - wsServer := ws.NewServer() - if cfg.Username != "" || cfg.Password != "" { - wsServer.SetBasicAuthHandler(basicAuthCheck(cfg.Username, cfg.Password)) + auth := newAuthorizer(cfg) + wsServer, err := newListener(cfg, auth) + if err != nil { + return nil, err } cs := ocpp16.NewCentralSystem(nil, wsServer) @@ -105,9 +106,9 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err // 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 != "" { - wsServer201.SetBasicAuthHandler(basicAuthCheck(cfg.Username, cfg.Password)) + wsServer201, err := newListener(cfg, auth) + if err != nil { + return nil, err } h201 := &handlerV201{Handler: h} csms := ocpp201.NewCSMS(nil, wsServer201) @@ -116,6 +117,12 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err csms.SetTransactionsHandler(h201) csms.SetMeterHandler(h201) csms.SetAuthorizationHandler(h201) + // Smart charging is registered for what the car reports, not for + // what we send: charging profiles go out through control.go. The + // one message here that changes behaviour is + // NotifyEVChargingNeeds; the rest of the profile is acknowledged + // and dropped. See charging_needs.go. + csms.SetSmartChargingHandler(h201) csms.SetNewChargingStationHandler(func(cs ocpp201.ChargingStationConnection) { h.setVersion(cs.ID(), Version201) h.OnConnect(cs.ID()) @@ -129,8 +136,11 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err go func() { defer close(s.doneV201) slog.Info("OCPP central system listening", - "version", Version201, "port", cfg.PortV201, "path", cfg.Path, - "basic_auth", cfg.Username != "") + "version", Version201, "scheme", cfg.Scheme(), + "bind", cfg.Bind, "port", cfg.PortV201, "path", cfg.Path, + "basic_auth", auth.requiresCredential(), + "per_charger_credentials", len(cfg.ChargerSecrets), + "client_certs", cfg.TLS != nil && cfg.TLS.ClientCAFile != "") csms.Start(cfg.PortV201, fmt.Sprintf("%s{ws}", cfg.Path)) }() } @@ -154,16 +164,15 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err go func() { defer close(s.done) slog.Info("OCPP central system listening", + "version", Version16, "scheme", cfg.Scheme(), "bind", cfg.Bind, "port", cfg.Port, "path", cfg.Path, - "basic_auth", 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). + "basic_auth", auth.requiresCredential(), + "per_charger_credentials", len(cfg.ChargerSecrets), + "client_certs", cfg.TLS != nil && cfg.TLS.ClientCAFile != "") + // The socket itself is opened on every interface — ws.Server.Start + // builds its address from the port alone. cfg.Bind is enforced one + // layer up, in authorizer.checkClient, which refuses the handshake + // for a connection that arrived somewhere else. // cs.Start blocks until cs.Stop is called. s.cs.Start(cfg.Port, fmt.Sprintf("%s{ws}", cfg.Path)) }() @@ -174,6 +183,59 @@ func Start(ctx context.Context, cfg *Config, tel *telemetry.Store) (*Server, err return s, nil } +// newListener builds one version's WebSocket server — TLS when configured — +// with both authorization gates wired. +// +// The basic-auth handler is registered only when a credential exists: the +// library reads a registered handler as "credentials are mandatory" and +// answers 401 to a charger that sends none, so registering it unconditionally +// would lock out every charger on a server with no username instead of +// admitting them all. checkClient is always safe to register; it authorizes +// everything when nothing is configured. +func newListener(cfg *Config, auth *authorizer) (ws.WsServer, error) { + var srv *ws.Server + if cfg.TLS.configured() { + // Half a TLS section is an error, not a reason to serve plaintext: + // an operator who asked for wss:// and silently got ws:// would + // have no way to tell the link was never encrypted. + tlsCfg, err := cfg.TLS.serverTLS() + if err != nil { + return nil, err + } + srv = ws.NewTLSServer(cfg.TLS.CertFile, cfg.TLS.KeyFile, tlsCfg) + } else { + srv = ws.NewServer() + } + if auth.requiresCredential() { + srv.SetBasicAuthHandler(auth.basicAuth) + } + srv.SetCheckClientHandler(auth.checkClient) + return &guardedServer{Server: srv, check: auth.checkClient}, nil +} + +// guardedServer keeps our connection check installed. +// +// ocppj.Server.Start unconditionally calls SetCheckClientHandler with its own +// handler, which is nil unless the caller reached past the 1.6/2.0.1 facade to +// set one. Handing the raw ws.Server to NewCentralSystem therefore discards +// the bind and identity gates silently at startup — the listener comes up, the +// logs say the gates are configured, and every impersonation attempt is +// accepted. This wrapper chains instead of replacing, so ours runs first and +// the library's own check still runs after. +type guardedServer struct { + *ws.Server + check func(id string, r *http.Request) bool +} + +func (g *guardedServer) SetCheckClientHandler(handler func(id string, r *http.Request) bool) { + g.Server.SetCheckClientHandler(func(id string, r *http.Request) bool { + if !g.check(id, r) { + return false + } + return handler == nil || handler(id, r) + }) +} + // Stop closes the WebSocket server and waits for the listener goroutine to exit. // A 5-second timeout prevents deadlock if the listener goroutine is stuck. func (s *Server) Stop() { @@ -221,16 +283,10 @@ func (s *Server) Path() string { return s.cfg.Path } -// basicAuthCheck compares credentials in constant time. This is the only -// gate in front of a 0.0.0.0 listener, so a timing oracle on the string -// compare is worth closing even though the shared-secret-without-TLS -// boundary is soft to begin with. -func basicAuthCheck(username, password string) func(string, string) bool { - u := []byte(username) - p := []byte(password) - return func(user, pass string) bool { - userOK := subtle.ConstantTimeCompare([]byte(user), u) == 1 - passOK := subtle.ConstantTimeCompare([]byte(pass), p) == 1 - return userOK && passOK - } -} +// The credential comparison that used to live here as basicAuthCheck now +// belongs to the authorizer in auth.go, which has to weigh a per-charger +// secret against the shared one before it can answer. Its constant-time +// comparison came from here and is kept: this is still the gate in front of +// a listener the socket layer will not pin, so a timing oracle on the string +// compare is worth closing even though a shared secret without TLS is a soft +// boundary to begin with. diff --git a/web/settings/tabs/loadpoints.js b/web/settings/tabs/loadpoints.js index 4f003033d..b195f499a 100644 --- a/web/settings/tabs/loadpoints.js +++ b/web/settings/tabs/loadpoints.js @@ -102,7 +102,61 @@ // The OCPP block of the Chargers tab: what the server is, where a charger // should dial, and the live list of charge points it has seen. vehicles // (config.vehicles) resolves each session's identity to a profile name. - function ocppSection(status, host, escHtml, vehicles) { + // The OCPP server's own settings, editable here rather than only in + // config.yaml. The Settings shell's generic capture pass writes these back + // by data-path, and creates the ocpp object when the config has none — + // which is the case on a box that has never turned OCPP on. + // + // TLS paths and per-charger credentials stay in config.yaml on purpose: + // they are host filesystem paths and one secret per charger, and both are + // set once at commissioning by someone with a shell. See docs/ocpp.md. + function ocppServerForm(config, escHtml, help) { + var h = typeof help === "function" ? help : function () { return ""; }; + var o = (config && config.ocpp) || {}; + return ( + '
' + + '
' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + ); + } + + function ocppSection(status, host, escHtml, vehicles, config, help) { var html = '
OCPP chargers'; if (!status) { @@ -114,23 +168,21 @@ html += '

' + 'An EV charger that speaks OCPP needs no driver: FTW has a built-in OCPP 1.6J + 2.0.1 server, ' + - 'and the charger connects to it and registers itself. The server is currently off. ' + - 'Enable it in config.yaml:' + + 'and the charger connects to it and registers itself. The server is currently off — ' + + 'turn it on here, set a password, and save.' + '

' + - '
ocpp:\n' +
-        '    enabled: true\n' +
-        '    port: 8887          # OCPP 1.6J\n' +
-        '    port_v201: 8888     # OCPP 2.0.1, omit to disable\n' +
-        '    username: ftw\n' +
-        '    password: <a long random string>
' + - '

' + + ocppServerForm(config, escHtml, help) + + '

' + 'Then point the charger at ws://' + escHtml(host) + ':8887/<charger-name> ' + - 'and it appears here. See docs/ocpp.md for per-vendor steps.' + + 'and it appears here. See docs/ocpp.md for per-vendor steps, and for TLS and ' + + 'per-charger credentials, which are set in config.yaml.' + '

'; html += '
'; return html; } + html += ocppServerForm(config, escHtml, help); + var port = status.port || 8887; var path = status.path || "/"; html += @@ -328,7 +380,7 @@ 'pick it here and set the electrical envelope. (Config files call this binding a loadpoint.)' + '

'; - html += ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles); + html += ocppSection(S.ocppStatus, (window.location && window.location.hostname) || "", escHtml, config.vehicles, config, help); if (!drivers.length) { html += @@ -583,6 +635,7 @@ ocppChargerIds: ocppChargerIds, ocppStateLabel: ocppStateLabel, ocppSection: ocppSection, + ocppServerForm: ocppServerForm, steerLabel: steerLabel, vehiclesSection: vehiclesSection, vehicleForIdentifier: vehicleForIdentifier, diff --git a/web/settings/tabs/loadpoints.test.mjs b/web/settings/tabs/loadpoints.test.mjs index 9398e95c5..3befb0274 100644 --- a/web/settings/tabs/loadpoints.test.mjs +++ b/web/settings/tabs/loadpoints.test.mjs @@ -5,8 +5,8 @@ import assert from "node:assert/strict"; globalThis.window = {}; await import("./loadpoints.js"); -const { evDriverNames, ocppChargerIds, ocppStateLabel, ocppSection, steerLabel, - vehiclesSection, vehicleForIdentifier, parseIdentifiers } = +const { evDriverNames, ocppChargerIds, ocppStateLabel, ocppSection, ocppServerForm, + steerLabel, vehiclesSection, vehicleForIdentifier, parseIdentifiers } = globalThis.window.FTWSettings.tabs.loadpoints._pure; const escHtml = (s) => @@ -126,12 +126,24 @@ describe("charger state labels", () => { }); describe("OCPP section", () => { - it("tells the operator how to enable a disabled server", () => { - const html = ocppSection({ enabled: false, chargers: [] }, "192.168.1.209", escHtml); - assert.match(html, /enabled: true/); + it("lets the operator turn a disabled server on, rather than sending them to config.yaml", () => { + const html = ocppSection({ enabled: false, chargers: [] }, "192.168.1.209", escHtml, [], {}); + assert.match(html, /data-checkbox-path="ocpp\.enabled"/); + assert.match(html, /data-path="ocpp\.password"/); assert.match(html, /ws:\/\/192\.168\.1\.209:8887/); }); + it("offers the server settings when it is already on", () => { + const html = ocppSection( + { enabled: true, port: 8887, path: "/", chargers: [] }, + "ftw.lan", escHtml, [], + { ocpp: { enabled: true, port: 8887, port_v201: 8888, username: "ftw" } }, + ); + assert.match(html, /data-checkbox-path="ocpp\.enabled"[^>]*checked/); + assert.match(html, /data-path="ocpp\.port_v201" value="8888"/); + assert.match(html, /data-path="ocpp\.username" value="ftw"/); + }); + it("shows the dial-in URL and the DHCP reservation advice when enabled", () => { const html = ocppSection({ enabled: true, port: 8887, path: "/", chargers: [] }, "ftw.lan", escHtml); assert.match(html, /ws:\/\/ftw\.lan:8887\//); @@ -176,3 +188,32 @@ describe("OCPP section", () => { assert.match(html, /2\.0\.1: port 8888/); }); }); + +describe("OCPP server form", () => { + it("creates the fields even when the config has never had an ocpp section", () => { + const html = ocppServerForm({}, escHtml); + assert.match(html, /data-checkbox-path="ocpp\.enabled"/); + // Defaults an operator would otherwise have to go and look up. + assert.match(html, /data-path="ocpp\.port" value="8887"/); + assert.match(html, /data-path="ocpp\.path" value="\/"/); + }); + + it("never renders the stored password back into the page", () => { + const html = ocppServerForm({ ocpp: { password: "the-real-secret" } }, escHtml); + assert.doesNotMatch(html, /the-real-secret/); + // Blank means "keep the stored one", so the placeholder has to say so. + assert.match(html, /data-path="ocpp\.password" value=""/); + assert.match(html, /placeholder="unchanged"/); + }); + + it("shows an empty bind as every interface rather than inventing 0.0.0.0", () => { + const html = ocppServerForm({ ocpp: {} }, escHtml); + assert.match(html, /data-path="ocpp\.bind" value=""/); + assert.match(html, /placeholder="every interface"/); + }); + + it("escapes values it puts back into the page", () => { + const html = ocppServerForm({ ocpp: { username: '">' } }, escHtml); + assert.doesNotMatch(html, /