Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)

The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:

  - off   monitoring only (default, unchanged behavior)
  - own   DriverVault is the charger's Central System (full control)
  - proxy DriverVault relays to Anker's cloud and injects commands

New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.

The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.

Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-07-18 16:40:22 +02:00
co-authored by Claude Opus 4.8
parent 367113f538
commit a1519f6e89
22 changed files with 2850 additions and 11 deletions
+41 -2
View File
@@ -104,13 +104,16 @@
package api
import (
"bufio"
"context"
"log"
"net"
"net/http"
"sync"
"time"
"drivervault/apiserver/internal/config"
"drivervault/apiserver/internal/ocpp"
"drivervault/apiserver/internal/pb"
"drivervault/apiserver/internal/plugins"
_ "drivervault/apiserver/internal/plugins/builtin" // register built-in plugins
@@ -136,6 +139,13 @@ type Server struct {
cfg config.Config
pb *pb.Client
plugins *plugins.Manager
// ocpp is the OCPP 1.6J Central System that Anker Solix chargers connect to
// when their owner picks a control mode of own/proxy (see internal/ocpp and
// integrations_ankersolix_control.go). Nil-safe: control endpoints report a
// clear error when a charger is not connected.
ocpp *ocpp.CSMS
control *controlIndex // token -> owning user/charger for the /ocpp endpoint
}
// New constructs a Server around an already-built PocketBase client.
@@ -144,14 +154,19 @@ func New(cfg config.Config, client *pb.Client) *Server {
cfg: cfg,
pb: client,
plugins: plugins.NewManager(cfg.PluginsFile),
ocpp: ocpp.NewCSMS(func(f string, a ...any) { log.Printf("ocpp: "+f, a...) }),
control: newControlIndex(),
}
}
// StartPlugins loads persisted plugin state and initialises enabled plugins.
func (s *Server) StartPlugins() error { return s.plugins.Load() }
// Stop releases server-held resources (currently: plugin instances).
func (s *Server) Stop(ctx context.Context) { s.plugins.Shutdown(ctx) }
// Stop releases server-held resources (plugin instances and OCPP sessions).
func (s *Server) Stop(ctx context.Context) {
s.ocpp.Shutdown(ctx)
s.plugins.Shutdown(ctx)
}
// usersCollection returns the PocketBase auth collection holding app users.
func (s *Server) usersCollection() string {
@@ -286,6 +301,18 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth)
mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers)
// Anker Solix OCPP control (per-charger; gated by the same cascade plus a
// control mode of own/proxy and a live CSMS session). See
// integrations_ankersolix_control.go.
mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken)
mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction)
// OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits
// outside /api/ so it bypasses bearer auth; it authenticates the charger with
// OCPP Basic auth (serial + per-charger control token) instead.
mux.HandleFunc("GET /ocpp/{serial}", s.handleOCPPConnect)
// Cars + sharing.
mux.HandleFunc("GET /api/cars", s.listCars)
mux.HandleFunc("POST /api/cars", s.createCar)
@@ -454,3 +481,15 @@ func (w *statusWriter) Write(b []byte) (int, error) {
w.wrote = true
return w.ResponseWriter.Write(b)
}
// Hijack lets the wrapped ResponseWriter be taken over for a protocol switch
// (the OCPP WebSocket upgrade at /ocpp/{serial}). Without this pass-through the
// logging middleware would hide the underlying http.Hijacker.
func (w *statusWriter) Hijack() (net.Conn, *bufio.ReadWriter, error) {
hj, ok := w.ResponseWriter.(http.Hijacker)
if !ok {
return nil, nil, http.ErrNotSupported
}
w.wrote = true // a hijacked connection writes its own response
return hj.Hijack()
}