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>
158 lines
5.7 KiB
Go
158 lines
5.7 KiB
Go
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)
|
|
}
|
|
}
|