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:
tajniak81andClaude Opus 4.8 committed 2026-07-18 16:40:22 +02:00
1 parent 367113f538
commit a1519f6e89
22 files changed
+2850 -11

No files matched your search

@@ -27,13 +27,45 @@ import (
const (
ankerPlugin = "anker-solix"
ankerSecretMask = "••••••••"
// OCPP control modes for the Anker Solix charger (see internal/ocpp).
ankerControlOff = "off" // monitoring only (default)
ankerControlOwn = "own" // DriverVault is the charger's Central System
ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects
)
// normalizeControlMode maps a raw control-mode value to a recognized mode, or ""
// when unset/unknown so the cascade continues to the next layer. An explicit
// "off" is recognized (and thus wins its layer).
func normalizeControlMode(v string) string {
switch strings.ToLower(strings.TrimSpace(v)) {
case ankerControlOwn:
return ankerControlOwn
case ankerControlProxy:
return ankerControlProxy
case ankerControlOff:
return ankerControlOff
default:
return ""
}
}
// ankerConfig is one layer's Anker Solix settings.
type ankerConfig struct {
Email string `json:"email"`
Password string `json:"password"`
Country string `json:"country"`
// ControlMode is the OCPP control path: off | own | proxy (see internal/ocpp).
// It resolves independently of the credentials, like Country.
ControlMode string `json:"controlMode"`
}
// ankerChargerBinding is the per-charger OCPP control token an operator installs
// into the charger (as the OCPP Basic-auth password) so it may connect to the
// DriverVault CSMS. It is user-layer only — not a cascade credential.
type ankerChargerBinding struct {
Token string `json:"token"`
AddedAt string `json:"addedAt,omitempty"`
}
// ankerStored is what we persist per user/org under pluginSettings.ankerSolix.
@@ -44,6 +76,8 @@ type ankerStored struct {
// Disabled is the organization layer's off switch, stored inverted so that
// absent == enabled. Only meaningful on the org record; ignored on user records.
Disabled bool `json:"disabled,omitempty"`
// ControlChargers maps a charger serial to its OCPP control token (user layer).
ControlChargers map[string]ankerChargerBinding `json:"controlChargers,omitempty"`
}
// ankerSettingsDoc is the pluginSettings JSON shape for the ankerSolix key.
@@ -79,7 +113,7 @@ var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3}
// pluginSettings blob (read from their user record).
func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) ankerResolution {
g, masterEnabled, _ := s.plugins.RawConfig(ankerPlugin)
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"]}
gc := ankerConfig{Email: g["email"], Password: g["password"], Country: g["country"], ControlMode: g["controlMode"]}
var oStored ankerStored
if who.OrgID != "" {
@@ -128,6 +162,18 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
}
}
// Control mode resolves independently too, defaulting to off (monitoring
// only). The highest layer that sets a recognized value wins; an explicit
// "off" set above still wins (and locks lower layers to monitoring).
res.eff.ControlMode = ankerControlOff
res.source["controlMode"] = "unset"
for _, l := range layers {
if v := normalizeControlMode(l.c.ControlMode); v != "" {
res.eff.ControlMode, res.source["controlMode"] = v, l.name
break
}
}
// Credentials resolve as a pair from the highest layer with an email, so the
// email and password never come from different layers.
credSrc := "unset"
@@ -234,9 +280,10 @@ func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string
return map[string]any{
"editableLayer": editable,
"fields": map[string]ankerFieldView{
"email": field("email", res.eff.Email, own.Email, false),
"password": field("password", res.eff.Password, own.Password, true),
"country": field("country", res.eff.Country, own.Country, false),
"email": field("email", res.eff.Email, own.Email, false),
"password": field("password", res.eff.Password, own.Password, true),
"country": field("country", res.eff.Country, own.Country, false),
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
},
}
}
@@ -247,6 +294,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"controlMode": res.eff.ControlMode, // effective OCPP control mode (off|own|proxy)
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
@@ -340,6 +388,7 @@ func (s *Server) handlePutAnker(w http.ResponseWriter, r *http.Request) {
applyField("email", func(c *ankerConfig, v string) { c.Email = v })
applyField("password", func(c *ankerConfig, v string) { c.Password = v })
applyField("country", func(c *ankerConfig, v string) { c.Country = strings.ToUpper(v) })
applyField("controlMode", func(c *ankerConfig, v string) { c.ControlMode = normalizeControlMode(v) })
// Persist the organization layer (admins) via the service account.
if editable == "org" {