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 "" }