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>
194 lines
7.8 KiB
Go
194 lines
7.8 KiB
Go
package api
|
|
|
|
// The remote half of the Anker Solix control plane.
|
|
//
|
|
// The other two transports each assume a route that a customer's charger usually
|
|
// does not have. OCPP (integrations_ankersolix_control.go) waits for the charger
|
|
// to dial in to us, which needs a public endpoint the charger can reach and a
|
|
// firmware willing to talk to our CSMS. Modbus TCP
|
|
// (integrations_ankersolix_modbus.go) dials the charger, which needs the server
|
|
// on the charger's own network. Between them they cover a charger we host and a
|
|
// charger we stand next to — and neither covers the ordinary case: a charger
|
|
// behind a customer's router, somewhere else entirely.
|
|
//
|
|
// This one goes the way the owner's phone already does. The charger holds a
|
|
// connection open to Anker's MQTT broker (it is the mqttStatus register the
|
|
// Modbus snapshot reports), and the account's own certificate lets us publish on
|
|
// the same topics the app publishes on. Nothing has to be reachable, forwarded
|
|
// or certificated on the customer's side; what it costs instead is a dependency
|
|
// on Anker's cloud being up, and on an unofficial protocol.
|
|
//
|
|
// The command set is the charger's, not OCPP's: start, stop, boost, skip-delay,
|
|
// a current limit and a restart, plus the one thing neither other transport can
|
|
// do at all — writing the charger's own configuration, which is what "settings"
|
|
// is for. Everything the register map or the CSMS can do that this cannot is
|
|
// refused by name rather than as an unknown action.
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"time"
|
|
|
|
"drivervault/apiserver/internal/plugins/builtin/ankersolix"
|
|
)
|
|
|
|
// ankerMqttTimeout bounds one command or status read end to end. It is generous
|
|
// because the path is: our broker connection, Anker's cloud, the customer's
|
|
// link, the charger — and back again for the confirmation. The plugin's own
|
|
// 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 —
|
|
// 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,
|
|
"payloadEncryption": res.payloadEncryption,
|
|
}
|
|
}
|
|
|
|
// ankerMqttAction issues one control command over Anker's cloud broker. The
|
|
// gate, rate limit, destructive-action confirmation and audit have already run
|
|
// in handleAnkerControlAction; this decides what to send and reports the result.
|
|
func (s *Server) ankerMqttAction(w http.ResponseWriter, r *http.Request, who *callerIdentity,
|
|
res ankerResolution, sn, action string, body ankerControlBody) {
|
|
|
|
// Actions this transport has no equivalent for. Naming the transport that
|
|
// does have them beats a bare "unknown action" the caller cannot act on.
|
|
switch action {
|
|
case "unlock", "availability", "trigger", "config":
|
|
writeError(w, http.StatusBadRequest,
|
|
"\""+action+"\" is an OCPP command; the Anker cloud connection cannot send it. Switch the control mode to a CSMS mode to use it.")
|
|
return
|
|
case "phase", "timeout":
|
|
writeError(w, http.StatusBadRequest,
|
|
"\""+action+"\" is set through the charger's Modbus registers; the Anker cloud connection cannot send it. Switch the control mode to Modbus TCP to use it.")
|
|
return
|
|
case "clear-limit":
|
|
// As over Modbus: "no limit" would mean writing a ceiling we would have to
|
|
// invent, and the charger clamps to its own rating anyway.
|
|
writeError(w, http.StatusBadRequest,
|
|
"the Anker cloud connection has no \"clear limit\" command; send \"limit\" with the amps you want instead")
|
|
return
|
|
}
|
|
|
|
ctx, cancel := context.WithTimeout(r.Context(), ankerMqttTimeout)
|
|
defer cancel()
|
|
|
|
var (
|
|
capability = "mqtt-command"
|
|
params = map[string]any{"transport": "mqtt"}
|
|
payload = map[string]any{"sn": sn}
|
|
)
|
|
switch action {
|
|
case "start", "stop", "boost", "skip-delay":
|
|
payload["command"] = action
|
|
if action == "boost" && body.On != nil && !*body.On {
|
|
// Boost is a one-way command on this transport: the charger clears it
|
|
// when the session ends, and there is no message to cancel it early.
|
|
writeError(w, http.StatusBadRequest,
|
|
"boost cannot be switched off over the Anker cloud; it ends with the charging session, or stop the session to end it now")
|
|
return
|
|
}
|
|
case "limit":
|
|
params["amps"] = body.Amps
|
|
payload["command"], payload["amps"] = "limit", body.Amps
|
|
case "reset", "restart":
|
|
// The charger's own restart, the cloud's answer to the OCPP reset. It has
|
|
// already been through the confirmation and the password step-up upstairs,
|
|
// like any other reboot.
|
|
payload["command"] = "restart"
|
|
case "settings":
|
|
// The values themselves are audited, not just the fact of a write: a
|
|
// setting that changes what the charger will draw, or whether it answers on
|
|
// the LAN at all, is worth being able to trace afterwards.
|
|
if len(body.Settings) == 0 {
|
|
writeError(w, http.StatusBadRequest,
|
|
"settings requires a \"settings\" object, e.g. {\"settings\":{\"ledBrightness\":50}}")
|
|
return
|
|
}
|
|
capability = "mqtt-settings"
|
|
params["settings"] = body.Settings
|
|
payload["settings"] = body.Settings
|
|
case "status":
|
|
capability = "mqtt-status"
|
|
default:
|
|
writeError(w, http.StatusBadRequest, "unknown control action: "+action)
|
|
return
|
|
}
|
|
|
|
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, ankerCloudConfig(res), capability, mustJSON(payload))
|
|
|
|
outcome := "accepted"
|
|
if err != nil {
|
|
outcome = "error"
|
|
}
|
|
s.auditControl(who, sn, action, params, outcome, err)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
if action == "status" {
|
|
writeJSON(w, http.StatusOK, map[string]any{"status": outcome, "result": json.RawMessage(raw)})
|
|
return
|
|
}
|
|
// The plugin answers {serial, command|applied, status, confirmed, detail?};
|
|
// relay it so the caller sees whether the charger acknowledged, not just that
|
|
// we sent.
|
|
writeJSON(w, http.StatusOK, json.RawMessage(raw))
|
|
}
|
|
|
|
// ankerMqttSnapshot reads a charger's live state for the status endpoint. Like
|
|
// its Modbus counterpart it is best effort: a charger that is offline, or an
|
|
// account the cloud will not hand a broker certificate for, simply has no
|
|
// snapshot — which is a fact to report, not an error to fail on.
|
|
func (s *Server) ankerMqttSnapshot(ctx context.Context, res ankerResolution, sn string) (ankersolix.MqttSnapshot, string, bool) {
|
|
var snap ankersolix.MqttSnapshot
|
|
if strings.TrimSpace(sn) == "" {
|
|
return snap, "", false
|
|
}
|
|
ctx, cancel := context.WithTimeout(ctx, ankerMqttTimeout)
|
|
defer cancel()
|
|
|
|
raw, err := s.plugins.InvokeWith(ctx, ankerPlugin, ankerCloudConfig(res), "mqtt-status", mustJSON(map[string]any{"sn": sn}))
|
|
if err != nil {
|
|
return snap, err.Error(), false
|
|
}
|
|
if err := json.Unmarshal(raw, &snap); err != nil {
|
|
return snap, err.Error(), false
|
|
}
|
|
return snap, "", true
|
|
}
|
|
|
|
// mustJSON encodes a small, known-good map for a plugin call. The values are
|
|
// built here from typed fields, so an encoding failure is not a runtime case.
|
|
func mustJSON(v map[string]any) json.RawMessage {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
return json.RawMessage(`{}`)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// shortenDetail trims an upstream failure to something that fits in a status
|
|
// card without hiding what went wrong.
|
|
func shortenDetail(s string) string {
|
|
s = strings.TrimSpace(strings.ReplaceAll(s, "\n", " "))
|
|
if s == "" {
|
|
return "no detail"
|
|
}
|
|
if len(s) > 200 {
|
|
return s[:200] + "…"
|
|
}
|
|
return s
|
|
}
|