Anker: every charger on the account, not just the ones outside a station
get_user_bind_and_not_in_station_evchargers is the only list the connector ever asked for, and its name says exactly what it withholds. A charger that belongs to a system is not in it. Its userBindEvChargersCount, though, counts every charger bound to the account — so an owner with two chargers in a system got "authenticated; 2 EV charger(s) bound to account" from the health probe and an empty list from the capability that is supposed to show them. A working login that finds nothing. So the capability now asks every view the cloud has and merges them by serial. The standalone list still answers for chargers standing on their own; get_site_list walks the systems and reads each one through get_scen_info, falling back to get_system_running_info where that is silent — the power-service / HES split charger-state already knows; and get_relate_and_bind_devices contributes model, firmware and the Wi-Fi flag, and discovers anything in the A519 family that the first two missed. Whichever way a charger was registered, one of the three has it. The merge is first-writer-wins per field rather than last view overwriting: the standalone record knows the name, the site record knows the live state, and neither should blank what the other established. A view that fails is a warning on the document instead of an error on the call, because one dead endpoint should not cost the chargers the other two found. Only losing all three is a failure. When nothing comes back at all the response says so in its own words and names the remaining suspect — country picks the regional server, and the wrong one authenticates happily and shows an empty account. The other half of "not showing any chargers" was that neither client ever showed a list. The serial was a text box, and the number is printed on a charger hanging on a wall. Both apps now list what the account holds — name, serial, model, site, state, an offline badge — and hand the serial to the OCPP control card instead of asking anyone to go and read it. Where control is off the list still stands on its own, as the answer to the first question an owner has after entering credentials. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a7cab50e06
commit
e138fad3f4
@@ -524,6 +524,7 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
// Relay the upstream JSON verbatim under "chargers".
|
||||
writeJSON(w, http.StatusOK, map[string]any{"chargers": json.RawMessage(raw)})
|
||||
// The plugin already answers {chargers, count, boundCount?, warnings?}; relay
|
||||
// that document as-is so the UI sees exactly what the capability produced.
|
||||
writeJSON(w, http.StatusOK, json.RawMessage(raw))
|
||||
}
|
||||
|
||||
@@ -174,7 +174,7 @@ func (p *Plugin) Descriptor() plugins.Descriptor {
|
||||
Category: plugins.CategoryChargers,
|
||||
AuthType: plugins.AuthBasic,
|
||||
Capabilities: []plugins.Capability{
|
||||
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."},
|
||||
{ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site and bound-device views (see chargers.go)."},
|
||||
{ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."},
|
||||
{ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."},
|
||||
{ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."},
|
||||
@@ -280,8 +280,11 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
|
||||
pp.SiteID = strings.TrimSpace(pp.SiteID)
|
||||
pp.VehicleID = strings.TrimSpace(pp.VehicleID)
|
||||
|
||||
// charger-state fans out over two endpoints and returns a derived document,
|
||||
// so it does not fit the single-endpoint dispatch below.
|
||||
// chargers and charger-state both fan out over several endpoints and return
|
||||
// a derived document, so they do not fit the single-endpoint dispatch below.
|
||||
if action == "chargers" {
|
||||
return p.accountChargers(ctx)
|
||||
}
|
||||
if action == "charger-state" {
|
||||
if pp.SiteID == "" {
|
||||
return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action)
|
||||
@@ -296,8 +299,6 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa
|
||||
needSite bool
|
||||
)
|
||||
switch action {
|
||||
case "chargers":
|
||||
endpoint, payload = epStandaloneChargers, map[string]any{}
|
||||
case "devices":
|
||||
endpoint, payload = epBindDevices, map[string]any{}
|
||||
case "sites":
|
||||
|
||||
@@ -0,0 +1,358 @@
|
||||
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
|
||||
}
|
||||
|
||||
// accountChargers 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) accountChargers(ctx context.Context) (json.RawMessage, 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 nil, fmt.Errorf("anker-solix: chargers: every cloud view failed: %s", strings.Join(warnings, "; "))
|
||||
}
|
||||
|
||||
out := inv.list()
|
||||
doc := map[string]any{"chargers": out, "count": len(out)}
|
||||
if boundCount >= 0 {
|
||||
doc["boundCount"] = boundCount
|
||||
}
|
||||
if len(warnings) > 0 {
|
||||
doc["warnings"] = warnings
|
||||
}
|
||||
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 json.Marshal(doc)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package ankersolix
|
||||
|
||||
import "testing"
|
||||
|
||||
// A charger that sits in a station is absent from the standalone list even
|
||||
// though the account count includes it — the case that made a good login look
|
||||
// like an account with no chargers.
|
||||
func TestStandaloneListMissesStationChargers(t *testing.T) {
|
||||
body := []byte(`{"code":0,"data":{"evChargers":[],"userBindEvChargersCount":2}}`)
|
||||
inv := newInventory()
|
||||
inv.addStandalone(body)
|
||||
if got := len(inv.list()); got != 0 {
|
||||
t.Fatalf("standalone view: got %d chargers, want 0", got)
|
||||
}
|
||||
if n, ok := countChargers(body); !ok || n != 2 {
|
||||
t.Fatalf("countChargers = %d, %v; want 2, true", n, ok)
|
||||
}
|
||||
|
||||
// The site view is where those two actually live.
|
||||
scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
|
||||
{"device_sn":"EVSN1","device_name":"Garage","operating_state":2,"power":"7.4","ocpp_connect_status":2},
|
||||
{"device_sn":"EVSN2","device_name":"Carport","operating_state":0}]}}}`)
|
||||
inv.addStates(parseScenePiles(scene, "site-1"), "Home")
|
||||
list := inv.list()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("after site view: got %d chargers, want 2", len(list))
|
||||
}
|
||||
if list[0].SN != "EVSN1" || list[0].SiteID != "site-1" || list[0].SiteName != "Home" {
|
||||
t.Fatalf("first charger = %+v", list[0])
|
||||
}
|
||||
if list[0].StatusDesc != stateCharging || list[0].Power != "7.4" {
|
||||
t.Fatalf("status/power not carried over: %+v", list[0])
|
||||
}
|
||||
if list[0].OcppStatusDesc != "connected" {
|
||||
t.Fatalf("ocpp status = %q, want connected", list[0].OcppStatusDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddStandaloneReadsHesFieldNames(t *testing.T) {
|
||||
body := []byte(`{"code":0,"data":{"evChargers":[
|
||||
{"evChargerSn":"EVSN1","evChargerName":"Garage","evChargerStatus":2,"wifi_online":true},
|
||||
{"evChargerName":"nameless"}]}}`)
|
||||
inv := newInventory()
|
||||
inv.addStandalone(body)
|
||||
list := inv.list()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("got %d chargers, want 1 (the serial-less one is skipped)", len(list))
|
||||
}
|
||||
c := list[0]
|
||||
if c.SN != "EVSN1" || c.Name != "Garage" || c.StatusDesc != stateCharging {
|
||||
t.Fatalf("charger = %+v", c)
|
||||
}
|
||||
if c.Online == nil || !*c.Online {
|
||||
t.Fatalf("online = %v, want true", c.Online)
|
||||
}
|
||||
if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
|
||||
t.Fatalf("sources = %v", c.Sources)
|
||||
}
|
||||
}
|
||||
|
||||
// Bound devices discover EV chargers by product family and enrich chargers the
|
||||
// other views already found — without dragging in the rest of the account.
|
||||
func TestAddBoundFiltersAndEnriches(t *testing.T) {
|
||||
inv := newInventory()
|
||||
scene := []byte(`{"code":0,"data":{"charging_pile_info":{"charging_pile_list":[
|
||||
{"device_sn":"EVSN1","operating_state":0}]}}}`)
|
||||
inv.addStates(parseScenePiles(scene, "site-1"), "Home")
|
||||
|
||||
bound := []byte(`{"code":0,"data":{"data":[
|
||||
{"device_sn":"EVSN1","device_pn":"A5191","device_name":"Garage","device_sw_version":"1.2.3","wifi_online":true},
|
||||
{"device_sn":"EVSN9","device_pn":"a5191","alias_name":"Carport"},
|
||||
{"device_sn":"SOLAR1","device_pn":"A17C0","device_name":"Solarbank"}]}}`)
|
||||
inv.addBound(bound)
|
||||
|
||||
list := inv.list()
|
||||
if len(list) != 2 {
|
||||
t.Fatalf("got %d chargers, want 2 (the solarbank is not one)", len(list))
|
||||
}
|
||||
if list[0].Name != "Garage" || list[0].Firmware != "1.2.3" || list[0].Model != "A5191" {
|
||||
t.Fatalf("enriched charger = %+v", list[0])
|
||||
}
|
||||
if len(list[0].Sources) != 2 || list[0].Sources[1] != "bound" {
|
||||
t.Fatalf("sources = %v, want [site bound]", list[0].Sources)
|
||||
}
|
||||
if list[1].SN != "EVSN9" || list[1].Name != "Carport" {
|
||||
t.Fatalf("discovered charger = %+v", list[1])
|
||||
}
|
||||
}
|
||||
|
||||
// The site view knows the live state; the standalone view saw the charger
|
||||
// first. Merging must not let the later view blank what the earlier one knew.
|
||||
func TestMergeKeepsFirstKnownValues(t *testing.T) {
|
||||
inv := newInventory()
|
||||
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
|
||||
inv.addStates(parseScenePiles([]byte(`{"data":{"charging_pile_info":{"charging_pile_list":[
|
||||
{"device_sn":"EVSN1","operating_state":2,"power":"7.4"}]}}}`), "site-1"), "Home")
|
||||
|
||||
list := inv.list()
|
||||
if len(list) != 1 {
|
||||
t.Fatalf("got %d chargers, want 1", len(list))
|
||||
}
|
||||
c := list[0]
|
||||
if c.Name != "Garage" {
|
||||
t.Fatalf("name = %q, want Garage (the nameless site record must not clear it)", c.Name)
|
||||
}
|
||||
if c.StatusDesc != stateCharging || c.Power != "7.4" || c.SiteID != "site-1" {
|
||||
t.Fatalf("site view did not fill the gaps: %+v", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDataListCandidateKeys(t *testing.T) {
|
||||
body := []byte(`{"data":{"evChargers":null,"chargerList":[{"sn":"A"},{"sn":"B"},"junk"]}}`)
|
||||
got := dataList(body, "evChargers", "chargerList")
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("got %d objects, want 2 (the non-object is dropped)", len(got))
|
||||
}
|
||||
if dataList([]byte(`not json`), "data") != nil {
|
||||
t.Fatal("malformed body should yield no list")
|
||||
}
|
||||
if dataList([]byte(`{"data":{}}`), "missing") != nil {
|
||||
t.Fatal("absent key should yield no list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickers(t *testing.T) {
|
||||
m := map[string]any{"a": " x ", "b": "", "n": float64(7), "ns": "42", "t": true, "z": float64(0)}
|
||||
if got := pickString(m, "b", "a"); got != "x" {
|
||||
t.Fatalf("pickString = %q, want x", got)
|
||||
}
|
||||
if got := pickString(m, "n"); got != "7" {
|
||||
t.Fatalf("pickString(number) = %q, want 7", got)
|
||||
}
|
||||
if got := pickString(m, "missing"); got != "" {
|
||||
t.Fatalf("pickString(missing) = %q, want empty", got)
|
||||
}
|
||||
if n, ok := pickInt(m, "missing", "ns"); !ok || n != 42 {
|
||||
t.Fatalf("pickInt = %d, %v; want 42, true", n, ok)
|
||||
}
|
||||
if b, ok := pickBool(m, "t"); !ok || !b {
|
||||
t.Fatalf("pickBool = %v, %v; want true, true", b, ok)
|
||||
}
|
||||
if b, ok := pickBool(m, "z"); !ok || b {
|
||||
t.Fatalf("pickBool(0) = %v, %v; want false, true", b, ok)
|
||||
}
|
||||
if _, ok := pickBool(m, "a"); ok {
|
||||
t.Fatal("a non-boolean string must not read as a bool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFillString(t *testing.T) {
|
||||
s := ""
|
||||
fillString(&s, " first ")
|
||||
fillString(&s, "second")
|
||||
if s != "first" {
|
||||
t.Fatalf("s = %q, want first", s)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user