package api import ( "context" "encoding/json" "net/http" "net/url" "strconv" "strings" ) // This file mirrors integrations.go and integrations_ankersolix.go for the // Greencell HabuDen EV charger: the same three-layer cascade (global → org → // user) lets everyone point the integration at their own MQTT broker 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.greencell on the caller's organization record. // - user (L3): pluginSettings.greencell on the caller's own user record. // // Unlike the cloud connectors, what resolves here is a *broker*, not an account: // host, port, TLS and the broker credentials describe one endpoint and therefore // resolve together as a unit from the highest layer that supplies a host. Mixing // a host from the organization with a password from a user would address a broker // with credentials that were never meant for it. The charger serial, the QUERY // command topic and the listen window resolve on their own, because each // describes the charger rather than the endpoint. // // Enablement is strictly per-user (L3), gated by the global master switch (the // plugin being enabled in the panel) and, for org users, by the org gate. const ( greencellPlugin = "greencell" greencellSecretMask = "••••••••" ) // greencellConfig is one layer's Greencell settings. type greencellConfig struct { // Broker identity — these five resolve together (see the file comment). Host string `json:"host"` Port string `json:"port"` TLS string `json:"tls"` // "on" | "off"; empty means unset so the cascade continues Username string `json:"username"` Password string `json:"password"` // Serial is the charger to read; blank means "discover whatever is there". Serial string `json:"serial"` // CommandTopic is the optional MQTT topic a QUERY is sent on to make the // charger publish at once. Greencell does not document it, so it is left to // whoever found theirs; blank means read-only listening. CommandTopic string `json:"commandTopic"` // Timeout is the listen window in seconds — how long a read waits for the // charger to publish before answering with what it has. Timeout string `json:"timeout"` } // greencellStored is what we persist per user/org under pluginSettings.greencell. type greencellStored struct { Config greencellConfig `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"` } // greencellSettingsDoc is the pluginSettings JSON shape for the greencell key. type greencellSettingsDoc struct { Greencell greencellStored `json:"greencell"` } // greencellFieldView is one field's resolved state for the UI. type greencellFieldView struct { Effective string `json:"effective"` // resolved value in force (secrets/inherited identity 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 } // greencellResolution is the fully-resolved Greencell state for one caller. type greencellResolution struct { eff greencellConfig // effective (unmasked) — used only server-side (probes) userOwn greencellConfig // caller's personal (L3) values (unmasked) orgOwn greencellConfig // 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 } // greencellBrokerFields are the fields that resolve as one unit with the host. var greencellBrokerFields = []string{"host", "port", "tls", "username", "password"} // greencellLayerRank orders the cascade layers; a higher number is lower priority. var greencellLayerRank = map[string]int{"global": 1, "org": 2, "user": 3} // normalizeGreencellTLS maps a raw TLS value to "on"/"off", or "" when unset or // unrecognized so the cascade continues to the next layer. func normalizeGreencellTLS(v string) string { switch strings.ToLower(strings.TrimSpace(v)) { case "on", "true", "yes", "1", "tls", "mqtts": return "on" case "off", "false", "no", "0", "plain": return "off" default: return "" } } // normalizeGreencellPort validates a broker port, returning "" for anything that // is not a usable TCP port so a typo does not silently address port 0. func normalizeGreencellPort(v string) string { v = strings.TrimSpace(v) if v == "" { return "" } n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 65535 { return "" } return strconv.Itoa(n) } // normalizeGreencellTimeout validates the listen window in seconds. The plugin // clamps the value it is given as well; rejecting nonsense here keeps a bad entry // from being stored and shown back as if it had taken effect. func normalizeGreencellTimeout(v string) string { v = strings.TrimSpace(v) if v == "" { return "" } n, err := strconv.Atoi(v) if err != nil || n < 1 || n > 60 { return "" } return strconv.Itoa(n) } // resolveGreencell computes the cascade for a caller. userRaw is the caller's // pluginSettings blob (read from their user record). func (s *Server) resolveGreencell(ctx context.Context, who *callerIdentity, userRaw json.RawMessage) greencellResolution { g, masterEnabled, _ := s.plugins.RawConfig(greencellPlugin) gc := greencellConfig{ Host: g["host"], Port: g["port"], TLS: g["tls"], Username: g["username"], Password: g["password"], Serial: g["serial"], CommandTopic: g["commandTopic"], Timeout: g["timeout"], } var oStored greencellStored if who.OrgID != "" { oStored, _ = s.orgGreencell(ctx, who.OrgID) } oc := oStored.Config var uStored greencellStored if len(userRaw) > 0 { var d greencellSettingsDoc _ = json.Unmarshal(userRaw, &d) uStored = d.Greencell } uc := uStored.Config res := greencellResolution{ 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 greencellConfig } layers := []layer{{"global", gc}} if who.OrgID != "" { layers = append(layers, layer{"org", oc}) } layers = append(layers, layer{"user", uc}) // The broker resolves as a unit from the highest layer that names a host, so // an address is never combined with credentials from a different layer. brokerSrc := "unset" for _, l := range layers { if h := strings.TrimSpace(l.c.Host); h != "" { res.eff.Host = h res.eff.Port = normalizeGreencellPort(l.c.Port) res.eff.TLS = normalizeGreencellTLS(l.c.TLS) res.eff.Username = strings.TrimSpace(l.c.Username) res.eff.Password = l.c.Password brokerSrc = l.name break } } for _, f := range greencellBrokerFields { res.source[f] = brokerSrc } // The serial and the listen window say nothing about the broker, so each // resolves on its own: the highest layer that sets it wins. res.source["serial"] = "unset" for _, l := range layers { if v := strings.TrimSpace(l.c.Serial); v != "" { res.eff.Serial, res.source["serial"] = v, l.name break } } res.source["commandTopic"] = "unset" for _, l := range layers { if v := strings.TrimSpace(l.c.CommandTopic); v != "" { res.eff.CommandTopic, res.source["commandTopic"] = v, l.name break } } res.source["timeout"] = "unset" for _, l := range layers { if v := normalizeGreencellTimeout(l.c.Timeout); v != "" { res.eff.Timeout, res.source["timeout"] = v, l.name break } } return res } // greencellLockedFor reports whether a field whose value comes from source is // locked for a caller whose editable layer is editable (i.e. set above them). func greencellLockedFor(source, editable string) bool { if editable == "none" { return true // superadmin edits the global layer in the panel, not here } sr, ok := greencellLayerRank[source] if !ok { return false // unset — the caller may be the first to set it } return sr < greencellLayerRank[editable] } // greencellMaskPresent returns the secret mask when v is non-empty, else "". func greencellMaskPresent(v string) string { if strings.TrimSpace(v) != "" { return greencellSecretMask } return "" } // orgGreencell reads an organization's stored Greencell 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) orgGreencell(ctx context.Context, orgID string) (greencellStored, json.RawMessage) { if orgID == "" || !s.pb.Configured() { return greencellStored{}, 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 greencellStored{}, nil } var rec struct { PluginSettings json.RawMessage `json:"pluginSettings"` } _ = json.Unmarshal(data, &rec) var doc greencellSettingsDoc if len(rec.PluginSettings) > 0 { _ = json.Unmarshal(rec.PluginSettings, &doc) } return doc.Greencell, rec.PluginSettings } // mergeGreencell applies a mutation to the greencell entry of a pluginSettings // blob, preserving any other plugin keys, and returns the new blob. func mergeGreencell(existing json.RawMessage, apply func(*greencellStored)) 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 gs greencellStored if raw, ok := doc["greencell"]; ok { _ = json.Unmarshal(raw, &gs) } apply(&gs) b, _ := json.Marshal(gs) doc["greencell"] = b out, _ := json.Marshal(doc) return out } // greencellScopeView 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) greencellScopeView(res greencellResolution, editable string) map[string]any { own := res.userOwn if editable == "org" { own = res.orgOwn } field := func(key, eff, ownv string, secret bool) greencellFieldView { src := res.source[key] locked := greencellLockedFor(src, editable) fv := greencellFieldView{Source: src, Locked: locked} switch { case secret: // Never expose a secret; show only presence. fv.Effective, fv.Own = greencellMaskPresent(eff), greencellMaskPresent(ownv) case (key == "host" || key == "username") && locked: // An inherited broker address or account belongs to the layer above; // show only that it is set. fv.Effective, fv.Own = greencellMaskPresent(eff), greencellMaskPresent(ownv) default: fv.Effective, fv.Own = eff, ownv } return fv } return map[string]any{ "editableLayer": editable, "fields": map[string]greencellFieldView{ "host": field("host", res.eff.Host, own.Host, false), "port": field("port", res.eff.Port, normalizeGreencellPort(own.Port), false), "tls": field("tls", res.eff.TLS, normalizeGreencellTLS(own.TLS), false), "username": field("username", res.eff.Username, own.Username, false), "password": field("password", res.eff.Password, own.Password, true), "serial": field("serial", res.eff.Serial, own.Serial, false), "timeout": field("timeout", res.eff.Timeout, normalizeGreencellTimeout(own.Timeout), false), "commandTopic": field("commandTopic", res.eff.CommandTopic, own.CommandTopic, false), }, } } // greencellView builds the masked, client-safe response body from a resolution. func (s *Server) greencellView(who *callerIdentity, res greencellResolution) map[string]any { out := map[string]any{ "available": res.available, "orgEnabled": res.orgEnabled, "enabled": res.enabled, "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.greencellScopeView(res, "none")} return out } scopes := map[string]any{"user": s.greencellScopeView(res, "user")} if res.canOrg { scopes["org"] = s.greencellScopeView(res, "org") } out["scopes"] = scopes return out } // greencellPluginConfig turns a resolution into the config map the plugin takes. func greencellPluginConfig(res greencellResolution) map[string]string { return map[string]string{ "host": res.eff.Host, "port": res.eff.Port, "tls": res.eff.TLS, "username": res.eff.Username, "password": res.eff.Password, "serial": res.eff.Serial, "commandTopic": res.eff.CommandTopic, "timeout": res.eff.Timeout, } } // greencellGate 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 settings // before turning it on). func greencellGate(res greencellResolution, requireOptIn bool) string { switch { case !res.available: return "The Greencell integration is disabled by the administrator" case !res.orgEnabled: return "The Greencell integration is disabled for your organization" case requireOptIn && !res.enabled: return "Enable the Greencell integration in Settings to load your chargers" case strings.TrimSpace(res.eff.Host) == "": return "Enter the address of the MQTT broker your charger publishes to" } return "" } // GET /api/integrations/greencell — resolved Greencell view for the caller. func (s *Server) handleGetGreencell(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.resolveGreencell(r.Context(), who, userRaw) writeJSON(w, http.StatusOK, s.greencellView(who, res)) } // PUT /api/integrations/greencell — save the caller's editable layer. Body: // {enabled?: bool, scope?: "user"|"org", config?: {host, port, tls, username, // password, serial, commandTopic, timeout}}. Fields locked above the caller are // ignored; a password left at the mask is preserved. func (s *Server) handlePutGreencell(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.resolveGreencell(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(*greencellConfig, string)) { v, ok := body.Config[key] if !ok || greencellLockedFor(res.source[key], editable) { return } if key == "password" && v == greencellSecretMask { return // keep current secret } set(&newOwn, strings.TrimSpace(v)) } applyField("host", func(c *greencellConfig, v string) { c.Host = v }) applyField("port", func(c *greencellConfig, v string) { c.Port = normalizeGreencellPort(v) }) applyField("tls", func(c *greencellConfig, v string) { c.TLS = normalizeGreencellTLS(v) }) applyField("username", func(c *greencellConfig, v string) { c.Username = v }) applyField("password", func(c *greencellConfig, v string) { c.Password = v }) applyField("serial", func(c *greencellConfig, v string) { c.Serial = strings.ToUpper(v) }) applyField("commandTopic", func(c *greencellConfig, v string) { c.CommandTopic = v }) applyField("timeout", func(c *greencellConfig, v string) { c.Timeout = normalizeGreencellTimeout(v) }) // Persist the organization layer (admins) via the service account. if editable == "org" { _, orgRaw := s.orgGreencell(r.Context(), who.OrgID) newDoc := mergeGreencell(orgRaw, func(gs *greencellStored) { gs.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 { gs.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 := mergeGreencell(userRaw, func(gs *greencellStored) { if personalEnable { gs.Enabled = *body.Enabled } if editable == "user" { gs.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.resolveGreencell(r.Context(), who, fresh) writeJSON(w, http.StatusOK, s.greencellView(who, res2)) } // POST /api/integrations/greencell/health — live probe using the caller's // resolved config: connect to the broker and ask any charger to announce itself. // Never returns secrets. func (s *Server) handleGreencellHealth(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.resolveGreencell(r.Context(), who, userRaw) if reason := greencellGate(res, false); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"health": map[string]any{"status": "down", "detail": reason}}) return } h, err := s.plugins.HealthCheckWith(r.Context(), greencellPlugin, greencellPluginConfig(res)) 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/greencell/chargers — the chargers that answer a discovery // broadcast on the caller's resolved broker. Gated by the same switches as the // settings view; 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) handleGreencellChargers(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.resolveGreencell(r.Context(), who, userRaw) if reason := greencellGate(res, true); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"chargers": []any{}, "unavailable": true, "detail": reason}) return } raw, err := s.plugins.InvokeWith(r.Context(), greencellPlugin, greencellPluginConfig(res), "chargers", nil) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } // The plugin already answers {"chargers": [...]}; relay it verbatim. writeJSON(w, http.StatusOK, json.RawMessage(raw)) } // GET /api/integrations/greencell/chargers/{sn}/state — one charger's live state, // read off the broker under the caller's resolved config. func (s *Server) handleGreencellChargerState(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 is required") return } userRaw := s.userPluginSettings(r.Context(), who.ID) res := s.resolveGreencell(r.Context(), who, userRaw) if reason := greencellGate(res, true); reason != "" { writeJSON(w, http.StatusOK, map[string]any{"unavailable": true, "detail": reason}) return } params, _ := json.Marshal(map[string]string{"sn": sn}) raw, err := s.plugins.InvokeWith(r.Context(), greencellPlugin, greencellPluginConfig(res), "charger-state", params) if err != nil { writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) return } writeJSON(w, http.StatusOK, json.RawMessage(raw)) }