// 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."}, // controlMode selects the OCPP control path. It does not affect this // (read-only) cloud plugin — the CSMS that acts on it lives in the api // package (internal/ocpp) — but it is advertised here so a superadmin can // set/lock it at the global layer, and it cascades like the other fields. {Key: "controlMode", Label: "Control mode", Type: "select", Default: "off", Help: "How DriverVault controls the charger over OCPP. Off = monitoring only (default). Own CSMS = the charger connects directly to DriverVault. Proxy CSMS = DriverVault relays to Anker's cloud and can inject commands.", Options: []plugins.SelectOption{ {Value: "off", Label: "Off (monitoring only)"}, {Value: "own", Label: "Own CSMS (full control)"}, {Value: "proxy", Label: "Proxy CSMS (relay + control)"}, }}, }, } } // 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 }