Add a built-in Anker Solix V1 Smart EV Charger plugin
A Go re-implementation of the auth and read-only data flow from thomluther/anker-solix-api, scoped to the V1 Smart EV Charger (A5191) and adapted to DriverVault's plugin contract. Login is a custom ECDH (P-256) + AES-256-CBC password exchange against passport/login, yielding a ~7-day auth token plus gtoken = md5(user_id) for subsequent requests; a fresh login covers expiry and 401/403. The country code routes to the EU or global Anker server. Exposes read-only capabilities (chargers, charger-status, charge-stats, charge-orders, ocpp-info, devices, sites, vehicles) with a health check that reports the bound-charger count. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
5e435c5f77
commit
0793b5ec8e
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user