Files
DriverVault/API Server/internal/plugins/builtin/ankersolix/ankersolix.go
T
tajniak81andClaude Opus 4.8 a1519f6e89 Add OCPP control for the Anker Solix EV charger (Own/Proxy CSMS)
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>
2026-07-18 16:40:22 +02:00

561 lines
20 KiB
Go

// 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
}