// 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. It tracks upstream v3.8.1 // (ha-anker-solix 3.8.1); the login exchange and every endpoint used here are // unchanged since v3.7.0. // // Scope & limitations: // - Read-only. Only EV-charger information is retrieved; no charge start/stop // or configuration commands are implemented. Control is a separate concern // and runs over OCPP (see internal/ocpp), not the cloud API. // - Cloud only. Upstream reads a charger's live state over both the cloud and // MQTT; we take the cloud half. Signals that exist only in MQTT — the boost // flag and the plug/start countdowns — are therefore never set, which the // derived state accounts for. // - 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" "sort" "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 epSystemRunInfo = "charging_hes_svc/get_system_running_info" // system runtime info; carries evChargerInfos[].evChargerStatus epSceneInfo = "power_service/v1/site/get_scen_info" // site home view; carries charging_pile_info (note upstream's "scen" typo) epEnergyAnalysis = "power_service/v1/site/energy_analysis" // interval energy for a site, device_type "ev_charger" 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 epOcppEndpoints = "power_service/v1/app/get_ocpp_endpoint_list" // the OCPP endpoints Anker itself uses, with their source numbers epRfidCards = "power_service/v1/rfid/get_device_cards" // RFID cards authorised on 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 epVehicleDetail = "power_service/v1/app/vehicle/get_vehicle_detail" // details for one registered vehicle ) // EV-charger operating states, mirroring anker-solix-api's SolixEvChargerStatus. // The cloud reports the same code under two names: operating_state in a site's // charging_pile_list, evChargerStatus in HES system running info. const ( stateStandby = "standby" statePreparing = "preparing" stateCharging = "charging" stateChargerPaused = "charger_paused" stateVehiclePaused = "vehicle_paused" stateCompleted = "completed" stateReserving = "reserving" stateDisabled = "disabled" stateError = "error" stateUnknown = "unknown" ) var evChargerStatus = map[int]string{ 0: stateStandby, 1: statePreparing, 2: stateCharging, 3: stateChargerPaused, 4: stateVehiclePaused, 5: stateCompleted, 6: stateReserving, 7: stateDisabled, 8: stateError, } // Operational modes, mirroring SolixEvChargerMode. start/stop/skip/boost are the // four real control values; wait_plug and wait_start are virtual states the // charger passes through and cannot be commanded into. const ( modeStartCharge = "start_charge" modeStopCharge = "stop_charge" modeSkipDelay = "skip_delay" modeBoostCharge = "boost_charge" modeWaitStart = "wait_start" modeWaitPlug = "wait_plug" ) // ocppConnStatus decodes ocpp_connect_status (SolixOcppConnectionStatus). var ocppConnStatus = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"} // 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 the fields above and the client apiBase string client *http.Client // sess holds the token and the login backoff, shared by every instance // configured for the same account (see session.go). It is what stops the // manager's per-request instances from signing in once each. sess *session } // 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.2.0", Kind: plugins.KindBuiltin, Category: plugins.CategoryChargers, AuthType: plugins.AuthBasic, Capabilities: []plugins.Capability{ {ID: "chargers", Method: "POST", Endpoint: epStandaloneChargers, Description: "Every EV charger on the account, merged from the standalone, per-site and bound-device views (see chargers.go)."}, {ID: "charger-status", Method: "POST", Endpoint: epStationInfo, Description: "Live station/status info for one charger (needs sn; optional featuretype 1 or 2)."}, {ID: "charger-state", Method: "POST", Endpoint: epSceneInfo, Description: "Normalized live state of a site's EV chargers: status, operational mode and the modes it can be switched to (needs siteId; optional sn)."}, {ID: "site-status", Method: "POST", Endpoint: epSceneInfo, Description: "Live site view; EV chargers appear under charging_pile_info (needs siteId)."}, {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: "charge-energy", Method: "POST", Endpoint: epEnergyAnalysis, Description: "Interval charging energy for a site's EV charger (needs siteId; optional sn, range, startDate, endDate)."}, {ID: "rfid-cards", Method: "POST", Endpoint: epRfidCards, Description: "RFID cards authorised on one charger (needs sn)."}, {ID: "ocpp-info", Method: "POST", Endpoint: epOcppInfo, Description: "OCPP endpoint source info for one charger (needs sn)."}, {ID: "ocpp-endpoints", Method: "POST", Endpoint: epOcppEndpoints, Description: "The OCPP endpoints Anker itself uses, with their source numbers."}, {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."}, {ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."}, }, 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 } // Credentials pick the session, so re-configuring the same account keeps its // token and its backoff, and changing account or country starts a fresh one. p.sess = sessionFor(p.apiBase, p.email, p.password) p.client = &http.Client{Timeout: 30 * time.Second} return nil } // HealthCheck logs in (if needed) and counts the account's EV chargers. It takes // the same inventory the chargers capability returns rather than the standalone // list alone: that list omits every charger that belongs to a system, so probing // it reported "0 chargers" for an account whose chargers the panel was, at the // same moment, listing. // // Authenticated but holding no charger is degraded, not down — the half we // address works, and the missing half is the account (or the country, which // picks the regional server). func (p *Plugin) HealthCheck(ctx context.Context) plugins.Health { start := time.Now() doc, err := p.chargerInventory(ctx) lat := time.Since(start).Milliseconds() if err != nil { return plugins.Health{Status: plugins.StatusDown, LatencyMs: lat, Detail: shorten(err.Error())} } if doc.Count == 0 { detail := "authenticated; no EV charger on the account — if it does own one, check the country setting, which picks the Anker server" if len(doc.Warnings) > 0 { detail = "authenticated; no EV charger found: " + strings.Join(doc.Warnings, "; ") } return plugins.Health{Status: plugins.StatusDegraded, LatencyMs: lat, Detail: shorten(detail)} } detail := fmt.Sprintf("authenticated; %d EV charger(s) on the account", doc.Count) if len(doc.Warnings) > 0 { // Some view failed; the count still stands, but say it is a floor. detail += fmt.Sprintf(" (%d of the cloud's views did not answer)", len(doc.Warnings)) } return plugins.Health{Status: plugins.StatusOK, LatencyMs: lat, Detail: detail} } // invokeParams is the union of everything an action may be given. Per-charger // actions take "sn" (the charger serial); per-site actions take "siteId", which // comes from the "sites" capability. type invokeParams struct { SN string `json:"sn"` SiteID string `json:"siteId"` VehicleID string `json:"vehicleId"` FeatureType int `json:"featuretype"` Range string `json:"range"` // day | week | month | year StartDate string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year EndDate string `json:"endDate"` } // Invoke runs a named read-only capability. The upstream response body is // returned verbatim, except for "charger-state", which is derived (see // chargerState). func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessage) (json.RawMessage, error) { var pp invokeParams 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) pp.SiteID = strings.TrimSpace(pp.SiteID) pp.VehicleID = strings.TrimSpace(pp.VehicleID) // chargers and charger-state both fan out over several endpoints and return // a derived document, so they do not fit the single-endpoint dispatch below. if action == "chargers" { return p.accountChargers(ctx) } if action == "charger-state" { if pp.SiteID == "" { return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action) } return p.chargerState(ctx, pp.SiteID, pp.SN) } var ( endpoint string payload map[string]any needSN bool needSite bool ) switch action { 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 "ocpp-endpoints": endpoint, payload = epOcppEndpoints, map[string]any{} case "charger-status": // featuretype selects which slice of the station record is returned; // Anker accepts 1 and 2, and 1 is what the app asks for by default. ft := pp.FeatureType if ft != 1 && ft != 2 { ft = 1 } endpoint, needSN = epStationInfo, true payload = map[string]any{"evChargerSn": pp.SN, "featuretype": ft} 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 "rfid-cards": endpoint, needSN = epRfidCards, true payload = map[string]any{"device_sn": pp.SN} case "ocpp-info": endpoint, needSN = epOcppInfo, true payload = map[string]any{"device_sn": pp.SN} case "site-status": endpoint, needSite = epSceneInfo, true payload = map[string]any{"site_id": pp.SiteID} case "charge-energy": // Anker's energy_analysis is site-scoped; an empty device_sn returns the // site's EV-charger totals, a serial narrows it to one charger. endpoint, needSite = epEnergyAnalysis, true payload = map[string]any{ "site_id": pp.SiteID, "device_sn": pp.SN, "device_type": "ev_charger", "type": energyRange(pp.Range), "start_time": pp.StartDate, "end_time": pp.EndDate, } case "vehicle": if pp.VehicleID == "" { return nil, fmt.Errorf("anker-solix: action %q requires a vehicleId", action) } endpoint = epVehicleDetail payload = map[string]any{"vehicle_id": pp.VehicleID} 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) } if needSite && pp.SiteID == "" { return nil, fmt.Errorf("anker-solix: action %q requires a siteId", action) } body, err := p.apiRequest(ctx, endpoint, payload) if err != nil { return nil, err } return json.RawMessage(body), nil } // energyRange normalizes the energy_analysis period, defaulting to a week like // the reference implementation's EV-charger poll. func energyRange(r string) string { switch strings.ToLower(strings.TrimSpace(r)) { case "day", "week", "month", "year": return strings.ToLower(strings.TrimSpace(r)) default: return "week" } } // 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 } // ---- derived EV-charger state ------------------------------------------------ // evChargerState is one charger's live state, normalized out of whichever cloud // view reported it. Status and mode names match anker-solix-api so a consumer // can read them against the reference implementation. type evChargerState struct { SN string `json:"sn"` Name string `json:"name,omitempty"` SiteID string `json:"siteId,omitempty"` Source string `json:"source"` // scene | hes — which view the record came from Status *int `json:"status,omitempty"` // raw operating_state / evChargerStatus StatusDesc string `json:"statusDesc"` // Mode is the operational mode the charger is effectively in; ModeOptions are // the modes it can be switched to from there. Charging is the binary reading // of Mode — everything but stop_charge counts as on. Mode string `json:"mode,omitempty"` ModeOptions []string `json:"modeOptions"` Charging *bool `json:"charging,omitempty"` Power string `json:"power,omitempty"` // charge power as reported, unit per upstream OcppStatus *int `json:"ocppStatus,omitempty"` OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` } // chargerState fetches a site's EV chargers from both cloud views and returns // their normalized state. The two views cover different system families — the // power_service scene for site systems, the HES service for HES systems — so a // site answering only one of them is normal; the call fails only when neither // view is reachable. An sn, when given, filters to that charger. func (p *Plugin) chargerState(ctx context.Context, siteID, sn string) (json.RawMessage, error) { var ( out []evChargerState sceneErr error hesErr error ) if body, err := p.apiRequest(ctx, epSceneInfo, map[string]any{"site_id": siteID}); err != nil { sceneErr = err } else { out = append(out, parseScenePiles(body, siteID)...) } if body, err := p.apiRequest(ctx, epSystemRunInfo, map[string]any{"siteId": siteID}); err != nil { hesErr = err } else { out = append(out, parseHesChargers(body, siteID)...) } if sceneErr != nil && hesErr != nil { return nil, fmt.Errorf("anker-solix: charger-state: no EV-charger view for site %s: %v; %v", siteID, sceneErr, hesErr) } // A charger reported by both views is one charger. The scene record wins: it // is the richer of the two, carrying charge power and OCPP status. seen := make(map[string]bool, len(out)) kept := out[:0] for _, c := range out { if seen[c.SN] || (sn != "" && c.SN != sn) { continue } seen[c.SN] = true kept = append(kept, c) } out = kept if out == nil { out = []evChargerState{} } return json.Marshal(map[string]any{"siteId": siteID, "chargers": out}) } // parseScenePiles reads the EV chargers out of a get_scen_info response. The // scene calls the state operating_state and the charge power just power; the // reference implementation renames both on ingest, and so do we. func parseScenePiles(body []byte, siteID string) []evChargerState { var env struct { Data struct { ChargingPileInfo struct { ChargingPileList []struct { DeviceSN string `json:"device_sn"` DeviceName string `json:"device_name"` OperatingState *int `json:"operating_state"` Power json.Number `json:"power"` OcppConnect *int `json:"ocpp_connect_status"` } `json:"charging_pile_list"` } `json:"charging_pile_info"` } `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { return nil } list := env.Data.ChargingPileInfo.ChargingPileList out := make([]evChargerState, 0, len(list)) for _, cp := range list { if cp.DeviceSN == "" { continue } out = append(out, newChargerState(cp.DeviceSN, cp.DeviceName, siteID, "scene", cp.OperatingState, cp.OcppConnect, cp.Power.String())) } return out } // parseHesChargers reads the EV chargers out of a get_system_running_info // response, where they arrive as evChargerInfos under their own field names. func parseHesChargers(body []byte, siteID string) []evChargerState { var env struct { Data struct { EvChargerInfos []struct { SN string `json:"evChargerSn"` Name string `json:"evChargerName"` Status *int `json:"evChargerStatus"` } `json:"evChargerInfos"` } `json:"data"` } if err := json.Unmarshal(body, &env); err != nil { return nil } out := make([]evChargerState, 0, len(env.Data.EvChargerInfos)) for _, ev := range env.Data.EvChargerInfos { if ev.SN == "" { continue } out = append(out, newChargerState(ev.SN, ev.Name, siteID, "hes", ev.Status, nil, "")) } return out } // newChargerState decorates a raw status code with its name, mode and options. func newChargerState(sn, name, siteID, source string, status, ocpp *int, power string) evChargerState { c := evChargerState{ SN: sn, Name: name, SiteID: siteID, Source: source, Status: status, StatusDesc: stateUnknown, Power: power, ModeOptions: []string{}, } if status != nil { c.StatusDesc = statusName(*status) // The cloud views carry no boost flag and no countdowns — those reach the // reference implementation over MQTT only — so the mode derives from the // status alone and a charger never reads as boosting or waiting here. c.Mode = chargerMode(c.StatusDesc, false, 0, 0) c.ModeOptions = chargerModeOptions(c.Mode, c.StatusDesc) on := c.Mode != modeStopCharge c.Charging = &on } if ocpp != nil { c.OcppStatus = ocpp desc, ok := ocppConnStatus[*ocpp] if !ok { desc = stateUnknown } c.OcppStatusDesc = desc } return c } // statusName maps an operating-state code to its name. func statusName(code int) string { if name, ok := evChargerStatus[code]; ok { return name } return stateUnknown } // chargerMode derives the operational mode from the charger's status, mirroring // anker-solix-api's ev_charger_mode_state. boost and the two countdowns are MQTT // signals; pass their zero values when only cloud data is available. func chargerMode(statusDesc string, boost bool, plugCountdown, startCountdown int) string { if boost { return modeBoostCharge } switch statusDesc { case statePreparing: if plugCountdown > 0 { return modeWaitPlug } if startCountdown > 0 { return modeWaitStart } return modeStartCharge case stateCharging, stateChargerPaused, stateVehiclePaused: return modeStartCharge default: return modeStopCharge } } // chargerModeOptions lists the modes reachable from the current one, mirroring // ev_charger_mode_options. The result is sorted so the output is stable. func chargerModeOptions(mode, statusDesc string) []string { if mode == "" { return []string{} } set := map[string]bool{mode: true} switch mode { case modeWaitPlug, modeWaitStart, modeStartCharge: set[modeStopCharge] = true // A start delay can only be skipped while it is running; boost only applies // once charging has been commanded. if mode == modeWaitStart { set[modeSkipDelay] = true } else if mode == modeStartCharge { set[modeBoostCharge] = true } case modeBoostCharge: set[modeStopCharge] = true default: // Only a charger sitting in standby can be told to start. if statusDesc == stateStandby { set[modeStartCharge] = true } } opts := make([]string, 0, len(set)) for m := range set { opts = append(opts, m) } sort.Strings(opts) return opts } // ---- token management -------------------------------------------------------- // Login backoff. A refused sign-in is cached and replayed to every caller until // its window passes, so credentials Anker rejects are offered once, not once per // request. const ( loginRetryBase = 1 * time.Minute loginRetryMax = 15 * time.Minute // codeSignInLocked is what Anker answers once it has already disabled the // account for repeated failures; it states a ten-minute penalty, so sit the // penalty out rather than spending attempts against a door that is shut. codeSignInLocked = 10019 loginLockoutWait = 10 * time.Minute ) // loginRejected is the login endpoint answering with a non-zero API code. The // code is kept so an already-locked account can be told from an ordinary // refusal. type loginRejected struct { code int msg string } func (e *loginRejected) Error() string { return fmt.Sprintf("anker-solix: login rejected (code %d): %s", e.code, e.msg) } // loginFailure wraps whatever made a login attempt fail, together with the time // before which no further attempt is made. type loginFailure struct { err error retryAt time.Time } func (e *loginFailure) Error() string { wait := time.Until(e.retryAt).Round(time.Second) if wait < 0 { wait = 0 } return fmt.Sprintf("%s (not retrying for %s)", e.err, wait) } func (e *loginFailure) Unwrap() error { return e.err } // isLoginFailure reports whether err came from the login exchange rather than // from the endpoint the caller was asking for — the difference between "this // account cannot sign in" and "this one view said no". func isLoginFailure(err error) bool { var lf *loginFailure return errors.As(err, &lf) } // 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) { s := p.sess if s == nil { return tokenInfo{}, errors.New("anker-solix: plugin not initialised") } s.mu.Lock() defer s.mu.Unlock() if s.tok != nil && time.Now().Before(s.tok.expiration) { return *s.tok, nil } if err := p.attemptLogin(ctx); err != nil { return tokenInfo{}, err } return *s.tok, nil } // attemptLogin logs in at most once per backoff window, handing every caller in // between the failure that opened it. Caller holds p.sess.mu. func (p *Plugin) attemptLogin(ctx context.Context) error { if p.email == "" || p.password == "" { // Nothing was sent to Anker, so there is nothing to back off from. return errors.New("anker-solix: email and password are required") } s := p.sess if s.loginErr != nil && time.Now().Before(s.loginRetryAt) { return s.loginErr } s.noteLogin(p.login(ctx)) return s.loginErr } // 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 { // 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 the session's token. Caller // holds p.sess.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 &loginRejected{code: env.Code, msg: 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.sess.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.sess.mu.Lock() p.sess.tok = nil lerr := p.attemptLogin(ctx) var newTok tokenInfo if lerr == nil { newTok = *p.sess.tok } p.sess.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 }