Anker Solix: the encrypted transport the app uses, ported from upstream v3.8.2
anker-solix-api v3.8.2 (2026-09-13) worked out the mobile app's REST payload encryption end to end — a second ECDH handshake after login, then every body AES-sealed and HMAC-signed both ways. Anker's servers still accept the plain JSON this connector has always sent, and upstream leaves the mode off for that reason; this ports it so that the day they stop is a config change rather than an outage. payloadcrypto.go holds the handshake (openapi/oauth/key/exchange, both public keys wrapped under the app's presetKey) and the per-request envelope (AES-128-CBC under secret[:16], x-signature keyed on its ascii hex). The handshake lives beside the token in the shared session and is dropped with it, so a fresh login is a fresh handshake before the retry. The switch is a global-layer field, payloadEncryption, default off: it is how the connector talks to Anker rather than whose account it talks as, so it does not cascade — ankerCloudConfig copies it into every per-user config, and the six places that built that map by hand now go through it. Verified against a fake cloud that implements the server half from upstream's description, not against Anker's; the switch should be tried on a test account before it is relied on. Nothing else in v3.8.2 reaches an EV charger: the Solarbank expansion sizing, the Gen 2 smart meter and the new site parameter types are other devices, and the A5191 MQTT block is unchanged. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
a26c608fc3
commit
f8b39fa0a3
9 files changed
+733
-63
No files matched your search
@@ -127,11 +127,7 @@ func (ankerChargerSource) gate(ctx context.Context, s *Server, who *callerIdenti
|
||||
if reason := ankerGate(res, true); reason != "" {
|
||||
return nil, false, reason
|
||||
}
|
||||
return map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
}, true, ""
|
||||
return ankerCloudConfig(res), true, ""
|
||||
}
|
||||
|
||||
func (ankerChargerSource) chargers(raw json.RawMessage) []providerCharger {
|
||||
|
||||
@@ -181,6 +181,10 @@ type ankerResolution struct {
|
||||
orgEnabled bool // org gate (default true; gates the org's users)
|
||||
enabled bool // caller's personal enable flag
|
||||
|
||||
// payloadEncryption is the global layer's switch for the plugin's REST
|
||||
// transport (off | on), copied into every cloud config as-is.
|
||||
payloadEncryption string
|
||||
|
||||
// hiddenForOrg is what the global layer hides from everyone below it;
|
||||
// hiddenForUser adds what the caller's organization hides from its own users.
|
||||
// They gate both the pickers the clients draw and which layer's stored mode
|
||||
@@ -225,6 +229,8 @@ func (s *Server) resolveAnker(ctx context.Context, who *callerIdentity, userRaw
|
||||
available: masterEnabled,
|
||||
orgEnabled: !oStored.Disabled, // default true; off only when the org disabled it
|
||||
enabled: uStored.Enabled,
|
||||
|
||||
payloadEncryption: g["payloadEncryption"],
|
||||
}
|
||||
|
||||
// What each layer below may still be offered. The global list reaches
|
||||
@@ -621,11 +627,7 @@ func (s *Server) handleAnkerHealth(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
}
|
||||
cfg := ankerCloudConfig(res)
|
||||
h, err := s.plugins.HealthCheckWith(r.Context(), ankerPlugin, cfg)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
@@ -653,11 +655,7 @@ func (s *Server) handleAnkerChargers(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
cfg := map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
}
|
||||
cfg := ankerCloudConfig(res)
|
||||
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "chargers", nil)
|
||||
if err != nil {
|
||||
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
||||
@@ -694,11 +692,7 @@ func (s *Server) handleAnkerChargerDetails(w http.ResponseWriter, r *http.Reques
|
||||
return
|
||||
}
|
||||
|
||||
cfg := map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
}
|
||||
cfg := ankerCloudConfig(res)
|
||||
raw, err := s.plugins.InvokeWith(r.Context(), ankerPlugin, cfg, "charger-details",
|
||||
mustJSON(map[string]any{"sn": sn}))
|
||||
if err != nil {
|
||||
@@ -813,11 +807,7 @@ func (s *Server) ankerCardGate(w http.ResponseWriter, r *http.Request) (*callerI
|
||||
writeError(w, http.StatusTooManyRequests, "too many card requests; please slow down")
|
||||
return nil, "", nil, false
|
||||
}
|
||||
return who, sn, map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
}, true
|
||||
return who, sn, ankerCloudConfig(res), true
|
||||
}
|
||||
|
||||
// ankerCardWrite runs one card capability and relays its document. The audit
|
||||
|
||||
@@ -338,7 +338,7 @@ func (s *Server) ankerProxyUpstream(ctx context.Context, res ankerResolution, se
|
||||
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
|
||||
return "", "", errors.New("Anker account credentials are required for proxy mode")
|
||||
}
|
||||
cfg := map[string]string{"email": res.eff.Email, "password": res.eff.Password, "country": res.eff.Country}
|
||||
cfg := ankerCloudConfig(res)
|
||||
payload, _ := json.Marshal(map[string]string{"sn": serial})
|
||||
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, cfg, "ocpp-info", payload)
|
||||
if err != nil {
|
||||
|
||||
@@ -40,13 +40,18 @@ import (
|
||||
// waits are shorter, so this only catches a request that is going nowhere.
|
||||
const ankerMqttTimeout = 45 * time.Second
|
||||
|
||||
// ankerCloudConfig is the plugin config one caller's resolved credentials make.
|
||||
// The cloud transport signs in as the account, so unlike Modbus it needs them.
|
||||
// ankerCloudConfig is the plugin config one caller's resolved credentials make —
|
||||
// every transient instance the api package builds for the cloud goes through
|
||||
// here. The cloud transport signs in as the account, so unlike Modbus it needs
|
||||
// them. Payload encryption rides along from the global layer: it is how the
|
||||
// connector talks to Anker, not whose account it talks as, so it is not part of
|
||||
// the cascade and no lower layer can set it.
|
||||
func ankerCloudConfig(res ankerResolution) map[string]string {
|
||||
return map[string]string{
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
"email": res.eff.Email,
|
||||
"password": res.eff.Password,
|
||||
"country": res.eff.Country,
|
||||
"payloadEncryption": res.payloadEncryption,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user