Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/chargers_test.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

295 lines
12 KiB
Go

package ankersolix
import (
"strings"
"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)
}
}
// Everything a view sent is kept, not only the fields the merge has a name for:
// nested objects and arrays arrive under the path the cloud nested them at, and
// a field that says nothing leaves no row behind.
func TestAttrsKeepEveryFieldTheCloudSent(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage",
"wifi_online":true,"rssi":-52,"grid":{"tariff":"day"},"tags":["a","b"],"blank":" ","absent":null}]}}`))
attrs := inv.list()[0].Attrs
want := map[string]string{
"evChargerSn": "EVSN1", "evChargerName": "Garage", "wifi_online": "true",
"rssi": "-52", "grid.tariff": "day", "tags[0]": "a", "tags[1]": "b",
}
for k, v := range want {
if attrs[k] != v {
t.Fatalf("attrs[%q] = %q, want %q", k, attrs[k], v)
}
}
for _, k := range []string{"blank", "absent"} {
if _, ok := attrs[k]; ok {
t.Fatalf("attrs[%q] = %q; a field that says nothing must not make a row", k, attrs[k])
}
}
// A second view fills gaps without overwriting what the first one answered.
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","rssi":-70,"bt_mac":"AA:BB"}]}}`))
attrs = inv.list()[0].Attrs
if attrs["rssi"] != "-52" {
t.Fatalf("rssi = %q, want the first view's -52", attrs["rssi"])
}
if attrs["bt_mac"] != "AA:BB" {
t.Fatalf("bt_mac = %q, want AA:BB", attrs["bt_mac"])
}
}
// The station record is the only view that answers for a charger standing
// outside a station, so it fills the live fields the list views left empty —
// without claiming the charger is registered anywhere new.
func TestAddStationFillsGapsWithoutAddingASource(t *testing.T) {
inv := newInventory()
inv.addStandalone([]byte(`{"data":{"evChargers":[{"evChargerSn":"EVSN1","evChargerName":"Garage"}]}}`))
inv.addStation("EVSN1", []byte(`{"code":0,"data":{"evChargerSn":"EVSN1","evChargerStatus":2,"power":"7.4",
"ocpp_connect_status":2,"station_name":"Home-DK","device_sw_version":"v1.0.6.1","plug_temperature":41}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging || c.Power != "7.4" {
t.Fatalf("station view did not fill the live fields: %+v", c)
}
if c.OcppStatusDesc != "connected" || c.SiteName != "Home-DK" || c.Firmware != "v1.0.6.1" {
t.Fatalf("station view did not fill the named fields: %+v", c)
}
if c.Attrs["plug_temperature"] != "41" {
t.Fatalf("plug_temperature = %q, want 41", c.Attrs["plug_temperature"])
}
if len(c.Sources) != 1 || c.Sources[0] != "standalone" {
t.Fatalf("sources = %v; the station record is a view, not a registration", c.Sources)
}
// A serial no view found is not a charger this pass may invent.
inv.addStation("EVSN9", []byte(`{"code":0,"data":{"evChargerSn":"EVSN9"}}`))
if len(inv.list()) != 1 {
t.Fatalf("got %d chargers, want 1", len(inv.list()))
}
}
func TestRawRecordsDescendsToANestedList(t *testing.T) {
body := []byte(`{"data":{"charging_pile_info":{"charging_pile_list":[{"device_sn":"EVSN1","power":"7.4"}]}}}`)
got := rawRecords(body, "charging_pile_info", "charging_pile_list")
if len(got) != 1 || got[0]["power"] != "7.4" {
t.Fatalf("rawRecords = %v", got)
}
if rawRecords(body, "charging_pile_info") != nil {
t.Fatal("an object where a list was asked for should yield nothing")
}
if rawRecords([]byte(`{"data":[]}`), "missing") != nil {
t.Fatal("a data that is not an object should yield nothing")
}
}
// The bound-device view carries the charger's state and its OCPP link under
// names of its own — chargerStatus, not evChargerStatus — which is how a card
// ends up printing a dash beside a value the same response contains.
func TestBoundViewNamesTheStateItsOwnWay(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191","alias_name":"Home-DK",
"chargerStatus":2,"ocpp_connect_status":2,"wifi_online":true,"wifi_name":"AP_1_IOT",
"wifi_mac":"7CE91373C236","bt_ble_mac":"7CE91373C238","rssi":-63,"time_zone":"Europe/Copenhagen",
"link_time":1788186620,"img_url":"https://example.invalid/a5191.png","relate_type":["ble","wifi"]}]}}`))
c := inv.list()[0]
if c.StatusDesc != stateCharging {
t.Errorf("statusDesc = %q, want %q — the bound view spells it chargerStatus", c.StatusDesc, stateCharging)
}
if c.OcppStatusDesc != "connected" {
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
}
if c.WifiName != "AP_1_IOT" || c.WifiMac != "7CE91373C236" || c.BleMac != "7CE91373C238" {
t.Errorf("networks = %+v", c)
}
if c.WifiRSSI == nil || *c.WifiRSSI != -63 {
t.Errorf("wifiRssi = %v, want -63", c.WifiRSSI)
}
if c.TimeZone != "Europe/Copenhagen" || c.ImageURL == "" {
t.Errorf("timeZone/imageUrl = %q / %q", c.TimeZone, c.ImageURL)
}
if c.LinkedAt == nil || *c.LinkedAt != 1788186620 {
t.Errorf("linkedAt = %v, want 1788186620", c.LinkedAt)
}
if len(c.RelatedBy) != 2 || c.RelatedBy[0] != "ble" || c.RelatedBy[1] != "wifi" {
t.Errorf("relatedBy = %v, want [ble wifi]", c.RelatedBy)
}
}
// A device record carries the charger's Bluetooth pairing password. That the
// field exists is reportable; its value is not something a card should carry.
func TestAttrsMaskCredentials(t *testing.T) {
inv := newInventory()
inv.addBound([]byte(`{"data":{"data":[{"device_sn":"EVSN1","device_pn":"A5191",
"blue_password":"AT200025277*!","wifi_name":"AP_1_IOT","cloud":{"auth_token":"abc"}}]}}`))
attrs := inv.list()[0].Attrs
for _, key := range []string{"blue_password", "cloud.auth_token"} {
if attrs[key] == "" {
t.Fatalf("attrs[%q] is absent; the field should still be reported", key)
}
if strings.ContainsAny(attrs[key], "ATabc") {
t.Errorf("attrs[%q] = %q, want it masked", key, attrs[key])
}
}
if attrs["wifi_name"] != "AP_1_IOT" {
t.Errorf("wifi_name = %q; masking must not reach a value that is not a credential", attrs["wifi_name"])
}
}