Files
DriverVault/API Server/internal/api/integrations_ankersolix_mqtt.go
T
tajniak81andClaude Opus 5 f8b39fa0a3 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>
2026-09-21 16:40:43 +02:00

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
}