The probe still asked get_user_bind_and_not_in_station_evchargers and read its userBindEvChargersCount, so it reported "0 EV charger(s) bound to account" for an account whose two chargers the panel was listing directly underneath — the same blind spot the capability was just moved off, left behind in the health check. It now takes the same inventory the chargers capability returns and counts that. Authenticated with nothing on the account is degraded rather than ok, following Greencell's rule: the half we address answers, and the empty half is the account or the country that picks the regional server, so the message says so instead of reporting a healthy connection to nothing. A count reached with some view missing says how many views stayed silent, because the number is then a floor rather than a total. The web panel colours degraded amber, as it already did for Greencell. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
378 lines
12 KiB
Go
378 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) {
|
|
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)
|
|
}
|
|
}
|