The Anker Solix connector was read-only (cloud monitoring only). Add an
OCPP 1.6J control path with a per-user, cascading control mode:
- off monitoring only (default, unchanged behavior)
- own DriverVault is the charger's Central System (full control)
- proxy DriverVault relays to Anker's cloud and injects commands
New internal/ocpp subsystem (stdlib-only, hand-rolled RFC 6455): a CSMS
with session management, inbound dispatch, and typed control commands
(RemoteStart/Stop, SetChargingProfile current limit, ChangeAvailability,
Reset, UnlockConnector, TriggerMessage, Get/ChangeConfiguration). Own- and
proxy-mode paths are verified end-to-end against a simulated charge point.
The charger connects to /ocpp/{serial}, authenticated with OCPP Basic auth
(serial + a per-charger control token) resolved to the owning user via an
in-memory token index. Control REST endpoints mirror the monitoring ones and
reuse the same cascade gate plus a live-session check. controlMode is a new
cascade field (global -> org -> user) advertised as a select on the plugin.
Frontend: control-mode select + provisioning card in Settings, and a real
Start/Stop/limit/reset control panel in Charging, gated on the active mode.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
253 lines
7.5 KiB
Go
253 lines
7.5 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(t.TempDir() + "/plugins.json").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")
|
|
}
|
|
}
|