A chargers poll asks four cloud views. Each called apiRequest, each found no token, and each ran its own login — so a login Anker refuses was offered four times in one poll, and the next poll spent the fifth. Five is what disables the account for ten minutes, which is how "code 26161: Failed to request." turned into "your account has been disabled" on the very next attempt. The plugin now remembers a refused login instead of repeating it: the failure is cached and replayed to every caller until a backoff window passes — a minute at first, doubling to fifteen, or the full ten minutes when Anker says it has already locked the account (code 10019). New credentials clear it, so a fixed password is tried at once. chargerInventory signs in once up front. A login the cloud refuses is not four views failing, so it is reported as itself rather than as three warnings with the lockout notice buried in the last one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
386 lines
12 KiB
Go
386 lines
12 KiB
Go
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)
|
|
}
|
|
}
|