anker-solix-api v3.9.0 (2026-09-29) is mostly PPS work — time-of-use plans over the cloud API, device energy queries, a condensed V2 command map — and none of that reaches an A5191; its MQTT map and command definitions are unchanged from v3.8.1. Three things do: - Singapore accounts live on the EU server (ha-anker-solix #593), so SG leaves the .com list. - A charger still showing a finished session can be started again. The app offers Start while status is completed; the mode options did not (ha-anker-solix #629, #630). - A re-login after a rejected token drops the payload handshake with the token, as upstream now does on a failed login. It was never reused — a successful login cleared it and ensureCipher refuses without a token — but it no longer outlives the token it was run under. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
458 lines
15 KiB
Go
458 lines
15 KiB
Go
package ankersolix
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/aes"
|
|
"crypto/cipher"
|
|
"crypto/ecdh"
|
|
"crypto/md5"
|
|
"crypto/rand"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"regexp"
|
|
"strings"
|
|
"testing"
|
|
|
|
"drivervault/apiserver/internal/plugins"
|
|
)
|
|
|
|
func TestDescriptor(t *testing.T) {
|
|
d := (&Plugin{}).Descriptor()
|
|
if d.Name != "anker-solix" {
|
|
t.Fatalf("name = %q, want anker-solix", d.Name)
|
|
}
|
|
if d.Kind != plugins.KindBuiltin {
|
|
t.Fatalf("kind = %q, want builtin", d.Kind)
|
|
}
|
|
if len(d.Capabilities) == 0 {
|
|
t.Fatal("expected capabilities")
|
|
}
|
|
fields := map[string]plugins.ConfigField{}
|
|
for _, f := range d.ConfigFields {
|
|
fields[f.Key] = f
|
|
}
|
|
for _, k := range []string{"email", "password", "country"} {
|
|
if _, ok := fields[k]; !ok {
|
|
t.Errorf("config field %q should be present", k)
|
|
}
|
|
}
|
|
if fields["email"].Required || fields["password"].Required {
|
|
t.Error("credentials must not be required at the global layer")
|
|
}
|
|
if !fields["password"].Secret {
|
|
t.Error("password field must be marked secret")
|
|
}
|
|
|
|
// controlMode is a select advertising the three OCPP control paths.
|
|
cm, ok := fields["controlMode"]
|
|
if !ok {
|
|
t.Fatal("controlMode config field should be present")
|
|
}
|
|
if cm.Type != "select" {
|
|
t.Errorf("controlMode type = %q, want select", cm.Type)
|
|
}
|
|
if cm.Default != "off" {
|
|
t.Errorf("controlMode default = %q, want off", cm.Default)
|
|
}
|
|
want := map[string]bool{"off": false, "own": false, "proxy": false}
|
|
for _, o := range cm.Options {
|
|
if _, known := want[o.Value]; known {
|
|
want[o.Value] = true
|
|
}
|
|
}
|
|
for v, seen := range want {
|
|
if !seen {
|
|
t.Errorf("controlMode is missing option %q", v)
|
|
}
|
|
}
|
|
|
|
// controlModesDisabled is the superadmin's hide-list: which of the modes the
|
|
// organizations and users below are offered at all. off is not among them —
|
|
// monitoring only is the fallback, so no layer may take it away.
|
|
hide, ok := fields["controlModesDisabled"]
|
|
if !ok {
|
|
t.Fatal("controlModesDisabled config field should be present")
|
|
}
|
|
if hide.Type != "multiselect" {
|
|
t.Errorf("controlModesDisabled type = %q, want multiselect", hide.Type)
|
|
}
|
|
hideable := map[string]bool{"mqtt": false, "modbus": false, "own": false, "proxy": false}
|
|
for _, o := range hide.Options {
|
|
if o.Value == "off" {
|
|
t.Error("off must not be hideable — it is what a charger falls back to")
|
|
}
|
|
if _, known := hideable[o.Value]; known {
|
|
hideable[o.Value] = true
|
|
}
|
|
}
|
|
for v, seen := range hideable {
|
|
if !seen {
|
|
t.Errorf("controlModesDisabled is missing option %q", v)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistered(t *testing.T) {
|
|
var found bool
|
|
for _, v := range plugins.NewManager(plugins.NewMemoryStore(nil)).List() {
|
|
if v.Name == "anker-solix" {
|
|
found = true
|
|
}
|
|
}
|
|
if !found {
|
|
t.Fatal("anker-solix not registered with the plugin manager")
|
|
}
|
|
}
|
|
|
|
func TestServerSelection(t *testing.T) {
|
|
cases := map[string]string{
|
|
"DE": serverEU,
|
|
"GB": serverEU, // not in the COM list → EU default
|
|
"US": serverCOM,
|
|
"AU": serverCOM,
|
|
"SG": serverEU, // moved off the COM list in upstream v3.9.0
|
|
"": serverEU, // empty → defaults to DE → EU
|
|
}
|
|
for country, want := range cases {
|
|
p := &Plugin{}
|
|
_ = p.Init(context.Background(), map[string]string{"country": country})
|
|
if p.apiBase != want {
|
|
t.Errorf("country %q: apiBase = %q, want %q", country, p.apiBase, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEncryptPasswordRoundTrip(t *testing.T) {
|
|
// The shared secret is 32 bytes; the reference encrypts AES-256-CBC with the
|
|
// key as its own IV[:16] and PKCS#7 padding, base64-encoded. Verify our
|
|
// output decrypts back to the plaintext under the same scheme.
|
|
shared := make([]byte, 32)
|
|
if _, err := rand.Read(shared); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
const pw = "s3cr3t-pässwörd!"
|
|
|
|
enc, err := encryptPassword(pw, shared)
|
|
if err != nil {
|
|
t.Fatalf("encryptPassword: %v", err)
|
|
}
|
|
raw, err := base64.StdEncoding.DecodeString(enc)
|
|
if err != nil {
|
|
t.Fatalf("base64 decode: %v", err)
|
|
}
|
|
if len(raw)%aes.BlockSize != 0 || len(raw) == 0 {
|
|
t.Fatalf("ciphertext length %d not a positive multiple of block size", len(raw))
|
|
}
|
|
|
|
block, _ := aes.NewCipher(shared)
|
|
out := make([]byte, len(raw))
|
|
cipher.NewCBCDecrypter(block, shared[:aes.BlockSize]).CryptBlocks(out, raw)
|
|
// strip PKCS#7 padding
|
|
pad := int(out[len(out)-1])
|
|
if pad < 1 || pad > aes.BlockSize {
|
|
t.Fatalf("bad padding byte %d", pad)
|
|
}
|
|
if got := string(out[:len(out)-pad]); got != pw {
|
|
t.Fatalf("round-trip = %q, want %q", got, pw)
|
|
}
|
|
}
|
|
|
|
func TestEncryptPasswordShortKey(t *testing.T) {
|
|
if _, err := encryptPassword("x", make([]byte, 16)); err == nil {
|
|
t.Fatal("expected error for a too-short shared key")
|
|
}
|
|
}
|
|
|
|
func TestMD5Hex(t *testing.T) {
|
|
// gtoken = md5(user_id). Verify against the standard library.
|
|
want := md5.Sum([]byte("user-123"))
|
|
if got := md5hex("user-123"); got != hex.EncodeToString(want[:]) {
|
|
t.Fatalf("md5hex = %q, want %q", got, hex.EncodeToString(want[:]))
|
|
}
|
|
if len(md5hex("anything")) != 32 {
|
|
t.Fatal("md5 hex digest must be 32 chars")
|
|
}
|
|
}
|
|
|
|
func TestClientPublicKeyFormat(t *testing.T) {
|
|
// The client public key sent to Anker is the uncompressed P-256 point
|
|
// (0x04 || X || Y = 65 bytes) in hex, and Anker's server key must parse.
|
|
priv, err := ecdh.P256().GenerateKey(rand.Reader)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
pub := priv.PublicKey().Bytes()
|
|
if len(pub) != 65 || pub[0] != 0x04 {
|
|
t.Fatalf("client public key not uncompressed 65-byte point: len=%d first=0x%02x", len(pub), pub[0])
|
|
}
|
|
|
|
serverBytes, err := hex.DecodeString(serverPublicKeyHex)
|
|
if err != nil {
|
|
t.Fatalf("server key hex: %v", err)
|
|
}
|
|
if _, err := ecdh.P256().NewPublicKey(serverBytes); err != nil {
|
|
t.Fatalf("server public key does not parse as a P-256 point: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTimezoneFormat(t *testing.T) {
|
|
gmt, ms := timezone()
|
|
if !regexp.MustCompile(`^GMT[+-]\d{2}:\d{2}$`).MatchString(gmt) {
|
|
t.Fatalf("timezone string %q does not match GMT+HH:MM", gmt)
|
|
}
|
|
// The millisecond offset must be consistent with the string's whole hours.
|
|
if (ms/1000)%60 != 0 && strings.HasSuffix(gmt, ":00") {
|
|
t.Fatalf("offset ms %d inconsistent with %q", ms, gmt)
|
|
}
|
|
}
|
|
|
|
func TestInvokeRequiresSN(t *testing.T) {
|
|
p := &Plugin{}
|
|
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
|
|
|
|
for _, action := range []string{"charger-status", "charge-stats", "charge-orders", "ocpp-info"} {
|
|
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
|
|
!strings.Contains(err.Error(), "requires an sn") {
|
|
t.Fatalf("action %q: expected sn-required error, got %v", action, err)
|
|
}
|
|
}
|
|
if _, err := p.Invoke(context.Background(), "bogus", nil); err == nil ||
|
|
!strings.Contains(err.Error(), "unknown action") {
|
|
t.Fatalf("expected unknown-action error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestCountChargers(t *testing.T) {
|
|
body := []byte(`{"code":0,"msg":"success!","data":{"evChargers":[],"userBindEvChargersCount":2}}`)
|
|
if n, ok := countChargers(body); !ok || n != 2 {
|
|
t.Fatalf("countChargers = (%d,%v), want (2,true)", n, ok)
|
|
}
|
|
if _, ok := countChargers([]byte("not json")); ok {
|
|
t.Fatal("expected ok=false for invalid json")
|
|
}
|
|
}
|
|
|
|
func TestApiErrorAndAuthCode(t *testing.T) {
|
|
if _, _, ok := apiError([]byte(`{"code":0,"msg":"success!"}`)); ok {
|
|
t.Fatal("code 0 must not be an error")
|
|
}
|
|
code, msg, ok := apiError([]byte(`{"code":10000,"msg":"boom"}`))
|
|
if !ok || code != 10000 || msg != "boom" {
|
|
t.Fatalf("apiError = (%d,%q,%v)", code, msg, ok)
|
|
}
|
|
if !isAuthCode([]byte(`{"code":401,"msg":"token invalid"}`)) {
|
|
t.Fatal("401 should be an auth code")
|
|
}
|
|
if isAuthCode([]byte(`{"code":10000,"msg":"other"}`)) {
|
|
t.Fatal("10000 should not be an auth code")
|
|
}
|
|
}
|
|
|
|
// TestLoginBodyShape guards the exact login payload the reference builds, without
|
|
// hitting the network: it re-derives the encrypted password and confirms it
|
|
// decrypts under the same ECDH shared secret. (Sanity check on the wiring.)
|
|
func TestLoginBodyShape(t *testing.T) {
|
|
priv, _ := ecdh.P256().GenerateKey(rand.Reader)
|
|
serverBytes, _ := hex.DecodeString(serverPublicKeyHex)
|
|
serverPub, _ := ecdh.P256().NewPublicKey(serverBytes)
|
|
shared, err := priv.ECDH(serverPub)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(shared) != 32 {
|
|
t.Fatalf("shared secret len = %d, want 32", len(shared))
|
|
}
|
|
enc, err := encryptPassword("pw", shared)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
raw, _ := base64.StdEncoding.DecodeString(enc)
|
|
block, _ := aes.NewCipher(shared)
|
|
out := make([]byte, len(raw))
|
|
cipher.NewCBCDecrypter(block, shared[:aes.BlockSize]).CryptBlocks(out, raw)
|
|
pad := int(out[len(out)-1])
|
|
if !bytes.Equal(out[:len(out)-pad], []byte("pw")) {
|
|
t.Fatal("login password encryption did not round-trip")
|
|
}
|
|
}
|
|
|
|
func TestInvokeRequiresSiteID(t *testing.T) {
|
|
p := &Plugin{}
|
|
_ = p.Init(context.Background(), map[string]string{"email": "u@example.com", "password": "p"})
|
|
|
|
for _, action := range []string{"charger-state", "site-status", "charge-energy"} {
|
|
if _, err := p.Invoke(context.Background(), action, nil); err == nil ||
|
|
!strings.Contains(err.Error(), "requires a siteId") {
|
|
t.Fatalf("action %q: expected siteId-required error, got %v", action, err)
|
|
}
|
|
}
|
|
if _, err := p.Invoke(context.Background(), "vehicle", nil); err == nil ||
|
|
!strings.Contains(err.Error(), "requires a vehicleId") {
|
|
t.Fatalf("expected vehicleId-required error, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestEnergyRange(t *testing.T) {
|
|
for in, want := range map[string]string{
|
|
"day": "day", "week": "week", "month": "month", "year": "year",
|
|
"YEAR": "year", " month ": "month",
|
|
"": "week", "decade": "week",
|
|
} {
|
|
if got := energyRange(in); got != want {
|
|
t.Errorf("energyRange(%q) = %q, want %q", in, got, want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChargerMode(t *testing.T) {
|
|
// Mirrors anker-solix-api's ev_charger_mode_state: a boost overrides
|
|
// everything, preparing splits on the countdowns, and the three active
|
|
// charging states all read as start_charge.
|
|
cases := []struct {
|
|
status string
|
|
boost bool
|
|
plugCountdown, startCountdown int
|
|
want string
|
|
}{
|
|
{stateCharging, true, 0, 0, modeBoostCharge},
|
|
{stateStandby, true, 0, 0, modeBoostCharge},
|
|
{statePreparing, false, 30, 0, modeWaitPlug},
|
|
{statePreparing, false, 0, 30, modeWaitStart},
|
|
{statePreparing, false, 30, 30, modeWaitPlug}, // plug wins over start
|
|
{statePreparing, false, 0, 0, modeStartCharge},
|
|
{stateCharging, false, 0, 0, modeStartCharge},
|
|
{stateChargerPaused, false, 0, 0, modeStartCharge},
|
|
{stateVehiclePaused, false, 0, 0, modeStartCharge},
|
|
{stateStandby, false, 0, 0, modeStopCharge},
|
|
{stateCompleted, false, 0, 0, modeStopCharge},
|
|
{stateDisabled, false, 0, 0, modeStopCharge},
|
|
{stateError, false, 0, 0, modeStopCharge},
|
|
{stateUnknown, false, 0, 0, modeStopCharge},
|
|
}
|
|
for _, c := range cases {
|
|
got := chargerMode(c.status, c.boost, c.plugCountdown, c.startCountdown)
|
|
if got != c.want {
|
|
t.Errorf("chargerMode(%q, boost=%v, %d, %d) = %q, want %q",
|
|
c.status, c.boost, c.plugCountdown, c.startCountdown, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChargerModeOptions(t *testing.T) {
|
|
// Mirrors ev_charger_mode_options; results are sorted.
|
|
cases := []struct {
|
|
mode, status string
|
|
want []string
|
|
}{
|
|
{modeStartCharge, stateCharging, []string{modeBoostCharge, modeStartCharge, modeStopCharge}},
|
|
{modeWaitStart, statePreparing, []string{modeSkipDelay, modeStopCharge, modeWaitStart}},
|
|
{modeWaitPlug, statePreparing, []string{modeStopCharge, modeWaitPlug}},
|
|
{modeBoostCharge, stateCharging, []string{modeBoostCharge, modeStopCharge}},
|
|
// Stopped: startable from standby, and from completed since v3.9.0.
|
|
{modeStopCharge, stateStandby, []string{modeStartCharge, modeStopCharge}},
|
|
{modeStopCharge, stateCompleted, []string{modeStartCharge, modeStopCharge}},
|
|
{modeStopCharge, stateError, []string{modeStopCharge}},
|
|
{"", stateUnknown, []string{}},
|
|
}
|
|
for _, c := range cases {
|
|
got := chargerModeOptions(c.mode, c.status)
|
|
if len(got) != len(c.want) {
|
|
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
|
|
continue
|
|
}
|
|
for i := range got {
|
|
if got[i] != c.want[i] {
|
|
t.Errorf("chargerModeOptions(%q, %q) = %v, want %v", c.mode, c.status, got, c.want)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestParseScenePiles(t *testing.T) {
|
|
body := []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":"","device_name":"nameless"},
|
|
{"device_sn":"EVSN2","operating_state":99}]}}}`)
|
|
|
|
got := parseScenePiles(body, "site-1")
|
|
if len(got) != 2 {
|
|
t.Fatalf("parsed %d chargers, want 2 (the SN-less entry is dropped)", len(got))
|
|
}
|
|
|
|
c := got[0]
|
|
if c.SN != "EVSN1" || c.Name != "Garage" || c.SiteID != "site-1" || c.Source != "scene" {
|
|
t.Errorf("identity = %+v", c)
|
|
}
|
|
if c.StatusDesc != stateCharging || c.Mode != modeStartCharge {
|
|
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateCharging, modeStartCharge)
|
|
}
|
|
if c.Charging == nil || !*c.Charging {
|
|
t.Error("a charging charger must read as charging")
|
|
}
|
|
if c.Power != "7.4" {
|
|
t.Errorf("power = %q, want 7.4", c.Power)
|
|
}
|
|
if c.OcppStatusDesc != "connected" {
|
|
t.Errorf("ocppStatusDesc = %q, want connected", c.OcppStatusDesc)
|
|
}
|
|
|
|
// An unmapped status code degrades to unknown, and unknown stops.
|
|
if got[1].StatusDesc != stateUnknown || got[1].Mode != modeStopCharge {
|
|
t.Errorf("unmapped status = %q/%q, want %q/%q",
|
|
got[1].StatusDesc, got[1].Mode, stateUnknown, modeStopCharge)
|
|
}
|
|
if got[1].OcppStatus != nil {
|
|
t.Error("absent ocpp_connect_status must stay nil, not default to disconnected")
|
|
}
|
|
if parseScenePiles([]byte("not json"), "site-1") != nil {
|
|
t.Error("invalid json should parse to nil")
|
|
}
|
|
}
|
|
|
|
func TestParseHesChargers(t *testing.T) {
|
|
body := []byte(`{"code":0,"data":{"hasEvCharger":true,"evChargerInfos":[
|
|
{"evChargerSn":"EVSN9","evChargerStatus":0,"evChargerName":"V1 EV Charger"}]}}`)
|
|
|
|
got := parseHesChargers(body, "site-2")
|
|
if len(got) != 1 {
|
|
t.Fatalf("parsed %d chargers, want 1", len(got))
|
|
}
|
|
c := got[0]
|
|
if c.SN != "EVSN9" || c.Source != "hes" || c.SiteID != "site-2" {
|
|
t.Errorf("identity = %+v", c)
|
|
}
|
|
if c.StatusDesc != stateStandby || c.Mode != modeStopCharge {
|
|
t.Errorf("status/mode = %q/%q, want %q/%q", c.StatusDesc, c.Mode, stateStandby, modeStopCharge)
|
|
}
|
|
if c.Charging == nil || *c.Charging {
|
|
t.Error("a standby charger must not read as charging")
|
|
}
|
|
// Standby is the one stopped state that can be started again.
|
|
var startable bool
|
|
for _, o := range c.ModeOptions {
|
|
if o == modeStartCharge {
|
|
startable = true
|
|
}
|
|
}
|
|
if !startable {
|
|
t.Errorf("standby should offer start_charge, options = %v", c.ModeOptions)
|
|
}
|
|
if parseHesChargers([]byte("{"), "site-2") != nil {
|
|
t.Error("invalid json should parse to nil")
|
|
}
|
|
}
|
|
|
|
func TestStatusName(t *testing.T) {
|
|
for code, want := range map[int]string{
|
|
0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused,
|
|
4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled,
|
|
8: stateError, 9: stateUnknown, -1: stateUnknown,
|
|
} {
|
|
if got := statusName(code); got != want {
|
|
t.Errorf("statusName(%d) = %q, want %q", code, got, want)
|
|
}
|
|
}
|
|
}
|