Category has been in the plugin contract since it was written — apis-external, drives-external, drives-local — and every builtin declared the same one, so it grouped nothing. Two of the three talk to a wallbox and one talks to a car manufacturer, and those are different questions an operator arrives with: the Toyota card is where a driver's account gets linked, the Anker and Greencell cards are where a charger's broker and credentials live. So vehicles and chargers join the constants and the three builtins say which they are. The panel groups on that field rather than on a list of names, which is what keeps an external plugin from needing panel code. Tab order mirrors the constants; a category with nothing in it gets no tab, and a single group hides the bar entirely, so an install with one connector looks exactly as it did. A category the panel does not recognise — or an empty one — falls to the external-APIs tab rather than vanishing, because a plugin nobody can see is a plugin nobody can disable. The selected tab falls back to the first group when its own goes away, which is what removing the last external plugin does. Registration still asks only for name, base URL and provider, so a plugin registered at runtime lands under Other APIs until its manifest names a category. That path already works and is the honest default: the panel is guessing about a service it has never spoken to, and the service can say. The header lockup is the other half. It was a copy of the Web App's mark rather than the same mark, and copies drift — a 32px icon against 28, a 24px wordmark against 21.6, "Driver" at text-strong instead of white, "Vault" a step lighter than brand-400. The Web App's Logo.vue moves in verbatim, props included. The one thing it cannot inherit is which variant to render: the Web App's rail is always dark, while this panel flips with its own theme toggle, so on-dark is bound to the theme and the hand-rolled bar fills that existed to survive that flip are gone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
903 lines
34 KiB
Go
903 lines
34 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. 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 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.2.0",
|
|
Kind: plugins.KindBuiltin,
|
|
Category: plugins.CategoryChargers,
|
|
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; 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
|
|
}
|
|
// 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}
|
|
}
|
|
|
|
// 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)
|
|
|
|
// charger-state fans out over two endpoints and returns a derived document,
|
|
// so it does not fit the single-endpoint dispatch below.
|
|
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 "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 "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 --------------------------------------------------------
|
|
|
|
// 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
|
|
}
|