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>
432 lines
14 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|