The control-mode picker offered all five paths to everyone, always. When one of them is broken in a deployment — OCPP is, right now — there was nothing to do about it: the superadmin could pick a different mode for the global layer, but the option stayed in every organization's and every user's dropdown, waiting to be chosen. The cascade could impose a mode. It could not withdraw one. So each layer now carries a second, separate thing: a list of the modes it hides from the layers below it. controlModesDisabled sits beside controlMode, on the global layer as a plugin config field and on an organization as part of the same pluginSettings blob its credentials already live in. A superadmin ticking Own CSMS and Proxy CSMS takes both OCPP paths out of every picker underneath; an org admin ticking Modbus takes it out of their own users'. Three decisions are worth naming. A hide-list governs the layers below, not the layer holding it. The superadmin can keep running Proxy globally while hiding it from everyone else, which is what you want while a mode is being repaired rather than retired: the operator testing the fix is the one person who still needs to select it. The alternative, a list that also invalidates its own layer's choice, would have made the panel contradict itself — a mode chosen in one field and switched off in the one below it. But a hidden mode really is hidden, not merely absent from a dropdown. A user who had picked Proxy last month stops resolving to Proxy the moment the superadmin hides it, and falls back to monitoring only. Filtering the picker alone would have left every existing charger on the broken path and quietly disagreed with the list the operator had just filled in. Resolution now walks the layers accumulating what each hides from the next, so a stored value only takes effect if the layers above it still permit it. And off is never hideable. It is what a charger falls back to and what an empty cascade resolves to, so a layer that could take it away could leave the layer below with a picker holding no valid choice at all. It is not among the checkboxes in any of the three clients, and the parser drops it if it arrives anyway. The panel needed a field shape it did not have — several options, any number chosen — so ConfigField grows a "multiselect" type, stored as the comma-separated string that fits the flat map every other field already uses. That is generic: any plugin can declare one now, and the PUT body is unchanged. The phone's field specs grew the same way, a scopeOptions hook that narrows a declared option list to what the server still offers, rather than teaching the integration card about control modes specifically. Both clients clamp a stored mode that has since been hidden back to off before drawing the picker, so the box shows what will actually happen rather than a choice that would be dropped on save. Verified: Go tests pass, both frontends build, flutter analyze is clean, and the panel's new checkbox field was rendered against the real stylesheet. The end-to-end path — superadmin hides a mode, an org admin and then a user reload and find it gone — has not been walked on a live stack; the panel is embedded in the Go binary, so the remote deployment needs a rebuild before any of this is visible there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
836 lines
32 KiB
Go
836 lines
32 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 = "••••••••"
|
|
|
|
// 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))
|
|
}
|