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
@@ -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" {
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"drivervault/apiserver/internal/ocpp"
|
||||
)
|
||||
|
||||
// This file is the control half of the Anker Solix integration: the OCPP
|
||||
// WebSocket endpoint a charger dials out to, the per-charger control-token index
|
||||
// that authenticates it, and the REST endpoints the web app calls to issue OCPP
|
||||
// commands. Monitoring (the read-only cloud plugin) lives in
|
||||
// integrations_ankersolix.go and the OCPP protocol itself in internal/ocpp.
|
||||
|
||||
// ---- control-token index -----------------------------------------------------
|
||||
|
||||
// ankerControlBinding is what a per-charger control token resolves to.
|
||||
type ankerControlBinding struct {
|
||||
UserID string
|
||||
Serial string
|
||||
}
|
||||
|
||||
// controlIndex maps an OCPP control token to its owning user + charger. It is an
|
||||
// in-memory cache, seeded when a token is (re)generated and rebuilt from
|
||||
// PocketBase the first time an unknown token connects (or lazily at startup).
|
||||
type controlIndex struct {
|
||||
mu sync.RWMutex
|
||||
byToken map[string]ankerControlBinding
|
||||
built bool
|
||||
}
|
||||
|
||||
func newControlIndex() *controlIndex {
|
||||
return &controlIndex{byToken: map[string]ankerControlBinding{}}
|
||||
}
|
||||
|
||||
func (ci *controlIndex) lookup(token string) (ankerControlBinding, bool) {
|
||||
ci.mu.RLock()
|
||||
defer ci.mu.RUnlock()
|
||||
b, ok := ci.byToken[token]
|
||||
return b, ok
|
||||
}
|
||||
|
||||
// setUser replaces all of a user's token entries with the given charger set, so
|
||||
// a regenerated token invalidates the previous one.
|
||||
func (ci *controlIndex) setUser(userID string, chargers map[string]ankerChargerBinding) {
|
||||
ci.mu.Lock()
|
||||
defer ci.mu.Unlock()
|
||||
for tok, b := range ci.byToken {
|
||||
if b.UserID == userID {
|
||||
delete(ci.byToken, tok)
|
||||
}
|
||||
}
|
||||
for serial, cb := range chargers {
|
||||
if cb.Token != "" {
|
||||
ci.byToken[cb.Token] = ankerControlBinding{UserID: userID, Serial: serial}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ensureControlIndex builds the token index from every user's pluginSettings the
|
||||
// first time it is needed. Best effort: a failure leaves built=false so a later
|
||||
// connect retries.
|
||||
func (s *Server) ensureControlIndex(ctx context.Context) {
|
||||
s.control.mu.RLock()
|
||||
built := s.control.built
|
||||
s.control.mu.RUnlock()
|
||||
if built || !s.pb.Configured() {
|
||||
return
|
||||
}
|
||||
q := url.Values{}
|
||||
q.Set("perPage", "500")
|
||||
q.Set("fields", "id,pluginSettings")
|
||||
lr, err := s.pb.List(ctx, s.usersCollection(), q)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
var items []struct {
|
||||
ID string `json:"id"`
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
_ = json.Unmarshal(lr.Items, &items)
|
||||
for _, it := range items {
|
||||
var doc ankerSettingsDoc
|
||||
if len(it.PluginSettings) > 0 {
|
||||
_ = json.Unmarshal(it.PluginSettings, &doc)
|
||||
}
|
||||
if len(doc.AnkerSolix.ControlChargers) > 0 {
|
||||
s.control.setUser(it.ID, doc.AnkerSolix.ControlChargers)
|
||||
}
|
||||
}
|
||||
s.control.mu.Lock()
|
||||
s.control.built = true
|
||||
s.control.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- OCPP WebSocket endpoint -------------------------------------------------
|
||||
|
||||
// handleOCPPConnect is the WebSocket endpoint a charger dials out to. It
|
||||
// authenticates the charger with OCPP Basic auth (username = serial, password =
|
||||
// the per-charger control token), resolves the owner's control mode, and either
|
||||
// registers the session (own) or bridges it to Anker's cloud (proxy).
|
||||
func (s *Server) handleOCPPConnect(w http.ResponseWriter, r *http.Request) {
|
||||
serial := r.PathValue("serial")
|
||||
user, token, ok := r.BasicAuth()
|
||||
if !ok || token == "" {
|
||||
w.Header().Set("WWW-Authenticate", `Basic realm="ocpp"`)
|
||||
writeError(w, http.StatusUnauthorized, "charger authentication required")
|
||||
return
|
||||
}
|
||||
binding, found := s.control.lookup(token)
|
||||
if !found {
|
||||
s.ensureControlIndex(r.Context())
|
||||
binding, found = s.control.lookup(token)
|
||||
}
|
||||
if !found || binding.Serial != serial || (user != "" && user != serial) {
|
||||
writeError(w, http.StatusUnauthorized, "invalid charger credentials")
|
||||
return
|
||||
}
|
||||
|
||||
who, userRaw, err := s.callerForUser(r.Context(), binding.UserID)
|
||||
if err != nil || who == nil {
|
||||
writeError(w, http.StatusForbidden, "charger owner unavailable")
|
||||
return
|
||||
}
|
||||
res := s.resolveAnker(r.Context(), who, userRaw)
|
||||
if !res.available || !res.orgEnabled || !res.enabled {
|
||||
writeError(w, http.StatusForbidden, "the charger owner has not enabled the integration")
|
||||
return
|
||||
}
|
||||
mode := res.eff.ControlMode
|
||||
if mode != ankerControlOwn && mode != ankerControlProxy {
|
||||
writeError(w, http.StatusForbidden, "control mode is off for this charger")
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the proxy upstream before upgrading so a failure is a clean HTTP
|
||||
// error rather than a dropped WebSocket.
|
||||
var upstreamURL, upstreamAuth string
|
||||
if mode == ankerControlProxy {
|
||||
upstreamURL, upstreamAuth, err = s.ankerProxyUpstream(r.Context(), res, serial)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "cannot reach Anker upstream for proxy mode: "+err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
conn, err := ocpp.Upgrade(w, r)
|
||||
if err != nil {
|
||||
// The response may already be hijacked; just log and drop.
|
||||
log.Printf("ocpp: upgrade failed for %s: %v", serial, err)
|
||||
return
|
||||
}
|
||||
|
||||
if mode == ankerControlOwn {
|
||||
if _, err := s.ocpp.Accept(serial, ocpp.ModeOwn, conn, nil); err != nil {
|
||||
log.Printf("ocpp: accept own %s: %v", serial, err)
|
||||
_ = conn.Close()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
up, err := ocpp.DialUpstream(r.Context(), upstreamURL, upstreamAuth)
|
||||
if err != nil {
|
||||
log.Printf("ocpp: proxy upstream dial failed for %s: %v", serial, err)
|
||||
_ = conn.Close()
|
||||
return
|
||||
}
|
||||
if _, err := s.ocpp.Accept(serial, ocpp.ModeProxy, conn, up); err != nil {
|
||||
log.Printf("ocpp: accept proxy %s: %v", serial, err)
|
||||
_ = conn.Close()
|
||||
_ = up.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// callerForUser builds a callerIdentity (and returns the pluginSettings blob) for
|
||||
// a user id, so the OCPP endpoint can resolve the cascade without a bearer token.
|
||||
func (s *Server) callerForUser(ctx context.Context, userID string) (*callerIdentity, json.RawMessage, error) {
|
||||
if userID == "" || !s.pb.Configured() {
|
||||
return nil, nil, errors.New("not configured")
|
||||
}
|
||||
var rec struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Organization string `json:"organization"`
|
||||
PluginSettings json.RawMessage `json:"pluginSettings"`
|
||||
}
|
||||
if err := s.pb.GetOne(ctx, s.usersCollection(), userID, &rec); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
role := rec.Role
|
||||
if role == "" {
|
||||
role = roleUser
|
||||
}
|
||||
return &callerIdentity{ID: rec.ID, Role: role, OrgID: rec.Organization}, rec.PluginSettings, nil
|
||||
}
|
||||
|
||||
// ankerProxyUpstream asks the read-only cloud plugin for the charger's OCPP
|
||||
// endpoint (its "ocpp-info" capability) and extracts a ws(s):// URL from the
|
||||
// reply. Anker's own per-charger auth key is not available to us, so no
|
||||
// Authorization header is set — if the upstream requires one it will reject the
|
||||
// dial, surfaced as a clear error.
|
||||
func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, serial string) (string, string, error) {
|
||||
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
|
||||
return "", "", errors.New("Anker account credentials are required for proxy mode")
|
||||
}
|
||||
cfg := map[string]string{"email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country}
|
||||
payload, _ := json.Marshal(map[string]string{"sn": serial})
|
||||
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, cfg, "ocpp-info", payload)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
u := extractWSURL(raw)
|
||||
if u == "" {
|
||||
return "", "", errors.New("no OCPP endpoint URL in the Anker response")
|
||||
}
|
||||
return u, "", nil
|
||||
}
|
||||
|
||||
// ---- control REST endpoints --------------------------------------------------
|
||||
|
||||
// handleAnkerControlStatus reports the control state for one charger: the
|
||||
// resolved mode, whether it is connected to our CSMS, the provisioning endpoint
|
||||
// and token, and a live status snapshot when connected.
|
||||
func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
sn := r.PathValue("sn")
|
||||
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
||||
res := s.resolveAnker(r.Context(), who, userRaw)
|
||||
binding := ankerBindingFor(userRaw, sn)
|
||||
|
||||
body := map[string]any{
|
||||
"serial": sn,
|
||||
"controlMode": res.eff.ControlMode,
|
||||
"available": res.available && res.orgEnabled && res.enabled,
|
||||
"endpoint": s.ocppEndpoint(r, sn),
|
||||
"hasToken": binding.Token != "",
|
||||
"token": binding.Token, // shown to the owner for charger provisioning
|
||||
}
|
||||
if sess, ok := s.ocpp.SessionFor(sn); ok {
|
||||
body["connected"] = true
|
||||
body["status"] = sess.Snapshot()
|
||||
} else {
|
||||
body["connected"] = false
|
||||
}
|
||||
writeJSON(w, http.StatusOK, body)
|
||||
}
|
||||
|
||||
// handleAnkerControlToken (re)generates the per-charger control token the
|
||||
// operator installs into the charger, and updates the token index.
|
||||
func (s *Server) handleAnkerControlToken(w http.ResponseWriter, r *http.Request) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return
|
||||
}
|
||||
if !s.pb.Configured() {
|
||||
writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server")
|
||||
return
|
||||
}
|
||||
sn := r.PathValue("sn")
|
||||
if strings.TrimSpace(sn) == "" {
|
||||
writeError(w, http.StatusBadRequest, "missing charger serial")
|
||||
return
|
||||
}
|
||||
token := genControlToken()
|
||||
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
||||
|
||||
var updated map[string]ankerChargerBinding
|
||||
newDoc := mergeAnker(userRaw, func(as *ankerStored) {
|
||||
if as.ControlChargers == nil {
|
||||
as.ControlChargers = map[string]ankerChargerBinding{}
|
||||
}
|
||||
as.ControlChargers[sn] = ankerChargerBinding{Token: token, AddedAt: time.Now().UTC().Format(time.RFC3339)}
|
||||
updated = as.ControlChargers
|
||||
})
|
||||
if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID,
|
||||
map[string]any{"pluginSettings": newDoc}, nil); err != nil {
|
||||
writePBError(w, err)
|
||||
return
|
||||
}
|
||||
s.control.setUser(who.ID, updated)
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"serial": sn,
|
||||
"token": token,
|
||||
"endpoint": s.ocppEndpoint(r, sn),
|
||||
})
|
||||
}
|
||||
|
||||
// handleAnkerControlAction issues one OCPP command to a connected charger.
|
||||
func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) {
|
||||
sess, ok := s.ankerControlSession(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
action := r.PathValue("action")
|
||||
|
||||
var body struct {
|
||||
IdTag string `json:"idTag"`
|
||||
ConnectorID int `json:"connectorId"`
|
||||
TransactionID int `json:"transactionId"`
|
||||
Amps float64 `json:"amps"`
|
||||
Operative *bool `json:"operative"`
|
||||
Hard bool `json:"hard"`
|
||||
RequestedMessage string `json:"requestedMessage"`
|
||||
Key string `json:"key"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
if r.Body != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(&body)
|
||||
}
|
||||
|
||||
ctx := r.Context()
|
||||
var (
|
||||
status string
|
||||
result any
|
||||
err error
|
||||
)
|
||||
switch action {
|
||||
case "start":
|
||||
status, err = sess.RemoteStartTransaction(ctx, body.IdTag, body.ConnectorID)
|
||||
case "stop":
|
||||
status, err = sess.RemoteStopTransaction(ctx, body.TransactionID)
|
||||
case "limit":
|
||||
status, err = sess.SetCurrentLimit(ctx, body.ConnectorID, body.Amps)
|
||||
case "clear-limit":
|
||||
status, err = sess.ClearChargingProfile(ctx, body.ConnectorID)
|
||||
case "availability":
|
||||
operative := true
|
||||
if body.Operative != nil {
|
||||
operative = *body.Operative
|
||||
}
|
||||
status, err = sess.ChangeAvailability(ctx, body.ConnectorID, operative)
|
||||
case "reset":
|
||||
status, err = sess.Reset(ctx, body.Hard)
|
||||
case "unlock":
|
||||
status, err = sess.UnlockConnector(ctx, body.ConnectorID)
|
||||
case "trigger":
|
||||
status, err = sess.TriggerMessage(ctx, body.RequestedMessage, body.ConnectorID)
|
||||
case "config":
|
||||
if body.Key != "" {
|
||||
status, err = sess.ChangeConfiguration(ctx, body.Key, body.Value)
|
||||
} else {
|
||||
var conf ocpp.GetConfigurationResult
|
||||
conf, err = sess.GetConfiguration(ctx, nil)
|
||||
result = conf
|
||||
}
|
||||
default:
|
||||
writeError(w, http.StatusBadRequest, "unknown control action: "+action)
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
resp := map[string]any{"status": status}
|
||||
if result != nil {
|
||||
resp["result"] = result
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
// ankerControlSession applies the full gate (cascade + control mode + a live
|
||||
// session the caller actually owns) and returns the charger's session.
|
||||
func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) {
|
||||
who := caller(r)
|
||||
if who == nil {
|
||||
writeError(w, http.StatusUnauthorized, "not authenticated")
|
||||
return nil, false
|
||||
}
|
||||
sn := r.PathValue("sn")
|
||||
userRaw := s.userPluginSettings(r.Context(), who.ID)
|
||||
res := s.resolveAnker(r.Context(), who, userRaw)
|
||||
|
||||
switch {
|
||||
case !res.available:
|
||||
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled by the administrator")
|
||||
return nil, false
|
||||
case !res.orgEnabled:
|
||||
writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled for your organization")
|
||||
return nil, false
|
||||
case !res.enabled:
|
||||
writeError(w, http.StatusForbidden, "enable the Anker Solix integration in Settings first")
|
||||
return nil, false
|
||||
}
|
||||
if res.eff.ControlMode == ankerControlOff {
|
||||
writeError(w, http.StatusConflict, "control mode is off; choose Own or Proxy CSMS to send commands")
|
||||
return nil, false
|
||||
}
|
||||
// The caller must own this charger (have a token bound to it), so a serial
|
||||
// alone can't be used to reach someone else's charger.
|
||||
if ankerBindingFor(userRaw, sn).Token == "" {
|
||||
writeError(w, http.StatusNotFound, "no control token for this charger; generate one first")
|
||||
return nil, false
|
||||
}
|
||||
sess, ok := s.ocpp.SessionFor(sn)
|
||||
if !ok {
|
||||
writeError(w, http.StatusConflict, "charger is not connected to the control backend")
|
||||
return nil, false
|
||||
}
|
||||
return sess, true
|
||||
}
|
||||
|
||||
// ---- helpers -----------------------------------------------------------------
|
||||
|
||||
// ocppEndpoint builds the ws(s):// URL the operator points the charger at.
|
||||
func (s *Server) ocppEndpoint(r *http.Request, sn string) string {
|
||||
scheme := "ws"
|
||||
if r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https") {
|
||||
scheme = "wss"
|
||||
}
|
||||
return scheme + "://" + r.Host + "/ocpp/" + url.PathEscape(sn)
|
||||
}
|
||||
|
||||
// ankerBindingFor reads one charger's stored control binding from a user's
|
||||
// pluginSettings blob.
|
||||
func ankerBindingFor(userRaw json.RawMessage, sn string) ankerChargerBinding {
|
||||
var doc ankerSettingsDoc
|
||||
if len(userRaw) > 0 {
|
||||
_ = json.Unmarshal(userRaw, &doc)
|
||||
}
|
||||
return doc.AnkerSolix.ControlChargers[sn]
|
||||
}
|
||||
|
||||
func genControlToken() string {
|
||||
var b [24]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return hex.EncodeToString(b[:])
|
||||
}
|
||||
|
||||
// extractWSURL walks an arbitrary JSON value and returns the first ws:// or
|
||||
// wss:// string it finds.
|
||||
func extractWSURL(raw json.RawMessage) string {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return ""
|
||||
}
|
||||
return walkForWS(v)
|
||||
}
|
||||
|
||||
func walkForWS(v any) string {
|
||||
switch t := v.(type) {
|
||||
case string:
|
||||
s := strings.TrimSpace(t)
|
||||
if strings.HasPrefix(s, "ws://") || strings.HasPrefix(s, "wss://") {
|
||||
return s
|
||||
}
|
||||
case map[string]any:
|
||||
for _, val := range t {
|
||||
if u := walkForWS(val); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
case []any:
|
||||
for _, val := range t {
|
||||
if u := walkForWS(val); u != "" {
|
||||
return u
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizeControlMode(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
"off": "off",
|
||||
"own": "own",
|
||||
"proxy": "proxy",
|
||||
"OWN": "own",
|
||||
" Proxy": "proxy",
|
||||
"": "", // unset — cascade continues to the next layer
|
||||
"bogus": "", // unknown — treated as unset
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := normalizeControlMode(in); got != want {
|
||||
t.Errorf("normalizeControlMode(%q) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIndexSetUserInvalidatesOldToken(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-old"}})
|
||||
if _, ok := ci.lookup("tok-old"); !ok {
|
||||
t.Fatal("tok-old should resolve after first set")
|
||||
}
|
||||
// Regenerate: same charger, new token. The old token must stop resolving.
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "tok-new"}})
|
||||
if _, ok := ci.lookup("tok-old"); ok {
|
||||
t.Error("tok-old should be invalidated after regeneration")
|
||||
}
|
||||
b, ok := ci.lookup("tok-new")
|
||||
if !ok || b.UserID != "u1" || b.Serial != "SN1" {
|
||||
t.Errorf("tok-new resolves to %+v (ok=%v), want u1/SN1", b, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestControlIndexIsolatesUsers(t *testing.T) {
|
||||
ci := newControlIndex()
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a"}})
|
||||
ci.setUser("u2", map[string]ankerChargerBinding{"SN2": {Token: "b"}})
|
||||
// Re-setting u1 must not touch u2's token.
|
||||
ci.setUser("u1", map[string]ankerChargerBinding{"SN1": {Token: "a2"}})
|
||||
if _, ok := ci.lookup("b"); !ok {
|
||||
t.Error("u2's token should be untouched when u1 is updated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnkerBindingFor(t *testing.T) {
|
||||
raw := json.RawMessage(`{"ankerSolix":{"controlChargers":{"SN1":{"token":"xyz"}}}}`)
|
||||
if got := ankerBindingFor(raw, "SN1").Token; got != "xyz" {
|
||||
t.Errorf("binding token = %q, want xyz", got)
|
||||
}
|
||||
if got := ankerBindingFor(raw, "other").Token; got != "" {
|
||||
t.Errorf("unknown charger should have empty token, got %q", got)
|
||||
}
|
||||
if got := ankerBindingFor(nil, "SN1").Token; got != "" {
|
||||
t.Errorf("nil settings should have empty token, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractWSURL(t *testing.T) {
|
||||
cases := map[string]string{
|
||||
`{"data":{"ocppUrl":"wss://ocpp.anker.com/CP1"}}`: "wss://ocpp.anker.com/CP1",
|
||||
`{"a":{"b":[{"endpoint":"ws://x/y"}]}}`: "ws://x/y",
|
||||
`{"data":{"note":"https://not-a-ws-url"}}`: "",
|
||||
`{"empty":true}`: "",
|
||||
}
|
||||
for in, want := range cases {
|
||||
if got := extractWSURL(json.RawMessage(in)); got != want {
|
||||
t.Errorf("extractWSURL(%s) = %q, want %q", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOCPPEndpoint(t *testing.T) {
|
||||
s := &Server{}
|
||||
// Plain HTTP request → ws://
|
||||
r := httptest.NewRequest("GET", "http://host.example/x", nil)
|
||||
r.Host = "host.example"
|
||||
if got := s.ocppEndpoint(r, "SN 1"); got != "ws://host.example/ocpp/SN%201" {
|
||||
t.Errorf("endpoint = %q", got)
|
||||
}
|
||||
// Behind a TLS-terminating proxy → wss://
|
||||
r2 := httptest.NewRequest("GET", "http://host.example/x", nil)
|
||||
r2.Host = "host.example"
|
||||
r2.Header.Set("X-Forwarded-Proto", "https")
|
||||
if got := s.ocppEndpoint(r2, "SN1"); got != "wss://host.example/ocpp/SN1" {
|
||||
t.Errorf("tls endpoint = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"drivervault/apiserver/internal/config"
|
||||
"drivervault/apiserver/internal/ocpp"
|
||||
"drivervault/apiserver/internal/pb"
|
||||
)
|
||||
|
||||
// TestOCPPRouteAndControlAuth exercises the real HTTP stack (all middleware
|
||||
// included) to confirm the OCPP endpoint and the control REST routes are wired,
|
||||
// and that the charger-auth gate rejects an unknown token. PocketBase is left
|
||||
// unconfigured, so the successful (charger-connected) path is out of scope here —
|
||||
// that is covered by the internal/ocpp own/proxy tests.
|
||||
func TestOCPPRouteAndControlAuth(t *testing.T) {
|
||||
s := New(config.Config{UsersCollection: "users"}, pb.New("", "", ""))
|
||||
srv := httptest.NewServer(s.Handler())
|
||||
defer srv.Close()
|
||||
|
||||
wsBase := "ws" + strings.TrimPrefix(srv.URL, "http")
|
||||
|
||||
// A charger connecting with no OCPP Basic auth is rejected (401).
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, nil); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("unauthenticated charger connect: want HTTP 401, got %v", err)
|
||||
}
|
||||
|
||||
// A bogus token is unknown to the index (PB unconfigured, so the rebuild is a
|
||||
// no-op) and is likewise rejected.
|
||||
h := http.Header{}
|
||||
h.Set("Authorization", ocpp.BasicAuthHeader("CP1", "bogus-token"))
|
||||
if _, err := ocpp.Dial(context.Background(), wsBase+"/ocpp/CP1", []string{"ocpp1.6"}, h); err == nil ||
|
||||
!strings.Contains(err.Error(), "401") {
|
||||
t.Fatalf("bogus-token charger connect: want HTTP 401, got %v", err)
|
||||
}
|
||||
|
||||
// The control REST routes are registered (401 for a missing bearer token, not
|
||||
// 404 for an unknown route) — this also proves the {sn}/control,
|
||||
// {sn}/control/token and {sn}/{action} patterns don't shadow each other.
|
||||
routes := []struct {
|
||||
method, path string
|
||||
}{
|
||||
{http.MethodGet, "/api/integrations/anker-solix/chargers/CP1/control"},
|
||||
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/control/token"},
|
||||
{http.MethodPost, "/api/integrations/anker-solix/chargers/CP1/start"},
|
||||
}
|
||||
for _, rt := range routes {
|
||||
req, _ := http.NewRequest(rt.method, srv.URL+rt.path, nil)
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", rt.method, rt.path, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: status %d, want 401 (route registered, bearer required)", rt.method, rt.path, resp.StatusCode)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user