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. // - get_evcharger_station_info answers for one charger at a time — the record // the mobile app opens when you tap a charger. It is the only view that says // anything live about a charger standing outside a station, which is exactly // the charger the three list views say least about. // // accountChargers therefore asks all four and merges the answers by serial, so // the list matches what the mobile app shows however the chargers were // registered. Merging keeps the fields the rest of DriverVault has names for, // and keeps every other field the cloud sent as well, under the cloud's own key // (see attrs): a value nothing here recognises is still a value the owner of the // charger may want to read. 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"` // What the account knows about the box on the wall, as opposed to the // charging: which networks it is on, where it thinks it is, when it was // bound, and the picture the app shows for it. Every one of these arrives // with the device views and none of them was ever read. WifiName string `json:"wifiName,omitempty"` WifiMac string `json:"wifiMac,omitempty"` WifiRSSI *int `json:"wifiRssi,omitempty"` BleMac string `json:"bleMac,omitempty"` TimeZone string `json:"timeZone,omitempty"` LinkedAt *float64 `json:"linkedAt,omitempty"` // unix seconds ImageURL string `json:"imageUrl,omitempty"` RelatedBy []string `json:"relatedBy,omitempty"` // ble, wifi — how the app reaches it // Attrs is everything each view reported about this charger, under the key // the cloud used for it. The fields above are the ones DriverVault gives a // name and a meaning to; Attrs is the rest, relayed rather than dropped, // because Anker documents none of this and a field we have no name for today // is still the only place some of what the charger knows ever appears. Attrs map[string]string `json:"attrs,omitempty"` } // Two limits keep one charger's attributes to something a card can hold: a // station record that carries a session list would otherwise arrive as hundreds // of indexed keys, and a base64 blob as one unreadable page. const ( maxAttrs = 200 maxAttrsLen = 240 ) // note folds one view's raw record into the charger's attributes. A key already // answered is left alone, so the first view to report a field wins — the same // rule the typed fields merge by. func (c *accountCharger) note(m map[string]any) { if c.Attrs == nil { c.Attrs = map[string]string{} } flattenInto(c.Attrs, "", m) } // flattenInto records every scalar under v in dst, keyed by the path the cloud // nested it at: objects join with a dot, arrays carry their index. A null is not // an answer and leaves no key behind. func flattenInto(dst map[string]string, key string, v any) { switch t := v.(type) { case map[string]any: for k, sub := range t { flattenInto(dst, joinAttrKey(key, k), sub) } case []any: for i, sub := range t { flattenInto(dst, fmt.Sprintf("%s[%d]", key, i), sub) } case nil: default: if key == "" || len(dst) >= maxAttrs { return } if _, seen := dst[key]; seen { return } s := attrString(t) if s == "" { return // nothing said is not a value } // A device record carries the charger's own Bluetooth pairing password, // and the account views hand out more than one credential besides. That a // field exists is worth reporting; its value is not something a card // should put on a screen, or a screenshot should carry off one. if isSecretKey(key) { s = "\u2022\u2022\u2022\u2022\u2022\u2022" } // Cut by rune, not by byte: half a character is not shorter, it is // broken, and it would reach the UI as a replacement glyph. if r := []rune(s); len(r) > maxAttrsLen { s = string(r[:maxAttrsLen]) + "…" } dst[key] = s } } // secretKeyWords are the field names whose values are credentials rather than // readings. Matched on the leaf name, so a nested one is caught too. var secretKeyWords = []string{"password", "passwd", "secret", "token", "private_key", "privatekey", "certificate", "auth_key", "authkey"} func isSecretKey(key string) bool { leaf := strings.ToLower(key) if i := strings.LastIndex(leaf, "."); i >= 0 { leaf = leaf[i+1:] } for _, word := range secretKeyWords { if strings.Contains(leaf, word) { return true } } return false } func joinAttrKey(prefix, key string) string { if prefix == "" { return key } return prefix + "." + key } // attrString words a scalar the way the wire did: a number keeps every digit it // arrived with rather than gaining an exponent, a bool stays a bool. func attrString(v any) string { switch t := v.(type) { case string: return strings.TrimSpace(t) case bool: return strconv.FormatBool(t) case float64: return strconv.FormatFloat(t, 'f', -1, 64) case json.Number: return t.String() default: return strings.TrimSpace(fmt.Sprint(t)) } } // 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") c.note(m) fillDevice(c, m) } } // fillDevice reads the fields every device view shares. The three views name // them alike where they can and differently where they cannot — the same status // is evChargerStatus in one record and chargerStatus in the next — so each is // looked up by candidate key, first answer winning, the way the rest of the // merge works. func fillDevice(c *accountCharger, m map[string]any) { fillString(&c.Name, pickString(m, "evChargerName", "device_name", "alias_name", "deviceName", "name")) fillString(&c.Model, pickString(m, "device_pn", "product_code", "evChargerPn", "pn")) fillString(&c.Firmware, pickString(m, "device_sw_version", "sw_version", "version")) fillString(&c.SiteID, pickString(m, "site_id", "siteId", "station_id", "stationId")) fillString(&c.SiteName, pickString(m, "site_name", "siteName", "station_name", "stationName")) fillString(&c.WifiName, pickString(m, "wifi_name", "wifiName", "ssid")) fillString(&c.WifiMac, pickString(m, "wifi_mac", "wifiMac")) fillString(&c.BleMac, pickString(m, "bt_ble_mac", "bleMac", "bt_mac")) fillString(&c.TimeZone, pickString(m, "time_zone", "timeZone", "timezone")) fillString(&c.ImageURL, pickString(m, "img_url", "imgUrl", "image_url")) if n, ok := pickInt(m, "rssi", "wifi_rssi", "signal"); ok && c.WifiRSSI == nil { c.WifiRSSI = &n } if t, ok := pickFloat(m, "link_time", "linkTime", "bind_time", "create_time"); ok && c.LinkedAt == nil { c.LinkedAt = &t } if len(c.RelatedBy) == 0 { c.RelatedBy = pickStrings(m, "relate_type", "relateType", "connect_type") } // The state, however the view spells it. A charger that answers one view and // not another is the normal case, which is why this is tried on all of them. if n, ok := pickInt(m, "evChargerStatus", "chargerStatus", "operating_state", "status"); ok { setChargerStatus(c, n) } if n, ok := pickInt(m, "ocpp_connect_status", "ocppConnectStatus"); ok && c.OcppStatus == nil { desc, named := ocppConnStatus[n] if !named { desc = stateUnknown } c.OcppStatus, c.OcppStatusDesc = &n, desc } 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") c.note(m) fillDevice(c, m) } } // noteRecords folds a view's raw records into the chargers already found. The // site views reach the inventory as normalized state (addStates), which is the // right shape for the fields we name and drops the rest — so the raw list is // walked once more here, purely for what those records also carried. A serial // nothing has found yet is skipped: this pass enriches chargers, it does not // discover them. func (inv *chargerInventory) noteRecords(records []map[string]any, snKeys ...string) { for _, m := range records { sn := pickString(m, snKeys...) if sn == "" { continue } if c, ok := inv.byID[sn]; ok { c.note(m) } } } // addStation folds the per-charger station record into one charger: everything // it carries as attributes, and the fields we have names for where no earlier // view answered. Anker does not document this record, so each field is looked up // by candidate key the way the list views are. func (inv *chargerInventory) addStation(sn string, body []byte) { c, ok := inv.byID[sn] if !ok { return } m := dataObject(body) if m == nil { return } c.note(m) fillDevice(c, m) fillString(&c.Power, pickString(m, "power", "charging_power", "chargingPower")) } // 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) inv.noteRecords(rawRecords(body, "charging_pile_info", "charging_pile_list"), "device_sn", "deviceSn", "sn") } 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) inv.noteRecords(rawRecords(body, "evChargerInfos"), "evChargerSn", "device_sn", "sn") } } } // 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) } // 4. The station record, one charger at a time. The three list views above // answer for the account; this one answers for a charger, and for a charger // registered on its own it is the only view that says what it is doing. Each // call is its own request, so a charger the cloud will not talk about costs // that charger's row and no more — a station view that fails everywhere is // one warning, not one per charger. views++ stationFailed, stationErr := 0, error(nil) for _, sn := range inv.order { body, err := p.apiRequest(ctx, epStationInfo, map[string]any{"evChargerSn": sn, "featuretype": 1}) if err != nil { stationFailed, stationErr = stationFailed+1, err continue } inv.addStation(sn, body) } if stationErr != nil && stationFailed == len(inv.order) { fail("station info", stationErr) } 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 } // dataObject returns a response's "data" object, or nil when it carries // something else — a list endpoint's data is an array, and the per-charger views // have been known to answer with a bare value. func dataObject(body []byte) map[string]any { var env struct { Data map[string]any `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { return nil } return env.Data } // rawRecords returns the objects in the array at path inside a response's "data" // object, descending through the objects named before the last element. It is // dataList's answer for a list the cloud nests rather than puts at the top. func rawRecords(body []byte, path ...string) []map[string]any { node := any(dataObject(body)) for i, key := range path { obj, ok := node.(map[string]any) if !ok { return nil } node = obj[key] if i == len(path)-1 { break } } raw, ok := node.([]any) if !ok { return nil } out := make([]map[string]any, 0, len(raw)) for _, item := range raw { if m, ok := item.(map[string]any); ok { out = append(out, m) } } return out } // 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 "" } // pickFloat returns the first numeric value among keys, unrounded — a unix // timestamp does not fit an int on every platform and does not want scaling. func pickFloat(m map[string]any, keys ...string) (float64, bool) { for _, k := range keys { switch v := m[k].(type) { case float64: return v, true case string: if f, err := strconv.ParseFloat(strings.TrimSpace(v), 64); err == nil { return f, true } } } return 0, false } // pickStrings returns the first list of strings among keys. The device views // carry the ways the app can reach a charger this way — ble, wifi — as a plain // array of names. func pickStrings(m map[string]any, keys ...string) []string { for _, k := range keys { raw, ok := m[k].([]any) if !ok { continue } out := make([]string, 0, len(raw)) for _, item := range raw { if s, ok := item.(string); ok && strings.TrimSpace(s) != "" { out = append(out, strings.TrimSpace(s)) } } if len(out) > 0 { return out } } return nil } // 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) } }