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>
561 lines
20 KiB
Go
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
|
|
}
|