Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go
T
tajniak81andClaude Opus 5 5e6b8b4b1c Anker Solix: the charger's mode, and the modes it can be moved into
The connector was written against anker-solix-api v3.7.0 and upstream is at
3.8.1 now. The reassuring half of the check first: nothing we depend on moved.
The passport/login ECDH exchange, the headers, and every endpoint path this
plugin calls are identical across v3.7.0...v3.8.1 — the only apitypes movement
touching an EV charger was get_device_rfid_cards being reordered within its own
dict. The 400 new lines in charger.py are the A2345 USB charger, which shares a
filename with our device and nothing else.

What did land for the V1 is two entries in the release notes, and both are MQTT:
3.8.0 gave standalone chargers the usage-mode entity they were missing, 3.8.1
added a switch that reads those modes as a plain on/off so EVCC and its like
have a binary to hold. We control chargers over OCPP, not MQTT, so the command
path is not ours to port. The reading of state underneath it is, and that half
does come over the cloud.

So charger-state. The status code arrives under two different names depending on
which system family a site belongs to — operating_state inside a scene's
charging_pile_list, evChargerStatus inside HES system running info — and
upstream's poller quietly renames both to ev_charger_status on ingest, which is
the tell that they are the same number. We ask both and merge, because a site
answering only one of them is the normal case rather than a fault; the call
fails only when neither view is there. chargerMode and chargerModeOptions then
follow ev_charger_mode_state and ev_charger_mode_options as written, including
the rule that a stopped charger is startable only from standby, and the binary
is the same one 3.8.1 chose: everything that is not stop_charge counts as on.

The gap worth naming is that the boost flag and the plug and start countdowns
reach upstream over MQTT and never over the cloud, so three of the six modes
cannot occur here. That is not a bug to be found later — chargerMode takes them
as parameters and the callers pass their zero values, so the day an MQTT source
exists the derivation is already correct and only its inputs change. The package
doc says so in the scope list beside the other limits.

Five endpoints upstream has had all along and we never exposed come with it,
all EV-charger-scoped: the site scene, energy_analysis under device_type
ev_charger, a charger's RFID cards, Anker's own OCPP endpoint list, and one
vehicle's details. charger-status takes the featuretype it was hardcoding at 1,
since upstream's exporter asks for both 1 and 2 and there was never a reason for
us to see only half.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:39:30 +02:00

432 lines
14 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)
}
}
}
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,
"": 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 only from standby.
{modeStopCharge, stateStandby, []string{modeStartCharge, modeStopCharge}},
{modeStopCharge, stateCompleted, []string{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)
}
}
}