diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go
index c0660eb..e5df8df 100644
--- a/API Server/internal/api/integrations_ankersolix.go
+++ b/API Server/internal/api/integrations_ankersolix.go
@@ -524,6 +524,7 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
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)})
+ // 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))
}
diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go
index 3b2f273..adabbb5 100644
--- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go
+++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go
@@ -174,7 +174,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
Category: plugins.CategoryChargers,
AuthType: plugins.AuthBasic,
Capabilities: []plugins.Capability{
- {ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."},
+ {ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site and bound-device views (see chargers.go)."},
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
@@ -280,8 +280,11 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
pp.SiteID = strings.TrimSpace(pp.SiteID)
pp.VehicleID = strings.TrimSpace(pp.VehicleID)
- // charger-state fans out over two endpoints and returns a derived document,
- // so it does not fit the single-endpoint dispatch below.
+ // chargers and charger-state both fan out over several endpoints and return
+ // a derived document, so they do not fit the single-endpoint dispatch below.
+ if action == "chargers" {
+ return p.accountChargers(ctx)
+ }
if action == "charger-state" {
if pp.SiteID == "" {
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
@@ -296,8 +299,6 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
needSite bool
)
switch action {
- case "chargers":
- endpoint, payload = epStandaloneChargers, map[string]any{}
case "devices":
endpoint, payload = epBindDevices, map[string]any{}
case "sites":
diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers.go b/API Server/internal/plugins/builtin/ankersolix/chargers.go
new file mode 100644
index 0000000..9aea526
--- /dev/null
+++ b/API Server/internal/plugins/builtin/ankersolix/chargers.go
@@ -0,0 +1,358 @@
+package ankersolix
+
+// The account's chargers do not all live in one place: which cloud view a
+// charger shows up in depends on how it was set up in the Anker app, and no
+// single endpoint sees all of them.
+//
+// - get_user_bind_and_not_in_station_evchargers lists only the chargers that
+// are NOT part of a station/system. Its userBindEvChargersCount, though,
+// counts every charger bound to the account — so an account whose chargers
+// all sit in a station answers "2 chargers bound" with an empty list, which
+// is how a perfectly good login ends up showing nothing.
+// - a charger that belongs to a system appears in that system's view instead:
+// get_scen_info for power-service sites, get_system_running_info for HES
+// ones (the same split chargerState handles).
+// - get_relate_and_bind_devices knows every bound device with its model,
+// firmware and Wi-Fi state, but nothing about charging.
+//
+// accountChargers therefore asks all three and merges the answers by serial, so
+// the list matches what the mobile app shows however the chargers were
+// registered.
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "strconv"
+ "strings"
+)
+
+// evChargerPNPrefix matches the EV-charger product family (A5191 is the V1 Smart
+// EV Charger); a bound device outside it is other Anker hardware on the account.
+const evChargerPNPrefix = "A519"
+
+// accountCharger is one charger, merged from every view that reported it. A
+// field no view supplied stays empty rather than guessed.
+type accountCharger struct {
+ SN string `json:"sn"`
+ Name string `json:"name,omitempty"`
+ Model string `json:"model,omitempty"`
+ Firmware string `json:"firmware,omitempty"`
+ SiteID string `json:"siteId,omitempty"`
+ SiteName string `json:"siteName,omitempty"`
+ Sources []string `json:"sources"` // standalone | site | bound
+
+ Online *bool `json:"online,omitempty"`
+ Status *int `json:"status,omitempty"`
+ StatusDesc string `json:"statusDesc,omitempty"`
+ Power string `json:"power,omitempty"`
+ OcppStatus *int `json:"ocppStatus,omitempty"`
+ OcppStatusDesc string `json:"ocppStatusDesc,omitempty"`
+}
+
+// chargerInventory merges chargers by serial, keeping the order they were first
+// seen in so the output is stable across polls.
+type chargerInventory struct {
+ order []string
+ byID map[string]*accountCharger
+}
+
+func newInventory() *chargerInventory {
+ return &chargerInventory{byID: map[string]*accountCharger{}}
+}
+
+// get returns the record for sn, creating it on first sight, and notes the view
+// it was seen in.
+func (inv *chargerInventory) get(sn, source string) *accountCharger {
+ c, ok := inv.byID[sn]
+ if !ok {
+ c = &accountCharger{SN: sn, Sources: []string{}}
+ inv.byID[sn] = c
+ inv.order = append(inv.order, sn)
+ }
+ for _, s := range c.Sources {
+ if s == source {
+ return c
+ }
+ }
+ c.Sources = append(c.Sources, source)
+ return c
+}
+
+// has reports whether a serial is already known — a bound device enriches a
+// charger we have already found even when its product code is unfamiliar.
+func (inv *chargerInventory) has(sn string) bool {
+ _, ok := inv.byID[sn]
+ return ok
+}
+
+// list returns the merged chargers in discovery order.
+func (inv *chargerInventory) list() []accountCharger {
+ out := make([]accountCharger, 0, len(inv.order))
+ for _, sn := range inv.order {
+ out = append(out, *inv.byID[sn])
+ }
+ return out
+}
+
+// addStandalone reads the chargers that sit outside any station.
+func (inv *chargerInventory) addStandalone(body []byte) {
+ for _, m := range dataList(body, "evChargers", "evChargerList", "chargerList", "list") {
+ sn := pickString(m, "evChargerSn", "device_sn", "deviceSn", "sn")
+ if sn == "" {
+ continue
+ }
+ c := inv.get(sn, "standalone")
+ fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "name"))
+ fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn"))
+ fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId"))
+ fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version"))
+ if n, ok := pickInt(m, "evChargerStatus", "operating_state", "status"); ok {
+ setChargerStatus(c, n)
+ }
+ if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
+ c.Online = &b
+ }
+ }
+}
+
+// addStates folds a site view's normalized chargers into the inventory and
+// reports how many that view carried.
+func (inv *chargerInventory) addStates(states []evChargerState, siteName string) int {
+ for _, st := range states {
+ c := inv.get(st.SN, "site")
+ fillString(&c.Name, st.Name)
+ fillString(&c.SiteID, st.SiteID)
+ fillString(&c.SiteName, siteName)
+ fillString(&c.Power, st.Power)
+ if st.Status != nil && c.Status == nil {
+ s := *st.Status
+ c.Status, c.StatusDesc = &s, st.StatusDesc
+ }
+ if st.OcppStatus != nil && c.OcppStatus == nil {
+ o := *st.OcppStatus
+ c.OcppStatus, c.OcppStatusDesc = &o, st.OcppStatusDesc
+ }
+ }
+ return len(states)
+}
+
+// addBound enriches known chargers from the account's bound-device list, and
+// discovers any device whose product code is in the EV-charger family.
+func (inv *chargerInventory) addBound(body []byte) {
+ for _, m := range dataList(body, "data", "device_list", "devices", "list") {
+ sn := pickString(m, "device_sn", "deviceSn", "sn")
+ if sn == "" {
+ continue
+ }
+ pn := pickString(m, "device_pn", "product_code", "pn")
+ if !inv.has(sn) && !strings.HasPrefix(strings.ToUpper(pn), evChargerPNPrefix) {
+ continue // some other Anker device on the same account
+ }
+ c := inv.get(sn, "bound")
+ fillString(&c.Name, pickString(m, "device_name", "alias_name", "name"))
+ fillString(&c.Model, pn)
+ fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version"))
+ fillString(&c.SiteID, pickString(m, "site_id", "siteId"))
+ if b, ok := pickBool(m, "wifi_online", "online", "is_online"); ok {
+ c.Online = &b
+ }
+ }
+}
+
+// siteRef is one system (site) registered on the account.
+type siteRef struct{ ID, Name string }
+
+// siteList returns the account's systems — the entry point for every
+// site-scoped view.
+func (p *Plugin) siteList(ctx context.Context) ([]siteRef, error) {
+ body, err := p.apiRequest(ctx, epSiteList, map[string]any{})
+ if err != nil {
+ return nil, err
+ }
+ var env struct {
+ Data struct {
+ SiteList []struct {
+ SiteID string `json:"site_id"`
+ SiteName string `json:"site_name"`
+ } `json:"site_list"`
+ } `json:"data"`
+ }
+ if err := json.Unmarshal(body, &env); err != nil {
+ return nil, fmt.Errorf("anker-solix: decode site list: %w", err)
+ }
+ out := make([]siteRef, 0, len(env.Data.SiteList))
+ for _, s := range env.Data.SiteList {
+ if s.SiteID != "" {
+ out = append(out, siteRef{ID: s.SiteID, Name: s.SiteName})
+ }
+ }
+ return out, nil
+}
+
+// accountChargers lists every EV charger on the account by merging the views
+// described at the top of this file. A view that fails is recorded as a warning
+// and the others still answer; only losing all of them is an error.
+func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) {
+ inv := newInventory()
+ var warnings []string
+ views, failed := 0, 0
+ fail := func(what string, err error) {
+ failed++
+ warnings = append(warnings, what+": "+shorten(err.Error()))
+ }
+
+ // 1. Chargers registered on their own, outside any station.
+ views++
+ boundCount := -1
+ if body, err := p.apiRequest(ctx, epStandaloneChargers, map[string]any{}); err != nil {
+ fail("standalone chargers", err)
+ } else {
+ if n, ok := countChargers(body); ok {
+ boundCount = n
+ }
+ inv.addStandalone(body)
+ }
+
+ // 2. Chargers that belong to a system, one site at a time.
+ views++
+ sites, err := p.siteList(ctx)
+ if err != nil {
+ fail("sites", err)
+ }
+ for _, st := range sites {
+ found := 0
+ if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": st.ID}); err == nil {
+ found = inv.addStates(parseScenePiles(body, st.ID), st.Name)
+ }
+ if found == 0 {
+ // Not a power-service site, or it reported no pile: try the HES view.
+ if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": st.ID}); err == nil {
+ inv.addStates(parseHesChargers(body, st.ID), st.Name)
+ }
+ }
+ }
+
+ // 3. Bound devices: model, firmware and the Wi-Fi online flag.
+ views++
+ if body, err := p.apiRequest(ctx, epBindDevices, map[string]any{}); err != nil {
+ fail("bound devices", err)
+ } else {
+ inv.addBound(body)
+ }
+
+ if failed == views {
+ return nil, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; "))
+ }
+
+ out := inv.list()
+ doc := map[string]any{"chargers": out, "count": len(out)}
+ if boundCount >= 0 {
+ doc["boundCount"] = boundCount
+ }
+ if len(warnings) > 0 {
+ doc["warnings"] = warnings
+ }
+ if len(out) == 0 {
+ // An account that owns chargers but exposes none through any view is
+ // almost always pointed at the wrong regional server.
+ doc["detail"] = "No EV charger was returned by any view. If the account does own one, check the country setting — it selects the Anker server, and the wrong one logs in but shows nothing."
+ }
+ return json.Marshal(doc)
+}
+
+// setChargerStatus records a raw operating-state code and its name; the first
+// view to report one wins.
+func setChargerStatus(c *accountCharger, code int) {
+ if c.Status != nil {
+ return
+ }
+ c.Status, c.StatusDesc = &code, statusName(code)
+}
+
+// dataList returns the first array of objects found under one of keys inside a
+// response's "data" object. The charger endpoints do not share a field name and
+// Anker has renamed them before, so the lookup is by candidate key rather than
+// by a fixed struct.
+func dataList(body []byte, keys ...string) []map[string]any {
+ var env struct {
+ Data map[string]any `json:"data"`
+ }
+ if err := json.Unmarshal(body, &env); err != nil || env.Data == nil {
+ return nil
+ }
+ for _, k := range keys {
+ raw, ok := env.Data[k].([]any)
+ if !ok {
+ continue
+ }
+ out := make([]map[string]any, 0, len(raw))
+ for _, item := range raw {
+ if m, ok := item.(map[string]any); ok {
+ out = append(out, m)
+ }
+ }
+ if len(out) > 0 {
+ return out
+ }
+ }
+ return nil
+}
+
+// pickString returns the first non-empty value among keys, as a string.
+func pickString(m map[string]any, keys ...string) string {
+ for _, k := range keys {
+ switch v := m[k].(type) {
+ case string:
+ if s := strings.TrimSpace(v); s != "" {
+ return s
+ }
+ case float64:
+ return strconv.FormatFloat(v, 'f', -1, 64)
+ }
+ }
+ return ""
+}
+
+// pickInt returns the first numeric value among keys.
+func pickInt(m map[string]any, keys ...string) (int, bool) {
+ for _, k := range keys {
+ switch v := m[k].(type) {
+ case float64:
+ return int(v), true
+ case string:
+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
+ return n, true
+ }
+ }
+ }
+ return 0, false
+}
+
+// pickBool returns the first boolean-ish value among keys.
+func pickBool(m map[string]any, keys ...string) (bool, bool) {
+ for _, k := range keys {
+ switch v := m[k].(type) {
+ case bool:
+ return v, true
+ case float64:
+ return v != 0, true
+ case string:
+ switch strings.ToLower(strings.TrimSpace(v)) {
+ case "true", "1", "yes":
+ return true, true
+ case "false", "0", "no":
+ return false, true
+ }
+ }
+ }
+ return false, false
+}
+
+// fillString sets dst only when it is still empty, so the first view that knows
+// a value wins and later ones only fill gaps.
+func fillString(dst *string, v string) {
+ if *dst == "" {
+ *dst = strings.TrimSpace(v)
+ }
+}
diff --git a/API Server/internal/plugins/builtin/ankersolix/chargers_test.go b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go
new file mode 100644
index 0000000..de04a79
--- /dev/null
+++ b/API Server/internal/plugins/builtin/ankersolix/chargers_test.go
@@ -0,0 +1,157 @@
+package ankersolix
+
+import "testing"
+
+// A charger that sits in a station is absent from the standalone list even
+// though the account count includes it — the case that made a good login look
+// like an account with no chargers.
+func TestStandaloneListMissesStationChargers(t *testing.T) {
+ body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`)
+ inv := newInventory()
+ inv.addStandalone(body)
+ if got := len(inv.list()); got != 0 {
+ t.Fatalf("standalone view: got %d chargers, want 0", got)
+ }
+ if n, ok := countChargers(body); !ok || n != 2 {
+ t.Fatalf("countChargers = %d, %v; want 2, true", n, ok)
+ }
+
+ // The site view is where those two actually live.
+ scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
+ {"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
+ {"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`)
+ inv.addStates(parseScenePiles(scene, "site-1"), "Home")
+ list := inv.list()
+ if len(list) != 2 {
+ t.Fatalf("after site view: got %d chargers, want 2", len(list))
+ }
+ if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" {
+ t.Fatalf("first charger = %+v", list[0])
+ }
+ if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" {
+ t.Fatalf("status/power not carried over: %+v", list[0])
+ }
+ if list[0].OcppStatusDesc != "connected" {
+ t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc)
+ }
+}
+
+func TestAddStandaloneReadsHesFieldNames(t *testing.T) {
+ body := []byte(`{"code":0,"data":{"evChargers":[
+ {"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true},
+ {"evChargerName":"nameless"}]}}`)
+ inv := newInventory()
+ inv.addStandalone(body)
+ list := inv.list()
+ if len(list) != 1 {
+ t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list))
+ }
+ c := list[0]
+ if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging {
+ t.Fatalf("charger = %+v", c)
+ }
+ if c.Online == nil || !*c.Online {
+ t.Fatalf("online = %v, want true", c.Online)
+ }
+ if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
+ t.Fatalf("sources = %v", c.Sources)
+ }
+}
+
+// Bound devices discover EV chargers by product family and enrich chargers the
+// other views already found — without dragging in the rest of the account.
+func TestAddBoundFiltersAndEnriches(t *testing.T) {
+ inv := newInventory()
+ scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
+ {"device_sn":"EVSN1","operating_state":0}]}}}`)
+ inv.addStates(parseScenePiles(scene, "site-1"), "Home")
+
+ bound := []byte(`{"code":0,"data":{"data":[
+ {"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true},
+ {"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"},
+ {"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`)
+ inv.addBound(bound)
+
+ list := inv.list()
+ if len(list) != 2 {
+ t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list))
+ }
+ if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" {
+ t.Fatalf("enriched charger = %+v", list[0])
+ }
+ if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" {
+ t.Fatalf("sources = %v, want [site bound]", list[0].Sources)
+ }
+ if list[1].SN != "EVSN9" || list[1].Name != "Carport" {
+ t.Fatalf("discovered charger = %+v", list[1])
+ }
+}
+
+// The site view knows the live state; the standalone view saw the charger
+// first. Merging must not let the later view blank what the earlier one knew.
+func TestMergeKeepsFirstKnownValues(t *testing.T) {
+ inv := newInventory()
+ inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
+ inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[
+ {"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home")
+
+ list := inv.list()
+ if len(list) != 1 {
+ t.Fatalf("got %d chargers, want 1", len(list))
+ }
+ c := list[0]
+ if c.Name != "Garage" {
+ t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name)
+ }
+ if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" {
+ t.Fatalf("site view did not fill the gaps: %+v", c)
+ }
+}
+
+func TestDataListCandidateKeys(t *testing.T) {
+ body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`)
+ got := dataList(body, "evChargers", "chargerList")
+ if len(got) != 2 {
+ t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got))
+ }
+ if dataList([]byte(`not json`), "data") != nil {
+ t.Fatal("malformed body should yield no list")
+ }
+ if dataList([]byte(`{"data":{}}`), "missing") != nil {
+ t.Fatal("absent key should yield no list")
+ }
+}
+
+func TestPickers(t *testing.T) {
+ m := map[string]any{"a": " x ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)}
+ if got := pickString(m, "b", "a"); got != "x" {
+ t.Fatalf("pickString = %q, want x", got)
+ }
+ if got := pickString(m, "n"); got != "7" {
+ t.Fatalf("pickString(number) = %q, want 7", got)
+ }
+ if got := pickString(m, "missing"); got != "" {
+ t.Fatalf("pickString(missing) = %q, want empty", got)
+ }
+ if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 {
+ t.Fatalf("pickInt = %d, %v; want 42, true", n, ok)
+ }
+ if b, ok := pickBool(m, "t"); !ok || !b {
+ t.Fatalf("pickBool = %v, %v; want true, true", b, ok)
+ }
+ if b, ok := pickBool(m, "z"); !ok || b {
+ t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok)
+ }
+ if _, ok := pickBool(m, "a"); ok {
+ t.Fatal("a non-boolean string must not read as a bool")
+ }
+}
+
+func TestFillString(t *testing.T) {
+ s := ""
+ fillString(&s, " first ")
+ fillString(&s, "second")
+ if s != "first" {
+ t.Fatalf("s = %q, want first", s)
+ }
+}
diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json
index cb7fb92..b1b50b1 100644
--- a/Phone App/assets/i18n/da.json
+++ b/Phone App/assets/i18n/da.json
@@ -339,6 +339,26 @@
"controlRevokeConfirm": "Tilbagekald denne laders styringstoken? Den bliver afbrudt og kan ikke forbinde igen, før du genererer et nyt.",
"controlConnected": "Forbundet til styrings-backend",
"controlDisconnected": "Ikke forbundet",
+ "chargersTitle": "Dine ladere",
+ "chargersHint": "Alle EV-ladere på den tilknyttede Anker-konto — både dem, der står for sig selv, og dem i et system.",
+ "chargersRefresh": "Opdater",
+ "chargersLoading": "Indlæser…",
+ "chargersEmpty": "Der blev ikke fundet nogen EV-lader på denne konto.",
+ "chargerUnnamed": "Lader uden navn",
+ "chargerOffline": "Offline",
+ "chargerUse": "Brug denne",
+ "states": {
+ "standby": "Standby",
+ "preparing": "Forbereder",
+ "charging": "Lader",
+ "charger_paused": "Sat på pause af laderen",
+ "vehicle_paused": "Sat på pause af bilen",
+ "completed": "Fuldført",
+ "reserving": "Reserveret",
+ "disabled": "Deaktiveret",
+ "error": "Fejl",
+ "unknown": "Ukendt"
+ },
"greencell": "Greencell (HabuDen EV-lader)",
"greencellDesc": "Læs din Greencell-lader via den MQTT-broker, den publicerer til. Kun lokalt — ingen Greencell-skykonto er involveret.",
"greencellBroker": "MQTT-broker",
diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json
index 9a73980..e44cb91 100644
--- a/Phone App/assets/i18n/en.json
+++ b/Phone App/assets/i18n/en.json
@@ -219,6 +219,26 @@
"controlRevokeConfirm": "Revoke this charger's control token? It will disconnect and can't reconnect until you generate a new one.",
"controlConnected": "Connected to control backend",
"controlDisconnected": "Not connected",
+ "chargersTitle": "Your chargers",
+ "chargersHint": "Every EV charger on the linked Anker account — the ones standing on their own and the ones inside a system.",
+ "chargersRefresh": "Refresh",
+ "chargersLoading": "Loading…",
+ "chargersEmpty": "No EV charger was found on this account.",
+ "chargerUnnamed": "Unnamed charger",
+ "chargerOffline": "Offline",
+ "chargerUse": "Use this one",
+ "states": {
+ "standby": "Standby",
+ "preparing": "Preparing",
+ "charging": "Charging",
+ "charger_paused": "Paused by charger",
+ "vehicle_paused": "Paused by car",
+ "completed": "Completed",
+ "reserving": "Reserved",
+ "disabled": "Disabled",
+ "error": "Error",
+ "unknown": "Unknown"
+ },
"greencell": "Greencell (HabuDen EV charger)",
"greencellDesc": "Read your Greencell wallbox over the MQTT broker it publishes to. Local only — no Greencell cloud account is involved.",
"greencellBroker": "MQTT broker",
diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json
index 1a7f5df..859542f 100644
--- a/Phone App/assets/i18n/pl.json
+++ b/Phone App/assets/i18n/pl.json
@@ -343,6 +343,26 @@
"controlRevokeConfirm": "Unieważnić token sterowania tej ładowarki? Rozłączy się i nie połączy ponownie, dopóki nie wygenerujesz nowego.",
"controlConnected": "Połączono z backendem sterowania",
"controlDisconnected": "Nie połączono",
+ "chargersTitle": "Twoje ładowarki",
+ "chargersHint": "Wszystkie ładowarki EV na połączonym koncie Anker — te wolnostojące i te należące do systemu.",
+ "chargersRefresh": "Odśwież",
+ "chargersLoading": "Wczytywanie…",
+ "chargersEmpty": "Na tym koncie nie znaleziono żadnej ładowarki EV.",
+ "chargerUnnamed": "Ładowarka bez nazwy",
+ "chargerOffline": "Offline",
+ "chargerUse": "Użyj tej",
+ "states": {
+ "standby": "Czuwanie",
+ "preparing": "Przygotowanie",
+ "charging": "Ładowanie",
+ "charger_paused": "Wstrzymane przez ładowarkę",
+ "vehicle_paused": "Wstrzymane przez auto",
+ "completed": "Zakończone",
+ "reserving": "Zarezerwowana",
+ "disabled": "Wyłączona",
+ "error": "Błąd",
+ "unknown": "Nieznany"
+ },
"greencell": "Greencell (ładowarka EV HabuDen)",
"greencellDesc": "Odczytuj swoją ładowarkę Greencell przez brokera MQTT, do którego publikuje. Tylko lokalnie — konto w chmurze Greencell nie jest potrzebne.",
"greencellBroker": "Broker MQTT",
diff --git a/Phone App/lib/api.dart b/Phone App/lib/api.dart
index 8401663..b089acf 100644
--- a/Phone App/lib/api.dart
+++ b/Phone App/lib/api.dart
@@ -632,6 +632,15 @@ class ApiClient {
return IntegrationHealth.fromJson(Map
{{ t("settings.integrations.chargersTitle") }}
+ +{{ t("settings.integrations.chargersHint") }}
+ ++ {{ c.name || t("settings.integrations.chargerUnnamed") }} +
++ {{ c.sn }} · {{ c.model }} · {{ c.siteName }} +
++ {{ ankerChargersDetail || t("settings.integrations.chargersEmpty") }} +
+{{ ankerChargersError }}
+{{ t("settings.integrations.controlTitle") }}