Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/chargers.go
T
tajniak81andClaude Opus 5 1a7f04cba0 A dash printed above the value it was missing
The Charger information card read "—" beside State and OCPP status while the raw
block three rows below it printed chargerStatus 1 and ocpp_connect_status 2. The
account had answered both. The merge asked for the state as evChargerStatus,
operating_state or status, which is how the standalone and station views spell
it, and the bound-device view — the one this account actually answers from —
spells it chargerStatus. The OCPP state it never asked that view for at all. All
three views now read through one fillDevice, which tries every spelling a view is
known to use, so a value any of them sends reaches the row that was drawing a
dash for want of it.

The same views were carrying the whole box-on-the-wall half unread: the Wi-Fi
network and its MAC, the signal strength, the Bluetooth MAC, the time zone, when
the account bound the charger, how the app can reach it — BLE, Wi-Fi — and the
product shot for the model, which now sits beside the charger's name in both
apps. Named rows, in three languages, the way the register map's readings are
named.

One field wanted the opposite treatment. The device record carries blue_password,
the charger's own Bluetooth pairing password, and the card was printing it in
clear into every screenshot anyone takes of that page. Any leaf key holding a
password, secret, token, private key or certificate is now masked in the raw
block: that the field exists is worth reporting, its value is not.

Four endpoints answer only when a serial is named, so none of them could belong
to the list the card is drawn from, and nothing had ever called them. The station
record, the charging totals, the OCPP backend and the RFID cards now arrive
through a charger-details capability behind
GET …/anker-solix/chargers/{sn}/details, asked for the charger being looked at,
best effort, each view reporting its own failure — an account that is not the
owner cannot read the cards, which is a fact about the account rather than an
error in the read.

Those four are shown under the cloud's own keys, and that is not an oversight.
The REST map documents which endpoints exist and what each is for; it does not
document a single one of their payloads. Naming those fields is the next commit,
made from what actually comes back, now that there is somewhere to see it.

Not touched: the endpoints the map marks ready but unwired — session history,
site price, OTA, sharing, notifications — each a feature rather than a row on this
card; and the unmapped ones, which the map warns delete sessions and unbind
devices with payloads nobody has ever seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 21:45:08 +02:00

679 lines
22 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.
// - 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)
}
}