The HabuDen has no cloud API to connect to. It is commissioned over Bluetooth in
the Greencell GC app, pointed at an MQTT broker the owner runs, and from then on
publishes there — so the connector is an MQTT client rather than an HTTP one,
and nothing in it reaches Greencell. The wire contract is Home Assistant's own
greencell component and the greencell_client 1.0.3 library beneath it, which is
the only published description of the topics: a BROADCAST on /greencell/broadcast
draws device announcements, and /greencell/evse/{sn}/ carries current in
milliamps, voltage, power under "momentary", the EVSE state, and the access level
chosen in the app.
That meant an MQTT client, and the server takes no dependencies, so internal/mqtt
is hand-rolled the way internal/ocpp's RFC 6455 layer is. It is scoped to what
this connector needs and says so: QoS 0 for everything we send, clean session,
no reconnect — a connection lives for one plugin call, which is exactly how the
manager builds and tears down an instance. Inbound PUBLISH is accepted at QoS 0,
1 and 2 with the acknowledgements each requires, because the QoS of a delivery is
the broker's choice and not ours; an unacknowledged QoS 1 is redelivered forever.
Read-only, and the reason is worth writing down rather than rediscovering. A
device in EXECUTE mode accepts START, STOP, SET_CURRENT and QUERY — but the topic
those go to appears in no source: not Greencell's integration page, not
greencell_client, and Home Assistant ships sensor-only for that same reason.
Publishing to a guessed topic would be a control feature whose failure mode is a
driver believing they stopped a charge. So the access level is reported, and
commandTopic is the seam: an operator who has watched their own broker and found
theirs sets it, and a state read then sends QUERY — the one command a READ-mode
device also honours — instead of waiting out the charger's publish cadence. The
day the topic is public, control is a payload away from the same field.
What the cascade resolves here is a broker, not an account, so host, port, TLS and
credentials resolve together from the highest layer that names a host: an
organization's address paired with a user's password would address a broker with
credentials never meant for it. The serial, the QUERY topic and the listen window
each describe the charger rather than the endpoint, so each resolves on its own.
Two reading rules the tests pin. A phase the device did not report stays nil
rather than zero, because zero amps on a charger is a real measurement — a JSON
null decoding to 0.0 was a live bug until a test caught it — and a partial read
returns with received/complete flags instead of failing, since a device that
publishes some topics on a slower cadence is still worth reading. And a reachable
broker with no charger on it is degraded, not down: the half we configure works
and the missing half is the device. The plugin's end-to-end tests run against an
in-process broker written to the raw wire format, so a bug in the client cannot
hide behind a matching bug in the fixture.
The apps get the third connector card. The panel needed nothing — it renders a
plugin's ConfigFields itself — but the per-user panes are still hand-written per
integration, which is now three near-copies and the argument for the generic
version already noted in the plugins README. The web form splits the broker from
the charger because the server resolves them differently. The phone card is a
declarative config against the shared widget, which gained a number field type, a
degraded state that reads amber rather than red, and a fix for a locked field
that was covering its own displayed value with dots. Twenty keys in three
languages across both apps; Greencell, HabuDen and the literal QUERY join the
proper nouns that stay in English.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
606 lines
22 KiB
Go
606 lines
22 KiB
Go
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))
|
|
}
|