Go the way the owner's phone already goes

Control had two transports and neither fitted the ordinary customer. OCPP waits
for the charger to dial in, which needs a public endpoint it can reach, a
certificate, and a firmware willing to talk to our CSMS. Modbus TCP 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; the common case is a
charger behind someone else's router, and that had nothing.

It was never unreachable, though. The charger holds a connection open to Anker's
own broker — it is how the mobile app drives it from anywhere, and it is the
mqttStatus register the Modbus snapshot has been reporting all along. So a third
control mode joins that broker as the account: get_user_mqtt_info issues a client
certificate, mTLS to aiot-mqtt-eu.anker.com:8883, and commands go out on the same
topics the app publishes on. Nothing on the customer's side has to be forwarded,
addressed or certificated.

What travels is not an API call. The payload is a JSON envelope around a base64
binary frame the device itself speaks — marker, little-endian length, message
type, name/length/type/value fields, XOR checksum — so mqttframe.go is a codec
rather than a client, written from the message maps in anker-solix-api and
anchored on the one frame that project documents byte for byte. A frame whose
fields do not tile exactly up to the checksum is refused rather than half-read:
these arrive over a link we do not control, and a truncated frame must not read
as a charger reporting zeros.

Two of the charger's habits shape the rest. It publishes nothing unless asked, so
a status read arms a telemetry trigger and waits for the next frame, and a poll
inside that window answers from what has since arrived. And a broker connection
costs a fetched certificate and a TLS handshake while the plugin manager builds a
throwaway instance per request — so the connection lives on the account's shared
session beside the auth token, for exactly the reason the token lives there, and
closes itself after five idle minutes.

The transport also sees two signals no other one does: the boost flag, and the
plug and start countdowns. The package doc has said since the first commit that
they are never set and the derived mode must do without them. Here they are set,
so a charger that has been told to start and is counting down a delay says so
rather than sitting in "preparing", and "skip the delay" is offered only while
there is a delay to skip.

The clients generalise instead of growing a second layout. Both snapshots name
the same quantities the same way, so what was Modbus-only in the readouts is now
whichever transport read the charger — ModbusStatus becomes ChargerStatus on the
phone, mb becomes dev on the web. What each transport can be *told* still
differs, and the buttons branch on that: reset and clear-limit stay with OCPP,
the timeout and phase registers with Modbus, skip-delay with the cloud. A command
a transport has no equivalent for is refused by name, saying which one has it.

The cost is worth saying plainly. This leans on Anker's cloud being up and on an
unofficial protocol the app may change under us, where Modbus leans on nothing
but the LAN. And it is checked against the reference implementation's own worked
example rather than against hardware — there is no charger on this end to point
it at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tajniak81
2026-09-02 16:47:10 +02:00
co-authored by Claude Opus 5
parent b27ca3ee19
commit 576df58776
28 changed files with 3186 additions and 171 deletions
@@ -28,14 +28,18 @@ const (
ankerPlugin = "anker-solix"
ankerSecretMask = "••••••••"
// Control modes for the Anker Solix charger. The first three are OCPP paths
// (see internal/ocpp) and need the charger to dial in to us; modbus is the
// local path (see the ankersolix plugin's modbus.go), where we dial the
// charger instead — the only one that works when the charger cannot reach us.
// Control modes for the Anker Solix charger, in order of how much they demand
// of the deployment. own and proxy are OCPP paths (see internal/ocpp) and need
// the charger to dial in to us. modbus is the local path (the ankersolix
// plugin's modbus.go), where we dial the charger — which needs the server on
// the charger's network. mqtt is the remote path (cloudmqtt.go): both sides
// meet at Anker's own broker, so the charger needs no reachability at all,
// which is what makes it the mode for a charger behind a customer's router.
ankerControlOff = "off" // monitoring only (default)
ankerControlOwn = "own" // DriverVault is the charger's Central System
ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects
ankerControlModbus = "modbus" // DriverVault talks Modbus TCP to the charger on the LAN
ankerControlMqtt = "mqtt" // DriverVault commands the charger over Anker's cloud broker
)
// normalizeControlMode maps a raw control-mode value to a recognized mode, or ""
@@ -49,6 +53,8 @@ func normalizeControlMode(v string) string {
return ankerControlProxy
case ankerControlModbus:
return ankerControlModbus
case ankerControlMqtt:
return ankerControlMqtt
case ankerControlOff:
return ankerControlOff
default:
@@ -61,7 +67,7 @@ type ankerConfig struct {
Email string `json:"email"`
Password string `json:"password"`
Country string `json:"country"`
// ControlMode is the OCPP control path: off | own | proxy (see internal/ocpp).
// ControlMode is the control path: off | mqtt | modbus | own | proxy.
// It resolves independently of the credentials, like Country.
ControlMode string `json:"controlMode"`
}
@@ -312,7 +318,7 @@ func (s *Server) ankerView(who *callerIdentity, res ankerResolution) map[string]
"available": res.available,
"orgEnabled": res.orgEnabled,
"enabled": res.enabled,
"controlMode": res.eff.ControlMode, // effective OCPP control mode (off|own|proxy)
"controlMode": res.eff.ControlMode, // effective control mode (off|mqtt|modbus|own|proxy)
"role": who.Role,
"orgId": who.OrgID,
"canEditOrg": res.canOrg,
@@ -381,6 +381,22 @@ func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request
"modbusPort": binding.ModbusPort,
}
// Over the Anker cloud the charger holds its session with Anker, not with us,
// so "connected" means it answered the broker just now.
if res.eff.ControlMode == ankerControlMqtt {
snap, why, ok := s.ankerMqttSnapshot(r.Context(), res, sn)
body["connected"] = ok
if ok {
body["status"] = snap
} else if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
body["detail"] = "Enter your Anker account email and password in Settings; the cloud connection signs in as your account."
} else {
body["detail"] = "The charger did not answer over Anker's cloud. Check that it is powered on and online in the Anker app. (" + shortenDetail(why) + ")"
}
writeJSON(w, http.StatusOK, body)
return
}
// In Modbus mode "connected" is something we find out by asking, not by
// having been dialled: the charger holds no session with us between commands.
if res.eff.ControlMode == ankerControlModbus {
@@ -548,6 +564,10 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request
}
}
if res.eff.ControlMode == ankerControlMqtt {
s.ankerMqttAction(w, r, who, res, sn, action, body)
return
}
if res.eff.ControlMode == ankerControlModbus {
s.ankerModbusAction(w, r, who, binding, sn, action, body)
return
@@ -665,10 +685,11 @@ func controlAuditParams(action string, connectorID int, amps float64, hard bool,
// transport is chosen: the cascade, a control mode that is not off, and a
// charger the caller actually owns. It answers the request itself on refusal.
//
// What "owns" means depends on the mode, because the two transports bind a
// charger differently: OCPP by the control token the charger authenticates
// with, Modbus by the local address we dial. Requiring a token in Modbus mode
// would demand a credential that path never uses.
// What "owns" means depends on the mode, because each transport binds a charger
// differently: OCPP by the control token the charger authenticates with, Modbus
// by the local address we dial, the Anker cloud by the account the charger is
// registered to. Requiring a token in Modbus or cloud mode would demand a
// credential neither path ever uses.
func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, ankerResolution, ankerChargerBinding, bool) {
var (
res ankerResolution
@@ -700,6 +721,18 @@ func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*call
}
binding = ankerBindingFor(userRaw, sn)
if res.eff.ControlMode == ankerControlMqtt {
// The cloud path binds a charger by the account it is registered to, so
// there is no token to install and no address to save. What it does need is
// the credentials for that account — and the plugin refuses a serial the
// account does not own, which is the ownership check the other two get from
// their own binding.
if strings.TrimSpace(res.eff.Email) == "" || strings.TrimSpace(res.eff.Password) == "" {
writeError(w, http.StatusBadRequest, "the Anker cloud connection signs in as your account; enter your Anker email and password in Settings first")
return nil, res, binding, false
}
return who, res, binding, true
}
if res.eff.ControlMode == ankerControlModbus {
if strings.TrimSpace(binding.ModbusHost) == "" {
writeError(w, http.StatusNotFound, "no local address for this charger; enable Modbus TCP in the Anker app and save the address it shows")
@@ -0,0 +1,168 @@
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
// and a current limit. 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.
// The cloud transport signs in as the account, so unlike Modbus it needs them.
func ankerCloudConfig(res ankerResolution) map[string]string {
return map[string]string{
"email": res.eff.Email,
"password": res.eff.Password,
"country": res.eff.Country,
}
}
// 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 "reset", "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 "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, 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
}
@@ -0,0 +1,101 @@
package api
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// refuse runs one action through the cloud transport and returns the response.
// Only the actions refused up front reach the plugin-free path, which is exactly
// what these cases cover.
func refuse(t *testing.T, action string, body ankerControlBody) *httptest.ResponseRecorder {
t.Helper()
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/integrations/anker-solix/control/SN1/"+action, nil)
(&Server{}).ankerMqttAction(rec, req, nil, ankerResolution{}, "SN1", action, body)
return rec
}
// An action this transport cannot send must say which transport can, so the
// answer tells the operator what to change rather than that something is
// unknown.
func TestAnkerMqttActionNamesTheTransportThatCan(t *testing.T) {
for _, tc := range []struct{ action, want string }{
{"reset", "CSMS"},
{"unlock", "CSMS"},
{"availability", "CSMS"},
{"config", "CSMS"},
{"phase", "Modbus TCP"},
{"timeout", "Modbus TCP"},
{"clear-limit", "with the amps you want"},
} {
rec := refuse(t, tc.action, ankerControlBody{})
if rec.Code != http.StatusBadRequest {
t.Errorf("%s returned %d, want 400", tc.action, rec.Code)
continue
}
if !strings.Contains(rec.Body.String(), tc.want) {
t.Errorf("%s answered %q, want it to mention %q", tc.action, rec.Body.String(), tc.want)
}
}
}
func TestAnkerMqttActionRejectsUnknownActions(t *testing.T) {
rec := refuse(t, "explode", ankerControlBody{})
if rec.Code != http.StatusBadRequest || !strings.Contains(rec.Body.String(), "unknown control action") {
t.Errorf("unknown action returned %d %q", rec.Code, rec.Body.String())
}
}
// Boost runs until the session ends and there is no message to cancel it, so
// asking to switch it off has to be refused rather than quietly sending a boost.
func TestAnkerMqttActionRefusesTurningBoostOff(t *testing.T) {
off := false
rec := refuse(t, "boost", ankerControlBody{On: &off})
if rec.Code != http.StatusBadRequest {
t.Fatalf("boost off returned %d, want 400", rec.Code)
}
if !strings.Contains(rec.Body.String(), "ends with the charging session") {
t.Errorf("boost off answered %q, want it to explain when boost ends", rec.Body.String())
}
}
// The cloud transport signs in as the account, so unlike Modbus it carries the
// caller's resolved credentials into the plugin call.
func TestAnkerCloudConfigCarriesTheResolvedCredentials(t *testing.T) {
cfg := ankerCloudConfig(ankerResolution{
eff: ankerConfig{Email: "a@example.com", Password: "secret", Country: "DK"},
})
if cfg["email"] != "a@example.com" || cfg["password"] != "secret" || cfg["country"] != "DK" {
t.Errorf("config = %v, want the resolved credentials", cfg)
}
if _, ok := cfg["controlMode"]; ok {
t.Error("the control mode is the API server's business, not the plugin's")
}
}
func TestMustJSONEncodesTheCommandPayload(t *testing.T) {
var got map[string]any
if err := json.Unmarshal(mustJSON(map[string]any{"sn": "SN1", "command": "limit", "amps": 16.0}), &got); err != nil {
t.Fatalf("mustJSON produced invalid JSON: %v", err)
}
if got["sn"] != "SN1" || got["command"] != "limit" || got["amps"] != 16.0 {
t.Errorf("payload = %v", got)
}
}
func TestShortenDetailKeepsTheAnswerReadable(t *testing.T) {
if got := shortenDetail(" broke\nbadly "); got != "broke badly" {
t.Errorf("shortenDetail = %q", got)
}
if got := shortenDetail(""); got != "no detail" {
t.Errorf("shortenDetail of nothing = %q", got)
}
long := shortenDetail(strings.Repeat("x", 400))
if len([]rune(long)) != 201 || !strings.HasSuffix(long, "…") {
t.Errorf("a long detail was not trimmed: %d runes", len([]rune(long)))
}
}
@@ -16,9 +16,11 @@ func TestNormalizeControlMode(t *testing.T) {
"own": "own",
"proxy": "proxy",
"modbus": "modbus",
"mqtt": "mqtt",
"OWN": "own",
" Proxy": "proxy",
"Modbus ": "modbus",
" MQTT ": "mqtt",
"": "", // unset — cascade continues to the next layer
"bogus": "", // unknown — treated as unset
}