diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go new file mode 100644 index 0000000..76a2993 --- /dev/null +++ b/API Server/internal/api/integrations_ankersolix.go @@ -0,0 +1,477 @@ +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): the plugin's config in plugins.json, 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 = "••••••••" +) + +// ankerConfig is one layer's Anker Solix settings. +type ankerConfig struct { + Email string `json:"email"` + Password string `json:"password"` + Country string `json:"country"` +} + +// 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"` +} + +// 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"]} + + 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 + } + } + + // 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), + }, + } +} + +// 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, + "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) }) + + // 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 + } + // Relay the upstream JSON verbatim under "chargers". + writeJSON(w, http.StatusOK, map[string]any{"chargers": json.RawMessage(raw)}) +} diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index 941a7e1..6f32b25 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -47,6 +47,9 @@ // GET /api/integrations/toyota PUT /api/integrations/toyota // POST /api/integrations/toyota/health // GET /api/integrations/toyota/vehicles +// GET /api/integrations/anker-solix PUT /api/integrations/anker-solix +// POST /api/integrations/anker-solix/health +// GET /api/integrations/anker-solix/chargers // // # cars, service records, parts, shares // GET /api/cars POST /api/cars @@ -278,6 +281,10 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("PUT /api/integrations/toyota", s.handlePutToyota) mux.HandleFunc("POST /api/integrations/toyota/health", s.handleToyotaHealth) mux.HandleFunc("GET /api/integrations/toyota/vehicles", s.handleToyotaVehicles) + mux.HandleFunc("GET /api/integrations/anker-solix", s.handleGetAnker) + mux.HandleFunc("PUT /api/integrations/anker-solix", s.handlePutAnker) + mux.HandleFunc("POST /api/integrations/anker-solix/health", s.handleAnkerHealth) + mux.HandleFunc("GET /api/integrations/anker-solix/chargers", s.handleAnkerChargers) // Cars + sharing. mux.HandleFunc("GET /api/cars", s.listCars) diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index d35cfc1..e811205 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -231,6 +231,14 @@ export const api = { saveToyota: (body) => request("/integrations/toyota", { method: "PUT", body: JSON.stringify(body) }), testToyota: () => request("/integrations/toyota/health", { method: "POST" }), + // Anker Solix (V1 Smart EV Charger) — same cascade as Toyota. getAnkerSolix + // returns the resolved view (effective/own/locked per field, secrets and + // inherited emails masked); saveAnkerSolix writes the caller's editable layer; + // testAnkerSolix runs a live login probe under the resolved credentials. + getAnkerSolix: () => request("/integrations/anker-solix"), + saveAnkerSolix: (body) => request("/integrations/anker-solix", { method: "PUT", body: JSON.stringify(body) }), + testAnkerSolix: () => request("/integrations/anker-solix/health", { method: "POST" }), + // Settings — advanced / danger zone exportData: () => requestBlob("/me/export"), importData: (payload) => request("/me/import", { method: "POST", body: JSON.stringify(payload) }), diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 23275e9..0a5f2e8 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -187,7 +187,13 @@ "save": "Save", "saved": "Saved ✓", "connected": "Connected", - "notConnected": "Not connected" + "notConnected": "Not connected", + "ankerSolix": "Anker Solix (V1 Smart EV Charger)", + "ankerSolixDesc": "Sign in with your Anker account to pull EV charger data from the Anker Solix cloud.", + "ankerEmail": "Anker account email", + "ankerPassword": "Anker account password", + "country": "Country", + "countryHint": "Two-letter country code of your Anker account (e.g. DE, GB, US)." }, "privacy": { diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 9283e4e..b363472 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -398,6 +398,117 @@ async function testToyotaConnection() { } } +// --- Integrations: Anker Solix (V1 Smart EV Charger) --- +// +// Same superadmin → org admin → user cascade as Toyota above; the fields are the +// Anker account email + password (resolved as a pair) and a country code. + +const anker = ref(null); // resolved view from the server +const ankerScope = ref("user"); // "user" | "org" (org admins only) +const ankerForm = ref({ email: "", password: "", country: "" }); +const ankerSaving = ref(false); +const ankerSaved = ref(false); +const ankerError = ref(""); +const ankerTesting = ref(false); +const ankerHealth = ref(null); // { status, detail } from the last test + +const ankerReadOnly = computed(() => !!anker.value?.isSuperadmin); +const ankerScopeKey = computed(() => (anker.value?.isSuperadmin ? "user" : ankerScope.value)); +const ankerScopeData = computed( + () => anker.value?.scopes?.[ankerScopeKey.value] || { editableLayer: "user", fields: {} } +); +const ankerEditingOrg = computed(() => ankerScopeKey.value === "org"); + +function ankerField(k) { + return ankerScopeData.value.fields?.[k] || { effective: "", own: "", source: "unset", locked: false }; +} +function ankerLocked(k) { + return ankerReadOnly.value || ankerField(k).locked; +} +const ankerEnabled = computed(() => + ankerEditingOrg.value ? anker.value?.orgEnabled : anker.value?.enabled +); +function ankerSourceLabel(k) { + const map = { global: "sourceGlobal", org: "sourceOrg", user: "sourceUser" }; + const key = map[ankerField(k).source] || "sourceGlobal"; + return t("settings.integrations.inheritedFrom", { source: t("settings.integrations." + key) }); +} + +function fillAnkerForm() { + const f = ankerScopeData.value.fields || {}; + ankerForm.value = { + email: f.email?.locked ? "" : f.email?.own || "", + password: "", + country: f.country?.locked ? "" : f.country?.own || "", + }; +} + +function applyAnkerView(body) { + anker.value = body; + if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user"; + fillAnkerForm(); +} + +async function loadAnkerSolix() { + try { + applyAnkerView(await api.getAnkerSolix()); + } catch (e) { + ankerError.value = e.message; + } +} + +watch(ankerScope, () => { + ankerError.value = ""; + ankerSaved.value = false; + ankerHealth.value = null; + fillAnkerForm(); +}); + +async function toggleAnker(v) { + ankerError.value = ""; + const org = ankerEditingOrg.value; + try { + applyAnkerView(await api.saveAnkerSolix(org ? { scope: "org", enabled: v } : { scope: "user", enabled: v })); + } catch (e) { + ankerError.value = e.message; + } +} + +async function saveAnkerSettings() { + ankerError.value = ""; + ankerSaving.value = true; + ankerSaved.value = false; + const config = {}; + for (const k of ["email", "password", "country"]) { + if (ankerLocked(k)) continue; + if (k === "password" && !ankerForm.value.password) continue; + config[k] = ankerForm.value[k]; + } + try { + applyAnkerView(await api.saveAnkerSolix({ scope: ankerScopeKey.value, config })); + ankerSaved.value = true; + setTimeout(() => (ankerSaved.value = false), 2000); + } catch (e) { + ankerError.value = e.message; + } finally { + ankerSaving.value = false; + } +} + +async function testAnkerConnection() { + ankerError.value = ""; + ankerHealth.value = null; + ankerTesting.value = true; + try { + const { health } = await api.testAnkerSolix(); + ankerHealth.value = health; + } catch (e) { + ankerError.value = e.message; + } finally { + ankerTesting.value = false; + } +} + function onLogout() { logout(); router.replace({ name: "login" }); @@ -532,6 +643,7 @@ onMounted(async () => { initDrafts(); await loadAvatar(); await loadToyota(); + await loadAnkerSolix(); }); onBeforeUnmount(() => { @@ -720,11 +832,11 @@ onBeforeUnmount(() => { -
+

{{ t("settings.integrations.title") }}

{{ t("settings.integrations.subtitle") }}

-
+

{{ t("settings.integrations.toyota") }}

@@ -839,6 +951,127 @@ onBeforeUnmount(() => {

{{ toyotaError }}

+ + +
+
+
+

{{ t("settings.integrations.ankerSolix") }}

+

{{ t("settings.integrations.ankerSolixDesc") }}

+
+ + {{ anker.enabled ? t("settings.integrations.connected") : t("settings.integrations.notConnected") }} + +
+ + +

{{ t("settings.integrations.unavailable") }}

+

+ {{ t("settings.integrations.orgDisabled") }} +

+ + + +

{{ ankerError }}

+