Files
DriverVault/API Server/internal/api/integrations_ankersolix.go
T
tajniak81andClaude Opus 5 e138fad3f4 Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever
asked for, and its name says exactly what it withholds. A charger that belongs to
a system is not in it. Its userBindEvChargersCount, though, counts every charger
bound to the account — so an owner with two chargers in a system got "authenticated;
2 EV charger(s) bound to account" from the health probe and an empty list from the
capability that is supposed to show them. A working login that finds nothing.

So the capability now asks every view the cloud has and merges them by serial. The
standalone list still answers for chargers standing on their own; get_site_list
walks the systems and reads each one through get_scen_info, falling back to
get_system_running_info where that is silent — the power-service / HES split
charger-state already knows; and get_relate_and_bind_devices contributes model,
firmware and the Wi-Fi flag, and discovers anything in the A519 family that the
first two missed. Whichever way a charger was registered, one of the three has it.

The merge is first-writer-wins per field rather than last view overwriting: the
standalone record knows the name, the site record knows the live state, and neither
should blank what the other established. A view that fails is a warning on the
document instead of an error on the call, because one dead endpoint should not
cost the chargers the other two found. Only losing all three is a failure. When
nothing comes back at all the response says so in its own words and names the
remaining suspect — country picks the regional server, and the wrong one
authenticates happily and shows an empty account.

The other half of "not showing any chargers" was that neither client ever showed a
list. The serial was a text box, and the number is printed on a charger hanging on
a wall. Both apps now list what the account holds — name, serial, model, site,
state, an offline badge — and hand the serial to the OCPP control card instead of
asking anyone to go and read it. Where control is off the list still stands on its
own, as the answer to the first question an owner has after entering credentials.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 20:13:36 +02:00

531 lines
19 KiB
Go

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 = "••••••••"
// 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 records a per-charger OCPP control token. 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"`
}
// 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
}
// 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"]}
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,
}
// 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).
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"
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
}
// 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
}
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),
"controlMode": field("controlMode", res.eff.ControlMode, own.ControlMode, false),
},
}
}
// 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 OCPP control mode (off|own|proxy)
"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
}
// 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) { c.ControlMode = normalizeControlMode(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)
down := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": detail}})
}
switch {
case !res.available:
down("The Anker Solix integration is disabled by the administrator")
return
case !res.orgEnabled:
down("The Anker Solix integration is disabled for your organization")
return
case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "":
down("Enter your Anker account email and password to connect")
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)
unavailable := func(detail string) {
writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": detail})
}
switch {
case !res.available:
unavailable("The Anker Solix integration is disabled by the administrator")
return
case !res.orgEnabled:
unavailable("The Anker Solix integration is disabled for your organization")
return
case !res.enabled:
unavailable("Enable the Anker Solix integration in Settings to load your chargers")
return
case strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "":
unavailable("Enter your Anker account email and password to connect")
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))
}