package api import ( "context" "encoding/json" "net/http" "net/url" "strings" ) // This file mirrors integrations.go for the Anker Solix V1 Smart EV Charger // plugin: the same three-layer cascade (global → org → user) lets everyone run // the integration under their own Anker account while a superadmin (and, in an // organization, an org admin) can impose settings from above. See integrations.go // for the full rationale; only the fields differ. // // - global (L1): pluginSettings on the app_settings singleton, set in the API // Server panel by a superadmin — the top of the cascade for everyone. // - org (L2): pluginSettings.ankerSolix on the caller's organization record. // - user (L3): pluginSettings.ankerSolix on the caller's own user record. // // The Anker email + password resolve together as a *pair* from the highest layer // that supplies an email, so credential halves are never mixed across layers. The // country resolves on its own. Enablement is strictly per-user (L3), gated by the // global master switch and, for org users, the org gate. const ( ankerPlugin = "anker-solix" ankerSecretMask = "••••••••" // Control modes for the Anker Solix charger, in order of how much they demand // of the deployment. own and proxy are OCPP paths (see internal/ocpp) and need // the charger to dial in to us. modbus is the local path (the ankersolix // plugin's modbus.go), where we dial the charger — which needs the server on // the charger's network. mqtt is the remote path (cloudmqtt.go): both sides // meet at Anker's own broker, so the charger needs no reachability at all, // which is what makes it the mode for a charger behind a customer's router. ankerControlOff = "off" // monitoring only (default) ankerControlOwn = "own" // DriverVault is the charger's Central System ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects ankerControlModbus = "modbus" // DriverVault talks Modbus TCP to the charger on the LAN ankerControlMqtt = "mqtt" // DriverVault commands the charger over Anker's cloud broker ) // 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 ankerControlModbus: return ankerControlModbus case ankerControlMqtt: return ankerControlMqtt case ankerControlOff: return ankerControlOff default: return "" } } // ankerControlModesAll lists every control mode, in the order the clients offer // them. off leads because it is the default and the fallback. var ankerControlModesAll = []string{ankerControlOff, ankerControlMqtt, ankerControlModbus, ankerControlOwn, ankerControlProxy} // parseControlModes reads a stored comma-separated mode list into a set, // dropping anything unrecognized. off is never in it: monitoring-only is what a // charger falls back to, so no layer may take it away from the layers below. func parseControlModes(v string) map[string]bool { out := map[string]bool{} for _, part := range strings.Split(v, ",") { if m := normalizeControlMode(part); m != "" && m != ankerControlOff { out[m] = true } } return out } // joinControlModes renders a mode set back to its stored form, in the canonical // order, so what a client sends round-trips to a predictable string. func joinControlModes(set map[string]bool) string { return strings.Join(controlModeList(set), ",") } // controlModeList sorts a mode set into the canonical order. func controlModeList(set map[string]bool) []string { out := []string{} for _, m := range ankerControlModesAll { if set[m] { out = append(out, m) } } return out } // controlModesOffered lists the modes a layer may still choose once the layers // above it have hidden theirs. func controlModesOffered(hidden map[string]bool) []string { out := []string{} for _, m := range ankerControlModesAll { if !hidden[m] { out = append(out, m) } } return out } // 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 control path: off | mqtt | modbus | own | proxy. // It resolves independently of the credentials, like Country. ControlMode string `json:"controlMode"` // ControlModesDisabled is a comma-separated list of modes this layer hides // from the layers below it — a mode a deployment (or an organization) does // not want offered at all. It does not constrain this layer's own choice, and // it never hides off. Meaningful on the global and organization layers only: // a user has nobody below them. ControlModesDisabled string `json:"controlModesDisabled,omitempty"` } // ankerChargerBinding is what we know about one charger the caller controls: // its OCPP control token, its local Modbus address, or both. The operator // installs the token into the charger (as its OCPP Basic-auth password); we keep // only its SHA-256 hash and a short hint, never the plaintext — the token is // shown to the owner exactly once, at generation. User-layer only, not a cascade // credential. type ankerChargerBinding struct { TokenHash string `json:"tokenHash,omitempty"` // sha256(token), lowercase hex TokenHint string `json:"tokenHint,omitempty"` // last 4 chars, for the UI AddedAt string `json:"addedAt,omitempty"` // ModbusHost and ModbusPort address the charger's local Modbus TCP server, // which the owner enables in the Anker app (Settings > Integrations > Modbus // TCP; the app then shows this address). They are per charger rather than per // account because they are a LAN address, not a credential — and unlike the // OCPP token they are not a secret, so they are stored and shown verbatim. ModbusHost string `json:"modbusHost,omitempty"` ModbusPort int `json:"modbusPort,omitempty"` // 0 means the standard 502 } // ankerStored is what we persist per user/org under pluginSettings.ankerSolix. type ankerStored struct { Config ankerConfig `json:"config"` // Enabled is the personal per-user opt-in (user layer). Default false. Enabled bool `json:"enabled"` // 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. type ankerSettingsDoc struct { AnkerSolix ankerStored `json:"ankerSolix"` } // ankerFieldView is one field's resolved state for the UI. type ankerFieldView struct { Effective string `json:"effective"` // resolved value in force (secrets/inherited creds masked) Own string `json:"own"` // the caller's own editable-layer value (secret masked) Source string `json:"source"` // global | org | user | unset Locked bool `json:"locked"` // set above the caller's editable layer } // ankerResolution is the fully-resolved Anker Solix state for one caller. type ankerResolution struct { eff ankerConfig // effective (unmasked) — used only server-side (probes) userOwn ankerConfig // caller's personal (L3) values (unmasked) orgOwn ankerConfig // organization (L2) values (unmasked) source map[string]string // field -> layer name (global|org|user|unset) isSuper bool // superadmin: manages the global layer in the panel canOrg bool // caller may edit the organization layer (org admin) available bool // global master switch (plugin enabled in the panel) orgEnabled bool // org gate (default true; gates the org's users) enabled bool // caller's personal enable flag // hiddenForOrg is what the global layer hides from everyone below it; // hiddenForUser adds what the caller's organization hides from its own users. // They gate both the pickers the clients draw and which layer's stored mode // is allowed to take effect. hiddenForOrg map[string]bool hiddenForUser map[string]bool orgHidden map[string]bool // the organization layer's own list, for its editor } // ankerLayerRank orders the cascade layers; a higher number is lower priority. var ankerLayerRank = map[string]int{"global": 1, "org": 2, "user": 3} // resolveAnker computes the cascade for a caller. userRaw is the caller's // 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"], ControlMode: g["controlMode"], ControlModesDisabled: g["controlModesDisabled"]} var oStored ankerStored if who.OrgID != "" { oStored, _ = s.orgAnker(ctx, who.OrgID) } oc := oStored.Config var uStored ankerStored if len(userRaw) > 0 { var d ankerSettingsDoc _ = json.Unmarshal(userRaw, &d) uStored = d.AnkerSolix } uc := uStored.Config res := ankerResolution{ source: map[string]string{}, userOwn: uc, orgOwn: oc, isSuper: who.isSuperadmin(), // An org admin may edit the organization layer in addition to their own // personal layer. Requires the service account (org writes go through it). canOrg: who.isManager() && !who.isSuperadmin() && who.OrgID != "" && s.pb.Configured(), available: masterEnabled, orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it enabled: uStored.Enabled, } // What each layer below may still be offered. The global list reaches // everyone; an organization's own list reaches only its users, so it narrows // the set once more on the way down. res.hiddenForOrg = parseControlModes(gc.ControlModesDisabled) res.orgHidden = parseControlModes(oc.ControlModesDisabled) res.hiddenForUser = map[string]bool{} for m := range res.hiddenForOrg { res.hiddenForUser[m] = true } if who.OrgID != "" { for m := range res.orgHidden { res.hiddenForUser[m] = true } } // Ordered layers, top (highest priority) first. type layer struct { name string c ankerConfig } layers := []layer{{"global", gc}} if who.OrgID != "" { layers = append(layers, layer{"org", oc}) } layers = append(layers, layer{"user", uc}) // Country resolves independently: the highest layer that sets it wins. res.source["country"] = "unset" for _, l := range layers { if v := strings.TrimSpace(l.c.Country); v != "" { res.eff.Country, res.source["country"] = v, l.name break } } // 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). // A mode hidden by a layer above is not merely absent from the picker: a value // stored below before it was hidden stops taking effect too, so switching a // mode off really does switch it off everywhere underneath. res.eff.ControlMode = ankerControlOff res.source["controlMode"] = "unset" for _, l := range layers { v := normalizeControlMode(l.c.ControlMode) if v == "" || res.hiddenAt(l.name)[v] { continue } 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" for _, l := range layers { if e := strings.TrimSpace(l.c.Email); e != "" { res.eff.Email, res.eff.Password, credSrc = e, l.c.Password, l.name break } } res.source["email"] = credSrc res.source["password"] = credSrc return res } // hiddenAt is the set of modes hidden from one cascade layer by the layers above // it. The global layer answers to nobody, so nothing is hidden from it. func (r ankerResolution) hiddenAt(layer string) map[string]bool { switch layer { case "org": return r.hiddenForOrg case "user": return r.hiddenForUser default: return map[string]bool{} } } // ankerLockedFor reports whether a field whose value comes from source is locked // for a caller whose editable layer is editable (i.e. the value is set above them). func ankerLockedFor(source, editable string) bool { if editable == "none" { return true // superadmin edits the global layer in the panel, not here } sr, ok := ankerLayerRank[source] if !ok { return false // unset — the caller may be the first to set it } return sr < ankerLayerRank[editable] } // ankerMaskPresent returns the secret mask when v is non-empty, else "". func ankerMaskPresent(v string) string { if strings.TrimSpace(v) != "" { return ankerSecretMask } return "" } // orgAnker reads an organization's stored Anker Solix settings (config + the org // gate) and its raw pluginSettings blob via the service account. Best effort: // zero values on any miss so callers proceed as if the org layer were empty. func (s *Server) orgAnker(ctx context.Context, orgID string) (ankerStored, json.RawMessage) { if orgID == "" || !s.pb.Configured() { return ankerStored{}, nil } data, status, err := s.pb.Raw(ctx, http.MethodGet, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(orgID)+"?fields=pluginSettings", nil) if err != nil || status != http.StatusOK { return ankerStored{}, nil } var rec struct { PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.Unmarshal(data, &rec) var doc ankerSettingsDoc if len(rec.PluginSettings) > 0 { _ = json.Unmarshal(rec.PluginSettings, &doc) } return doc.AnkerSolix, rec.PluginSettings } // mergeAnker applies a mutation to the ankerSolix entry of a pluginSettings blob, // preserving any other plugin keys, and returns the new blob. func mergeAnker(existing json.RawMessage, apply func(*ankerStored)) json.RawMessage { doc := map[string]json.RawMessage{} if len(existing) > 0 { _ = json.Unmarshal(existing, &doc) } if doc == nil { doc = map[string]json.RawMessage{} // existing was JSON null } var as ankerStored if raw, ok := doc["ankerSolix"]; ok { _ = json.Unmarshal(raw, &as) } apply(&as) b, _ := json.Marshal(as) doc["ankerSolix"] = b out, _ := json.Marshal(doc) return out } // ankerScopeView builds the masked field set for one editable scope. editable is // the layer the caller edits in this scope ("user" | "org" | "none"); a field is // locked when its effective value is set above that layer. func (s *Server) ankerScopeView(res ankerResolution, editable string) map[string]any { own := res.userOwn if editable == "org" { own = res.orgOwn } field := func(key, eff, ownv string, secret bool) ankerFieldView { src := res.source[key] locked := ankerLockedFor(src, editable) fv := ankerFieldView{Source: src, Locked: locked} switch { case secret: // Never expose a secret; show only presence. fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv) case key == "email" && locked: // Inherited email — hide the concrete value from a lower layer. fv.Effective, fv.Own = ankerMaskPresent(eff), ankerMaskPresent(ownv) default: fv.Effective, fv.Own = eff, ownv } return fv } // The modes this scope may pick from: everything the layers above it left // standing. A superadmin's read-only view answers as the user layer they are. hidden := res.hiddenForUser if editable == "org" { hidden = res.hiddenForOrg } out := 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), "controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false), }, "controlModes": controlModesOffered(hidden), } if editable == "org" { // The organization's own hide-list, which its admin edits here. Modes the // global layer already hid are not in controlModes above, so they simply // never come up. out["controlModesDisabled"] = controlModeList(res.orgHidden) } return out } // ankerView builds the masked, client-safe response body from a resolution. func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]any { out := map[string]any{ "available": res.available, "orgEnabled": res.orgEnabled, "enabled": res.enabled, "controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy) "controlModes": controlModesOffered(res.hiddenForUser), // modes still offered to this caller "role": who.Role, "orgId": who.OrgID, "canEditOrg": res.canOrg, "isSuperadmin": res.isSuper, } if res.isSuper { // Superadmin manages the global layer in the panel; here it is read-only. out["editableLayer"] = "none" out["scopes"] = map[string]any{"user": s.ankerScopeView(res, "none")} return out } scopes := map[string]any{"user": s.ankerScopeView(res, "user")} if res.canOrg { scopes["org"] = s.ankerScopeView(res, "org") } out["scopes"] = scopes return out } // ankerGate returns the reason the integration cannot run for this caller, or "" // when it can. requireOptIn additionally demands the personal enable flag, which // a live probe deliberately does not: the probe is how you check credentials // before switching the integration on. func ankerGate(res ankerResolution, requireOptIn bool) string { switch { case !res.available: return "The Anker Solix integration is disabled by the administrator" case !res.orgEnabled: return "The Anker Solix integration is disabled for your organization" case requireOptIn && !res.enabled: return "Enable the Anker Solix integration in Settings to load your chargers" case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "": return "Enter your Anker account email and password to connect" } return "" } // GET /api/integrations/anker-solix — resolved Anker Solix view for the caller. func (s *Server) handleGetAnker(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) writeJSON(w, http.StatusOK, s.ankerView(who, res)) } // PUT /api/integrations/anker-solix — save the caller's editable layer. Body: // {enabled?: bool, scope?: "user"|"org", config?: {email, password, country}}. // Fields locked above the caller are ignored; a password left at the mask is // preserved. func (s *Server) handlePutAnker(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 } var body struct { Enabled *bool `json:"enabled"` Scope string `json:"scope"` // "user" (default) | "org" (admins only) Config map[string]string `json:"config"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writeError(w, http.StatusBadRequest, "invalid json") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) // Resolve which layer this write targets. Everyone edits their own personal // (user) layer by default; an org admin may target the organization layer by // asking for scope "org". Superadmins are read-only here (they manage global // in the panel) and may only toggle their personal enable flag. editable := "user" switch { case res.isSuper: editable = "none" case strings.EqualFold(strings.TrimSpace(body.Scope), "org"): if !res.canOrg { writeError(w, http.StatusForbidden, "only an organization admin can edit organization settings") return } editable = "org" } // Build the new target-layer config from its current own values, overlaying // only fields the caller is allowed to change in this scope. newOwn := res.userOwn if editable == "org" { newOwn = res.orgOwn } applyField := func(key string, set func(*ankerConfig, string)) { v, ok := body.Config[key] if !ok || ankerLockedFor(res.source[key], editable) { return } if key == "password" && v == ankerSecretMask { return // keep current secret } set(&newOwn, strings.TrimSpace(v)) } 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) { m := normalizeControlMode(v) // A mode hidden above is not a choice this layer can make; keep whatever it // had rather than storing something that would never take effect. if m != "" && res.hiddenAt(editable)[m] { return } c.ControlMode = m }) if editable == "org" { // Only a layer with users under it has anything to hide from them. applyField("controlModesDisabled", func(c *ankerConfig, v string) { c.ControlModesDisabled = joinControlModes(parseControlModes(v)) }) } // Persist the organization layer (admins) via the service account. if editable == "org" { _, orgRaw := s.orgAnker(r.Context(), who.OrgID) newDoc := mergeAnker(orgRaw, func(as *ankerStored) { as.Config = newOwn // In the org scope the enable flag is the org master switch, stored // inverted (disabled) so absent means enabled. if body.Enabled != nil { as.Disabled = !*body.Enabled } }) _, st, err := s.pb.Raw(r.Context(), http.MethodPatch, "/api/collections/"+colOrgs+"/records/"+url.PathEscape(who.OrgID), map[string]json.RawMessage{"pluginSettings": newDoc}) if err != nil { writeUpstreamDown(w, err) return } if st != http.StatusOK { writeError(w, http.StatusBadGateway, "could not save organization settings") return } } // Persist the user record: the personal enable flag lives here (user/ // superadmin scope — in the org scope it targets the org gate instead), and // so does the personal config layer when this write targets the user scope. personalEnable := body.Enabled != nil && editable != "org" if personalEnable || editable == "user" { newDoc := mergeAnker(userRaw, func(as *ankerStored) { if personalEnable { as.Enabled = *body.Enabled } if editable == "user" { as.Config = newOwn } }) if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID, map[string]any{"pluginSettings": newDoc}, nil); err != nil { writePBError(w, err) return } } // Re-resolve and return the fresh view. fresh := s.userPluginSettings(r.Context(), who.ID) res2 := s.resolveAnker(r.Context(), who, fresh) writeJSON(w, http.StatusOK, s.ankerView(who, res2)) } // POST /api/integrations/anker-solix/health — live probe using the caller's // resolved config. Never returns secrets. func (s *Server) handleAnkerHealth(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) if reason := ankerGate(res, false); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": reason}}) return } cfg := map[string]string{ "email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country, } h, err := s.plugins.HealthCheckWith(r.Context(), ankerPlugin, cfg) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, map[string]any{"health": h}) } // GET /api/integrations/anker-solix/chargers — the caller's Anker EV chargers, // fetched server-side under their resolved credentials. Gated by the same // switches as the settings view (global master, org gate, personal opt-in, // credentials present); when any gate is off it returns 200 with an empty list // plus a reason, so the UI can degrade quietly rather than error. func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) if reason := ankerGate(res, true); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": reason}) return } cfg := map[string]string{ "email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country, } raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "chargers", nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } // The plugin already answers {chargers, count, boundCount?, warnings?}; relay // that document as-is so the UI sees exactly what the capability produced. writeJSON(w, http.StatusOK, json.RawMessage(raw)) } // GET /api/integrations/anker-solix/chargers/{sn}/details — every view the // account holds about one charger: the station record, the totals, the history, // the sessions, the OCPP backend, the cards, the sharing, the binding, the // firmware and the rest, plus the site's own views when the charger belongs to // one. Gated exactly like the charger list, and answered // the same way when a gate is off: 200 with nothing and the reason, because a // disconnected integration is a normal state with an answer. func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Request) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return } sn := strings.TrimSpace(r.PathValue("sn")) if sn == "" { writeError(w, http.StatusBadRequest, "charger serial required") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) if reason := ankerGate(res, true); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"sn": sn, "views": []any{}, "unavailable": true, "detail": reason}) return } cfg := map[string]string{ "email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country, } raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details", mustJSON(map[string]any{"sn": sn})) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, json.RawMessage(raw)) } // The two card writes. They are the only calls in the Anker connector that // change anything on the account, so both are gated exactly like the reads, // rate-limited beside the control commands — a card is who may start a charge, // which is the same actuator asked a slower question — and audited by serial and // card, with the number kept out of the log line: it is the credential itself. // Both answer with the card list as it stands after the write, so the caller // sees what the account holds rather than what an undocumented endpoint claimed. // ankerCardBody is what a card write is asked for. type ankerCardBody struct { CardNumber string `json:"cardNumber"` CardName string `json:"cardName"` } // POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards — add a card to // the charger, or rename one already on it. func (s *Server) handleAnkerCardSave(w http.ResponseWriter, r *http.Request) { who, sn, cfg, ok := s.ankerCardGate(w, r) if !ok { return } var body ankerCardBody if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&body) } if strings.TrimSpace(body.CardNumber) == "" { writeError(w, http.StatusBadRequest, "card number required") return } s.ankerCardWrite(w, r, who, cfg, "rfid-card-save", sn, map[string]any{ "sn": sn, "cardNumber": body.CardNumber, "cardName": body.CardName, }) } // DELETE /api/integrations/anker-solix/chargers/{sn}/rfid-cards/{number} — // remove one card, named in full. func (s *Server) handleAnkerCardDelete(w http.ResponseWriter, r *http.Request) { who, sn, cfg, ok := s.ankerCardGate(w, r) if !ok { return } number := strings.TrimSpace(r.PathValue("number")) if number == "" { writeError(w, http.StatusBadRequest, "card number required") return } s.ankerCardWrite(w, r, who, cfg, "rfid-card-delete", sn, map[string]any{ "sn": sn, "cardNumber": number, }) } // POST /api/integrations/anker-solix/chargers/{sn}/rfid-cards/scan — open the // charger's card reader and answer with the card someone taps on it. Takes as // long as the window does, about twenty seconds, and answers either way: a // window that closed with nothing tapped is an answer, not a timeout. func (s *Server) handleAnkerCardScan(w http.ResponseWriter, r *http.Request) { who, sn, cfg, ok := s.ankerCardGate(w, r) if !ok { return } s.ankerCardWrite(w, r, who, cfg, "rfid-card-scan", sn, map[string]any{"sn": sn}) } // GET /api/integrations/anker-solix/chargers/{sn}/rfid-cards/charger — the list // of cards the charger itself holds, asked of the device with 0104 rather than // of the account. // // The two lists are written together and can still come apart: a card the // account has forgotten still opens the charger until the device is told // otherwise, and the account's copy is the only one every other view here // draws. Asking the device is the only way to see the difference. It answers // with UIDs and nothing else — the charger has no field for a card's name. func (s *Server) handleAnkerChargerCards(w http.ResponseWriter, r *http.Request) { who, sn, cfg, ok := s.ankerCardGate(w, r) if !ok { return } s.ankerCardWrite(w, r, who, cfg, "rfid-cards-charger", sn, map[string]any{"sn": sn}) } // ankerCardGate is everything a card call needs before it may run: a caller, a // serial, an integration that is on and has credentials, and a rate limit. A // gate that is off answers 409 rather than the reads' 200-with-a-reason: a write // that did not happen is not a state to render, it is a request that failed. func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, string, map[string]string, bool) { who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") return nil, "", nil, false } sn := strings.TrimSpace(r.PathValue("sn")) if sn == "" { writeError(w, http.StatusBadRequest, "charger serial required") return nil, "", nil, false } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveAnker(r.Context(), who, userRaw) if reason := ankerGate(res, true); reason != "" { writeError(w, http.StatusConflict, reason) return nil, "", nil, false } if !s.ctlRL.allow(who.ID + "|" + sn) { writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down") return nil, "", nil, false } return who, sn, map[string]string{ "email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country, }, true } // ankerCardWrite runs one card capability and relays its document. The audit // line names the charger and how the write went, never the card number. func (s *Server) ankerCardWrite(w http.ResponseWriter, r *http.Request, who *callerIdentity, cfg map[string]string, action, sn string, params map[string]any) { raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, action, mustJSON(params)) if err != nil { s.auditControl(who, sn, action, nil, "failed", err) writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } s.auditControl(who, sn, action, nil, "ok", nil) writeJSON(w, http.StatusOK, json.RawMessage(raw)) }