diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go new file mode 100644 index 0000000..4d0acf2 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -0,0 +1,549 @@ +// Package ankersolix is a built-in connector for the Anker Power / Solix cloud, +// scoped to the Anker Solix V1 Smart EV Charger (product A5191). It is a Go +// re-implementation of the authentication and read-only data flow from the +// anker-solix-api project (https://github.com/thomluther/anker-solix-api), +// adapted to DriverVault's plugin contract. +// +// Scope & limitations: +// - Read-only. Only EV-charger information is retrieved; no charge start/stop +// or configuration commands are implemented. +// - Single device family. Capabilities target the V1 Smart EV Charger; other +// Anker Power devices (solarbanks, power stations, HES) are out of scope. +// - Unofficial. This talks to Anker's private mobile-app cloud API with the +// app's public client identity; Anker may change or break it at any time. +// +// Authentication is a single login exchange, not OAuth: +// 1. An ephemeral ECDH key pair (NIST P-256 / SECP256R1) is generated and a +// shared secret is derived against Anker's static server public key. +// 2. The account password is AES-256-CBC encrypted under that shared secret +// (key = secret, IV = first 16 bytes of the secret, PKCS#7 padding) and +// base64-encoded. +// 3. POST passport/login with the client public key and encrypted password +// returns an auth_token (valid ~7 days), a user_id and an expiry. All +// subsequent requests send x-auth-token plus gtoken = md5(user_id). +// +// The auth token is long-lived; when it nears expiry (or a request is rejected +// as unauthorized) the plugin performs a fresh login. There is no refresh token. +package ankersolix + +import ( + "bytes" + "context" + "crypto/aes" + "crypto/cipher" + "crypto/ecdh" + "crypto/md5" + "crypto/rand" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "drivervault/apiserver/internal/plugins" +) + +// Anker cloud servers. Country assignment decides which one an account lives on; +// a wrong assignment authenticates but returns no sites/devices. Countries in +// comCountries use the global server, everything else defaults to EU (mirroring +// anker-solix-api's API_SERVERS / API_COUNTRIES). +const ( + serverEU = "https://ankerpower-api-eu.anker.com" + serverCOM = "https://ankerpower-api.anker.com" + + // serverPublicKeyHex is Anker's static server public key (uncompressed P-256 + // point, 0x04 || X || Y). Identical for the EU and global servers. This is an + // app constant, not a user secret. + serverPublicKeyHex = "04c5c00c4f8d1197cc7c3167c52bf7acb054d722f0ef08dcd7e0883236e0d72a3868d9750cb47fa4619248f3d83f0f662671dadc6e2d31c2f41db0161651c7c076" +) + +// Endpoints. Login plus the read-only endpoints relevant to an EV charger. +const ( + epLogin = "passport/login" + + epStandaloneChargers = "charging_hes_svc/get_user_bind_and_not_in_station_evchargers" // list account EV chargers not assigned to a station + epStationInfo = "charging_hes_svc/get_evcharger_station_info" // per-charger station/status info + epChargeStats = "power_service/v1/app/order/get_charge_order_stats" // charging totals for a charger + epChargeStatsList = "power_service/v1/app/order/get_charge_order_stats_list" // per-order charging history + epOcppInfo = "power_service/v1/app/get_ocpp_info" // OCPP endpoint source info for a charger + epBindDevices = "power_service/v1/app/get_relate_and_bind_devices" // bound devices incl. firmware version + epSiteList = "power_service/v1/site/get_site_list" // sites (systems) on the account + epUserVehicles = "power_service/v1/app/vehicle/get_vehicle_list" // vehicles registered for smart charging +) + +// tokenExpiryMargin is subtracted from the reported token lifetime so a request +// never fires with a token that expires mid-flight. +const tokenExpiryMargin = 5 * time.Minute + +// comCountries are the ISO country IDs served by the global (".com") server. +// Any country not listed here is served by the EU server. +var comCountries = map[string]bool{ + "DZ": true, "LB": true, "SY": true, "EG": true, "LY": true, "TN": true, + "MA": true, "JO": true, "PS": true, "AR": true, "AU": true, "BR": true, + "HK": true, "IN": true, "MX": true, "NG": true, "NZ": true, "RU": true, + "SG": true, "ZA": true, "KR": true, "TW": true, "US": true, "CA": true, +} + +func init() { + plugins.Register("anker-solix", func() plugins.Plugin { return &Plugin{} }) +} + +// tokenInfo holds the currently-held auth token and derived values. +type tokenInfo struct { + authToken string + gtoken string // md5(user_id) + nickname string + expiration time.Time +} + +// Plugin is the Anker Solix EV-charger connector. +type Plugin struct { + email string + password string + countryId string + + mu sync.Mutex // guards tok and the login flow + tok *tokenInfo + apiBase string + client *http.Client +} + +// Descriptor returns the plugin's static metadata for the admin panel. +func (p *Plugin) Descriptor() plugins.Descriptor { + return plugins.Descriptor{ + Name: "anker-solix", + Provider: "Anker Solix (V1 Smart EV Charger)", + Version: "0.1.0", + Kind: plugins.KindBuiltin, + Category: plugins.CategoryAPIsExternal, + AuthType: plugins.AuthBasic, + Capabilities: []plugins.Capability{ + {ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "List EV chargers bound to the account."}, + {ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn)."}, + {ID: "charge-stats", Method: "POST", Endpoint: epChargeStats, Description: "Cumulative charging statistics for one charger (needs sn)."}, + {ID: "charge-orders", Method: "POST", Endpoint: epChargeStatsList, Description: "Per-session charging history for one charger (needs sn)."}, + {ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."}, + {ID: "devices", Method: "POST", Endpoint: epBindDevices, Description: "Bound devices on the account, incl. firmware version."}, + {ID: "sites", Method: "POST", Endpoint: epSiteList, Description: "Sites (systems) registered to the account."}, + {ID: "vehicles", Method: "POST", Endpoint: epUserVehicles, Description: "Vehicles registered for smart charging."}, + }, + ConfigFields: []plugins.ConfigField{ + // Credentials are intentionally NOT required at the global (panel) layer, + // matching the Toyota connector: an operator may set a shared account + // here, or leave it blank for a future per-user cascade. The plugin + // returns a clear error when a request is made without credentials. + {Key: "email", Label: "Anker account email", Type: "text", + Help: "The email address for your Anker / Solix (mobile app) account."}, + {Key: "password", Label: "Anker account password", Type: "password", Secret: true, + Help: "Your Anker account password. Stored locally, sent only (encrypted) to Anker's login endpoint."}, + {Key: "country", Label: "Country", Type: "text", Default: "DE", + Help: "ISO country code of your Anker account (e.g. DE, GB, US). Determines which Anker server the account lives on; a wrong value logs in but shows no devices."}, + }, + } +} + +// Init applies resolved config and builds the HTTP client. It performs no +// network I/O; login happens lazily on the first request or health check. +func (p *Plugin) Init(_ context.Context, config map[string]string) error { + p.mu.Lock() + defer p.mu.Unlock() + + p.email = strings.TrimSpace(config["email"]) + p.password = config["password"] + p.countryId = strings.ToUpper(strings.TrimSpace(config["country"])) + if p.countryId == "" { + p.countryId = "DE" + } + if comCountries[p.countryId] { + p.apiBase = serverCOM + } else { + p.apiBase = serverEU + } + // Config change invalidates any cached token. + p.tok = nil + p.client = &http.Client{Timeout: 30 * time.Second} + return nil +} + +// HealthCheck logs in (if needed) and lists the account's EV chargers. +func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { + start := time.Now() + body, err := p.apiRequest(ctx, epStandaloneChargers, map[string]any{}) + lat := time.Since(start).Milliseconds() + if err != nil { + return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())} + } + detail := "authenticated; EV chargers reachable" + if n, ok := countChargers(body); ok { + detail = fmt.Sprintf("authenticated; %d EV charger(s) bound to account", n) + } + return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail} +} + +// Invoke runs a named read-only capability. Per-charger actions require an "sn" +// (the charger serial) in params. The upstream response body is returned +// verbatim. +func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { + var pp struct { + SN string `json:"sn"` + } + if len(params) > 0 { + if err := json.Unmarshal(params, &pp); err != nil { + return nil, fmt.Errorf("anker-solix: invalid params: %w", err) + } + } + pp.SN = strings.TrimSpace(pp.SN) + + var ( + endpoint string + payload map[string]any + needSN bool + ) + switch action { + case "chargers": + endpoint, payload = epStandaloneChargers, map[string]any{} + case "devices": + endpoint, payload = epBindDevices, map[string]any{} + case "sites": + endpoint, payload = epSiteList, map[string]any{} + case "vehicles": + endpoint, payload = epUserVehicles, map[string]any{} + case "charger-status": + endpoint, needSN = epStationInfo, true + payload = map[string]any{"evChargerSn": pp.SN, "featuretype": 1} + case "charge-stats": + endpoint, needSN = epChargeStats, true + payload = map[string]any{"device_sn": pp.SN, "date_type": "all", "start_date": "", "end_date": ""} + case "charge-orders": + endpoint, needSN = epChargeStatsList, true + payload = map[string]any{"device_sn": pp.SN, "order_status": 1, "date_type": "all", + "start_date": "", "end_date": "", "page": 0, "page_size": 10} + case "ocpp-info": + endpoint, needSN = epOcppInfo, true + payload = map[string]any{"device_sn": pp.SN} + default: + return nil, fmt.Errorf("anker-solix: unknown action %q", action) + } + if needSN && pp.SN == "" { + return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action) + } + + body, err := p.apiRequest(ctx, endpoint, payload) + if err != nil { + return nil, err + } + return json.RawMessage(body), nil +} + +// Shutdown releases pooled connections. +func (p *Plugin) Shutdown(context.Context) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.client != nil { + p.client.CloseIdleConnections() + } + return nil +} + +// ---- token management -------------------------------------------------------- + +// ensureToken guarantees a non-expired auth token, logging in as needed. +// Serialized by p.mu so concurrent requests trigger at most one login. +func (p *Plugin) ensureToken(ctx context.Context) (tokenInfo, error) { + p.mu.Lock() + defer p.mu.Unlock() + + if p.tok != nil && time.Now().Before(p.tok.expiration) { + return *p.tok, nil + } + if err := p.login(ctx); err != nil { + return tokenInfo{}, err + } + return *p.tok, nil +} + +// login performs the ECDH + AES password exchange against passport/login and +// stores the returned token. Caller holds p.mu. +func (p *Plugin) login(ctx context.Context) error { + if p.email == "" || p.password == "" { + return errors.New("anker-solix: email and password are required") + } + + // Fresh ephemeral P-256 key pair and shared secret against Anker's server key. + curve := ecdh.P256() + priv, err := curve.GenerateKey(rand.Reader) + if err != nil { + return fmt.Errorf("anker-solix: generate key: %w", err) + } + serverBytes, err := hex.DecodeString(serverPublicKeyHex) + if err != nil { + return fmt.Errorf("anker-solix: server key: %w", err) + } + serverPub, err := curve.NewPublicKey(serverBytes) + if err != nil { + return fmt.Errorf("anker-solix: server key: %w", err) + } + shared, err := priv.ECDH(serverPub) + if err != nil { + return fmt.Errorf("anker-solix: derive shared key: %w", err) + } + encPassword, err := encryptPassword(p.password, shared) + if err != nil { + return fmt.Errorf("anker-solix: %w", err) + } + + gmt, offsetMs := timezone() + reqBody := map[string]any{ + "ab": p.countryId, + "client_secret_info": map[string]any{"public_key": hex.EncodeToString(priv.PublicKey().Bytes())}, + "enc": 0, + "email": p.email, + "password": encPassword, + "time_zone": offsetMs, + "transaction": fmt.Sprintf("%d", time.Now().UnixMilli()), + } + + body, status, err := p.doRequest(ctx, epLogin, reqBody, "", "", gmt) + if err != nil { + return err + } + if status != http.StatusOK { + return fmt.Errorf("anker-solix: login failed (HTTP %d): %s", status, shorten(string(body))) + } + return p.storeToken(body) +} + +// storeToken parses the login envelope and updates p.tok. Caller holds p.mu. +func (p *Plugin) storeToken(body []byte) error { + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + Data struct { + AuthToken string `json:"auth_token"` + UserID string `json:"user_id"` + NickName string `json:"nick_name"` + TokenExpires int64 `json:"token_expires_at"` + } `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return fmt.Errorf("anker-solix: decode login response: %w", err) + } + if env.Code != 0 { + return fmt.Errorf("anker-solix: login rejected (code %d): %s", env.Code, shorten(env.Msg)) + } + if env.Data.AuthToken == "" || env.Data.UserID == "" { + return errors.New("anker-solix: login response missing auth_token or user_id") + } + + // token_expires_at is a unix timestamp (seconds); Anker tokens live ~7 days. + // Fall back to a conservative 6-day lifetime if the field is absent. + exp := time.Now().Add(6 * 24 * time.Hour) + if env.Data.TokenExpires > 0 { + exp = time.Unix(env.Data.TokenExpires, 0) + } + exp = exp.Add(-tokenExpiryMargin) + + p.tok = &tokenInfo{ + authToken: env.Data.AuthToken, + gtoken: md5hex(env.Data.UserID), + nickname: env.Data.NickName, + expiration: exp, + } + return nil +} + +// ---- data requests ----------------------------------------------------------- + +// apiRequest performs an authenticated POST, re-authenticating once if the token +// is rejected. It returns the raw response body; a non-zero API code or non-2xx +// status is returned as an error. +func (p *Plugin) apiRequest(ctx context.Context, endpoint string, payload map[string]any) ([]byte, error) { + tok, err := p.ensureToken(ctx) + if err != nil { + return nil, err + } + + gmt, _ := timezone() + body, status, err := p.doRequest(ctx, endpoint, payload, tok.authToken, tok.gtoken, gmt) + if err != nil { + return nil, err + } + + // A rejected token surfaces as 401/403 or an auth error code; log in afresh + // and retry once. + if status == http.StatusUnauthorized || status == http.StatusForbidden || isAuthCode(body) { + p.mu.Lock() + p.tok = nil + lerr := p.login(ctx) + var newTok tokenInfo + if lerr == nil { + newTok = *p.tok + } + p.mu.Unlock() + if lerr != nil { + return nil, lerr + } + body, status, err = p.doRequest(ctx, endpoint, payload, newTok.authToken, newTok.gtoken, gmt) + if err != nil { + return nil, err + } + } + + if status != http.StatusOK { + return nil, fmt.Errorf("anker-solix: request %s failed (HTTP %d): %s", endpoint, status, shorten(string(body))) + } + if code, msg, ok := apiError(body); ok { + return nil, fmt.Errorf("anker-solix: request %s failed (code %d): %s", endpoint, code, shorten(msg)) + } + return body, nil +} + +// doRequest issues a single POST to an endpoint with the common Anker headers. +// When authToken is empty the auth headers are omitted (login request). +func (p *Plugin) doRequest(ctx context.Context, endpoint string, payload map[string]any, authToken, gtoken, gmt string) ([]byte, int, error) { + buf, err := json.Marshal(payload) + if err != nil { + return nil, 0, fmt.Errorf("anker-solix: encode request: %w", err) + } + url := p.apiBase + "/" + endpoint + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(buf)) + if err != nil { + return nil, 0, err + } + req.Header.Set("content-type", "application/json") + req.Header.Set("model-type", "DESKTOP") + req.Header.Set("app-name", "anker_power") + req.Header.Set("os-type", "android") + req.Header.Set("country", p.countryId) + req.Header.Set("timezone", gmt) + if authToken != "" { + req.Header.Set("x-auth-token", authToken) + req.Header.Set("gtoken", gtoken) + } + + resp, err := p.client.Do(req) + if err != nil { + return nil, 0, fmt.Errorf("anker-solix: request %s: %w", endpoint, err) + } + defer drain(resp) + respBody, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20)) + return respBody, resp.StatusCode, nil +} + +// ---- crypto & small helpers -------------------------------------------------- + +// encryptPassword AES-256-CBC encrypts the password under the ECDH shared secret +// (key = secret, IV = secret[:16], PKCS#7 padding) and base64-encodes it. +func encryptPassword(password string, shared []byte) (string, error) { + if len(shared) < 32 { + return "", fmt.Errorf("shared key too short: %d bytes", len(shared)) + } + block, err := aes.NewCipher(shared) + if err != nil { + return "", fmt.Errorf("aes cipher: %w", err) + } + plain := pkcs7Pad([]byte(password), block.BlockSize()) + out := make([]byte, len(plain)) + cipher.NewCBCEncrypter(block, shared[:block.BlockSize()]).CryptBlocks(out, plain) + return base64.StdEncoding.EncodeToString(out), nil +} + +// pkcs7Pad appends PKCS#7 padding to a multiple of blockSize. +func pkcs7Pad(data []byte, blockSize int) []byte { + pad := blockSize - len(data)%blockSize + return append(data, bytes.Repeat([]byte{byte(pad)}, pad)...) +} + +// md5hex returns the lowercase hex MD5 of s (Anker's gtoken derivation). +func md5hex(s string) string { + sum := md5.Sum([]byte(s)) + return hex.EncodeToString(sum[:]) +} + +// timezone returns Anker's GMT offset string (e.g. "GMT+01:00") and the offset +// in milliseconds, both derived from the host's local zone. +func timezone() (string, int) { + _, offsetSec := time.Now().Zone() + sign := "+" + abs := offsetSec + if abs < 0 { + sign, abs = "-", -abs + } + gmt := fmt.Sprintf("GMT%s%02d:%02d", sign, abs/3600, (abs%3600)/60) + return gmt, offsetSec * 1000 +} + +// apiError reports a non-zero API code (and its message) from a response body. +func apiError(body []byte) (int, string, bool) { + var env struct { + Code int `json:"code"` + Msg string `json:"msg"` + } + if err := json.Unmarshal(body, &env); err != nil { + return 0, "", false + } + if env.Code != 0 { + return env.Code, env.Msg, true + } + return 0, "", false +} + +// isAuthCode reports whether a response body carries an authentication-failure +// API code (token invalid/expired), warranting a re-login. +func isAuthCode(body []byte) bool { + code, _, ok := apiError(body) + if !ok { + return false + } + // Anker returns 401xx-family codes for token problems; treat the common + // invalid/expired-token codes as auth failures. + switch code { + case 401, 40100, 40101, 40102, 40103: + return true + } + return false +} + +// countChargers best-effort extracts the bound EV-charger count from a +// get_user_bind_and_not_in_station_evchargers response. +func countChargers(body []byte) (int, bool) { + var env struct { + Data struct { + EvChargers []json.RawMessage `json:"evChargers"` + UserBindEvChargersCnt *int `json:"userBindEvChargersCount"` + } `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return 0, false + } + if env.Data.UserBindEvChargersCnt != nil { + return *env.Data.UserBindEvChargersCnt, true + } + if env.Data.EvChargers != nil { + return len(env.Data.EvChargers), true + } + return 0, false +} + +// drain closes a response body after discarding any remainder so the connection +// can be reused. +func drain(resp *http.Response) { + if resp != nil && resp.Body != nil { + _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) + _ = resp.Body.Close() + } +} + +// shorten trims long/multiline upstream messages for health details and errors. +func shorten(s string) string { + s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " ")) + if len(s) > 200 { + return s[:200] + "…" + } + return s +} diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go new file mode 100644 index 0000000..6eda07b --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix_test.go @@ -0,0 +1,229 @@ +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") + } +} + +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") + } +} diff --git a/API Server/internal/plugins/builtin/builtin.go b/API Server/internal/plugins/builtin/builtin.go index e3311a7..3494d39 100644 --- a/API Server/internal/plugins/builtin/builtin.go +++ b/API Server/internal/plugins/builtin/builtin.go @@ -12,6 +12,8 @@ package builtin import ( + // anker-solix — Anker Solix V1 Smart EV Charger read-only cloud data. + _ "drivervault/apiserver/internal/plugins/builtin/ankersolix" // toyota — Toyota Connected Europe (MyToyota) read-only vehicle data. _ "drivervault/apiserver/internal/plugins/builtin/toyota" )