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.from(h)); } + /// The chargers on the linked Anker account, fetched server-side under the + /// resolved credentials. A closed gate answers 200 with an empty list and the + /// reason in `detail`, so this is not an error path. + Future listAnkerChargers() async { + final data = await _send("GET", "/integrations/anker-solix/chargers"); + if (data is! Map) return const AnkerChargerList(); + return AnkerChargerList.fromJson(Map.from(data)); + } + // Greencell (HabuDen EV charger). Same cascade, but what resolves is an MQTT // broker rather than a cloud account — the charger publishes to a broker the // owner runs and the server joins it. testGreencell connects to that broker and diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 62c5c5f..1f0d9f7 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -1080,6 +1080,65 @@ class AnkerControl { double get meterKwh => meterWh / 1000.0; } +/// One EV charger on the linked Anker account, from …/anker-solix/chargers. The +/// server merges the cloud's several views of an account, so a charger reads the +/// same here whether it stands on its own or belongs to a system; a field no +/// view supplied simply stays empty. +class AnkerCharger { + final String sn; + final String name; + final String model; + final String siteName; + final String statusDesc; // charging | standby | … as the cloud names it + final bool? online; // null when no view reported a connection state + + const AnkerCharger({ + required this.sn, + this.name = "", + this.model = "", + this.siteName = "", + this.statusDesc = "", + this.online, + }); + + factory AnkerCharger.fromJson(Map j) => AnkerCharger( + sn: _asStr(j["sn"]), + name: _asStr(j["name"]), + model: _asStr(j["model"]), + siteName: _asStr(j["siteName"]), + statusDesc: _asStr(j["statusDesc"]), + online: j["online"] is bool ? j["online"] as bool : null, + ); + + /// What to call the charger in a list: its name when it has one, its serial + /// otherwise — never an empty row. + String get label => name.isNotEmpty ? name : sn; +} + +/// The charger list plus the reason it may be empty (a gate that is off, or an +/// account that exposed nothing), so a caller can say why rather than show a +/// blank panel. +class AnkerChargerList { + final List chargers; + final String detail; + + const AnkerChargerList({this.chargers = const [], this.detail = ""}); + + factory AnkerChargerList.fromJson(Map j) { + final raw = j["chargers"]; + return AnkerChargerList( + chargers: raw is List + ? raw + .whereType() + .map((c) => AnkerCharger.fromJson(Map.from(c))) + .where((c) => c.sn.isNotEmpty) + .toList() + : const [], + detail: _asStr(j["detail"]), + ); + } +} + // --- vehicle providers (the connected-service tab) --------------------------- // // A provider is a manufacturer service a car can be linked to (MyToyota today). diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 6e1ae4b..9edcab8 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -705,6 +705,7 @@ class _ControlCard extends StatefulWidget { class _ControlCardState extends State<_ControlCard> { final _serial = TextEditingController(); + List _chargers = const []; // the account's chargers, when known String _mode = "off"; // effective control mode (off | own | proxy) AnkerControl? _ctl; String? _error; @@ -736,9 +737,29 @@ class _ControlCardState extends State<_ControlCard> { } catch (_) { if (mounted) setState(() => _mode = "off"); } + if (_active) await _loadChargers(); await _refresh(); } + /// The account's chargers turn the serial into a pick from a list. Without + /// them (nothing linked, or the cloud unreachable) the field stays a text box + /// so a serial can still be typed in by hand. + Future _loadChargers() async { + try { + final res = await apiClient.listAnkerChargers(); + if (!mounted) return; + setState(() { + _chargers = res.chargers; + // Nothing chosen yet: start on the first charger the account reports. + if (_serial.text.trim().isEmpty && _chargers.isNotEmpty) { + _serial.text = _chargers.first.sn; + } + }); + } catch (_) { + if (mounted) setState(() => _chargers = const []); + } + } + Future _refresh() async { final sn = _serial.text.trim(); if (sn.isEmpty) { @@ -819,15 +840,34 @@ class _ControlCardState extends State<_ControlCard> { const SizedBox(height: 12), Row(children: [ Expanded( - child: TextField( - controller: _serial, - autocorrect: false, - decoration: InputDecoration( - border: const OutlineInputBorder(), - isDense: true, - hintText: t("charging.control.serialPlaceholder"), - ), - ), + child: _chargers.isNotEmpty + ? DropdownButtonFormField( + initialValue: _serial.text.trim().isEmpty ? null : _serial.text.trim(), + isExpanded: true, + decoration: const InputDecoration(border: OutlineInputBorder(), isDense: true), + items: [ + for (final c in _chargers) + DropdownMenuItem( + value: c.sn, + child: Text(c.name.isNotEmpty ? "${c.name} · ${c.sn}" : c.sn, + overflow: TextOverflow.ellipsis), + ), + ], + onChanged: (sn) { + if (sn == null) return; + setState(() => _serial.text = sn); + _refresh(); + }, + ) + : TextField( + controller: _serial, + autocorrect: false, + decoration: InputDecoration( + border: const OutlineInputBorder(), + isDense: true, + hintText: t("charging.control.serialPlaceholder"), + ), + ), ), const SizedBox(width: 8), OutlinedButton(onPressed: _refresh, child: Text(t("charging.control.refresh"))), diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 00e72e0..2de5bbb 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -2008,6 +2008,11 @@ class _IntegrationCardState extends State<_IntegrationCard> { ), ), + // The chargers on the account. With a control mode active the list lives + // inside the provisioning card below, where picking one fills the serial. + if (_c.anker && view.enabled && view.controlMode == "off") + const Padding(padding: EdgeInsets.only(top: 16), child: _AnkerChargers()), + // OCPP control provisioning (Anker only, when a control mode is active). if (_c.anker && view.controlMode != "off") Padding( @@ -2112,6 +2117,161 @@ class _ConnBadge extends StatelessWidget { } } +/// The chargers on the linked Anker account. It answers the first question an +/// owner has after entering their credentials — did it find my chargers? — and, +/// where a control mode is active, hands the provisioning card the serial rather +/// than asking anyone to read it off the wall. +class _AnkerChargers extends StatefulWidget { + /// Called with a serial when a charger is picked; null hides the pick action + /// (nothing to fill in when control is off). + final void Function(String sn)? onPick; + const _AnkerChargers({this.onPick}); + @override + State<_AnkerChargers> createState() => _AnkerChargersState(); +} + +class _AnkerChargersState extends State<_AnkerChargers> { + List _chargers = const []; + String _detail = ""; // the server's reason for an empty list + bool _loading = false; + bool _loaded = false; + String? _error; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _error = null; + _loading = true; + }); + try { + final res = await apiClient.listAnkerChargers(); + if (mounted) { + setState(() { + _chargers = res.chargers; + _detail = res.detail; + _loaded = true; + }); + } + } catch (e) { + if (mounted) { + setState(() { + _chargers = const []; + _error = "$e"; + }); + } + } finally { + if (mounted) setState(() => _loading = false); + } + } + + /// The cloud names a state in its own words (charging, standby, …); translate + /// it, falling back to the readable slug for a state we have no wording for. + String _stateLabel(String slug) { + final key = "settings.integrations.states.$slug"; + final label = t(key); + return label == key ? slug.replaceAll("_", " ") : label; + } + + @override + Widget build(BuildContext context) { + final muted = DriverVault.muted(context); + final dark = DriverVault.isDark(context); + final sunken = dark ? DriverVault.darkSunken : DriverVault.ink25; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: sunken, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Expanded( + child: Text(t("settings.integrations.chargersTitle"), + style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)), + ), + TextButton( + onPressed: _loading ? null : _load, + child: Text(_loading + ? t("settings.integrations.chargersLoading") + : t("settings.integrations.chargersRefresh")), + ), + ]), + Text(t("settings.integrations.chargersHint"), style: TextStyle(fontSize: 12, color: muted)), + for (final c in _chargers) + Container( + margin: const EdgeInsets.only(top: 8), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: dark ? DriverVault.darkCard : Colors.white, + border: Border.all(color: Theme.of(context).dividerColor), + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(c.name.isNotEmpty ? c.name : t("settings.integrations.chargerUnnamed"), + style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), + Text( + [ + c.sn, + if (c.model.isNotEmpty) c.model, + if (c.siteName.isNotEmpty) c.siteName, + ].join(" · "), + style: DriverVault.mono(context, size: 11).copyWith(color: muted), + ), + if (c.statusDesc.isNotEmpty || c.online == false) + Text( + c.online == false + ? t("settings.integrations.chargerOffline") + : _stateLabel(c.statusDesc), + style: TextStyle( + fontSize: 12, + color: c.online == false + ? DriverVault.warning + : c.statusDesc == "charging" + ? DriverVault.success + : muted, + ), + ), + ], + ), + ), + if (widget.onPick != null) + TextButton( + onPressed: () => widget.onPick!(c.sn), + child: Text(t("settings.integrations.chargerUse")), + ), + ], + ), + ), + if (_chargers.isEmpty && _loaded) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_detail.isNotEmpty ? _detail : t("settings.integrations.chargersEmpty"), + style: TextStyle(fontSize: 13, color: muted)), + ), + if (_error != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text(_error!, style: const TextStyle(fontSize: 13, color: DriverVault.danger)), + ), + ], + ), + ); + } +} + /// Anker Solix OCPP control provisioning — per-charger token + connection status. /// Only shown when a control mode (own/proxy) is active for the user. class _AnkerControlProvision extends StatefulWidget { @@ -2223,6 +2383,11 @@ class _AnkerControlProvisionState extends State<_AnkerControlProvision> { style: TextStyle(fontSize: 12, color: muted), ), const SizedBox(height: 12), + _AnkerChargers(onPick: (sn) { + setState(() => _serial.text = sn); + _loadControl(); + }), + const SizedBox(height: 12), Text(t("settings.integrations.chargerSerial"), style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)), const SizedBox(height: 4), diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index 7c46207..fd6c223 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -300,6 +300,11 @@ export const api = { 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" }), + // The chargers on the linked Anker account, fetched server-side under the + // resolved credentials: {chargers, count, boundCount?, detail?}. Answers 200 + // with an empty list and a reason when a gate is off, so the caller can show + // the reason rather than an error. + listAnkerChargers: () => request("/integrations/anker-solix/chargers"), // Anker Solix OCPP control (per charger). getAnkerControl returns the control // mode, connection status, provisioning endpoint + token, and a live status diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 824a1f8..95d7f0b 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -297,6 +297,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/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 14b5aaf..2db5167 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -296,6 +296,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/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index ad86e32..6da3a23 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -301,6 +301,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/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 38a1053..e974778 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -74,6 +74,28 @@ async function loadCtlMode() { } } +// The chargers on the linked Anker account. With them the serial is a pick from +// a list; without them (account not linked, or the cloud unreachable) the field +// stays a plain text box so a serial can still be typed in by hand. +const chargers = ref([]); + +async function loadChargers() { + try { + const res = await api.listAnkerChargers(); + chargers.value = res?.chargers || []; + } catch { + chargers.value = []; + } + // Nothing chosen yet: start on the first charger the account reports. + if (!ctlSerial.value.trim() && chargers.value.length) { + ctlSerial.value = chargers.value[0].sn; + } +} + +function chargerLabel(c) { + return c.name ? `${c.name} · ${c.sn}` : c.sn; +} + async function refreshCtl() { const sn = ctlSerial.value.trim(); if (!sn) { @@ -131,6 +153,7 @@ function cancelReset() { onMounted(async () => { await loadCtlMode(); + if (ctlActive.value) await loadChargers(); await refreshCtl(); }); @@ -290,7 +313,16 @@ onMounted(async () => {
- + +
diff --git a/Web App/web/src/views/Settings.vue b/Web App/web/src/views/Settings.vue index 659d63d..c7fdb52 100644 --- a/Web App/web/src/views/Settings.vue +++ b/Web App/web/src/views/Settings.vue @@ -529,6 +529,57 @@ watch(ankerCtlSerial, () => { ankerNewToken.value = ""; }); +// --- The chargers on the linked Anker account --- +// Proof that the login found something, and the source of the serial the control +// block needs — the serial is printed on the charger, but nobody wants to go and +// read it off the wall when the account already knows it. +const ankerChargers = ref([]); +const ankerChargersLoading = ref(false); +const ankerChargersError = ref(""); +const ankerChargersDetail = ref(""); // why the list is empty, when the server says +const ankerChargersLoaded = ref(false); + +async function loadAnkerChargers() { + ankerChargersError.value = ""; + ankerChargersLoading.value = true; + try { + const res = await api.listAnkerChargers(); + ankerChargers.value = res?.chargers || []; + ankerChargersDetail.value = res?.detail || ""; + ankerChargersLoaded.value = true; + } catch (e) { + ankerChargers.value = []; + ankerChargersError.value = e.message; + } finally { + ankerChargersLoading.value = false; + } +} + +// Load once the card is open and the integration is on — opening the card is the +// moment the user is asking "what is on my account?", and every other visit to +// Settings should not spend an Anker round trip. +watch([ankerOpen, () => anker.value?.enabled], ([open, enabled]) => { + if (open && enabled && !ankerChargersLoaded.value && !ankerChargersLoading.value) { + loadAnkerChargers(); + } +}); + +// A charger's operating state arrives as the cloud's own slug (charging, +// standby, …); translate it, and fall back to the readable slug for a state we +// have no wording for yet. +function ankerStateLabel(slug) { + if (!slug) return ""; + const key = `settings.integrations.states.${slug}`; + const label = t(key); + return label === key ? slug.replace(/_/g, " ") : label; +} + +// Point the control block at a charger picked from the list. +function useAnkerCharger(sn) { + ankerCtlSerial.value = sn; + loadAnkerControl(); +} + function applyAnkerView(body) { anker.value = body; if (ankerScope.value === "org" && !body.canEditOrg) ankerScope.value = "user"; @@ -1428,6 +1479,53 @@ onBeforeUnmount(() => { {{ ankerHealth.detail || ankerHealth.status }}

+ +
+
+

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

+ +
+

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

+ +
    +
  • +
    +

    + {{ c.name || t("settings.integrations.chargerUnnamed") }} +

    +

    + {{ c.sn }} · {{ c.model }} · {{ c.siteName }} +

    +
    +
    + + {{ t("settings.integrations.chargerOffline") }} + + + {{ ankerStateLabel(c.statusDesc) }} + + +
    +
  • +
+

+ {{ ankerChargersDetail || t("settings.integrations.chargersEmpty") }} +

+

{{ ankerChargersError }}

+
+

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