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:
co-authored by
Claude Opus 4.8
parent
367113f538
commit
a1519f6e89
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user