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 } // chargersDoc is what the chargers capability answers, and what the health // check counts. boundCount is the standalone view's own tally of the account's // chargers, which is not the same number as len(chargers) — it misses whatever // only the site and bound-device views can see, so it is reported alongside // rather than instead. type chargersDoc struct { Chargers []accountCharger `json:"chargers"` Count int `json:"count"` BoundCount *int `json:"boundCount,omitempty"` Warnings []string `json:"warnings,omitempty"` Detail string `json:"detail,omitempty"` } // accountChargers is the chargers capability: the inventory as a JSON document. func (p *Plugin) accountChargers(ctx context.Context) (json.RawMessage, error) { doc, err := p.chargerInventory(ctx) if err != nil { return nil, err } return json.Marshal(doc) } // chargerInventory 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) chargerInventory(ctx context.Context) (chargersDoc, error) { // Sign in once, up front. Every view below needs the same token, and a login // Anker refuses is not one view failing — it is all of them, retried in turn, // five of which disable the account for ten minutes. Fail on the login // itself, and say so instead of blaming the views. if _, err := p.ensureToken(ctx); err != nil { return chargersDoc{}, err } 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 chargersDoc{}, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; ")) } out := inv.list() doc := chargersDoc{Chargers: out, Count: len(out), Warnings: warnings} if boundCount >= 0 { doc.BoundCount = &boundCount } 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 doc, nil } // 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) } }