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 }