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:
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" {
|
||||
|
||||
Reference in new issue
Block a user