From 576df58776dc0d106b1535cf13b7d9acf1fa0a90 Mon Sep 17 00:00:00 2001 From: tajniak81 <13187254+tajniak81@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:47:10 +0200 Subject: [PATCH] Go the way the owner's phone already goes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- API Server/README.md | 44 +- .../internal/api/integrations_ankersolix.go | 18 +- .../api/integrations_ankersolix_control.go | 41 +- .../api/integrations_ankersolix_mqtt.go | 168 ++++ .../api/integrations_ankersolix_mqtt_test.go | 101 +++ .../api/integrations_ankersolix_test.go | 2 + .../plugins/builtin/ankersolix/ankersolix.go | 43 +- .../plugins/builtin/ankersolix/cloudmqtt.go | 737 ++++++++++++++++++ .../builtin/ankersolix/cloudmqtt_test.go | 249 ++++++ .../plugins/builtin/ankersolix/modbus.go | 20 +- .../plugins/builtin/ankersolix/mqttframe.go | 472 +++++++++++ .../builtin/ankersolix/mqttframe_test.go | 263 +++++++ .../builtin/ankersolix/mqttsnapshot.go | 371 +++++++++ .../builtin/ankersolix/mqttsnapshot_test.go | 146 ++++ .../plugins/builtin/ankersolix/session.go | 30 +- Phone App/assets/i18n/da.json | 15 + Phone App/assets/i18n/en.json | 15 + Phone App/assets/i18n/pl.json | 15 + Phone App/lib/models.dart | 64 +- Phone App/lib/screens/charging_screen.dart | 190 ++++- Phone App/lib/screens/settings_screen.dart | 19 +- README.md | 9 +- Web App/web/src/api.js | 18 +- Web App/web/src/i18n/da.json | 15 + Web App/web/src/i18n/en.json | 15 + Web App/web/src/i18n/pl.json | 15 + Web App/web/src/views/Charging.vue | 238 ++++-- Web App/web/src/views/Settings.vue | 24 +- 28 files changed, 3186 insertions(+), 171 deletions(-) create mode 100644 API Server/internal/api/integrations_ankersolix_mqtt.go create mode 100644 API Server/internal/api/integrations_ankersolix_mqtt_test.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/mqttframe.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go create mode 100644 API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go diff --git a/API Server/README.md b/API Server/README.md index fc89b1b..c466fdb 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -30,7 +30,7 @@ internal/ │ └── dist/ # built panel, embedded via go:embed ├── config/config.go # env + .env load, .env write-back ├── models/models.go # domain types + derived-field computation -├── mqtt/ # hand-rolled MQTT 3.1.1 client (Greencell EVSE telemetry) +├── mqtt/ # hand-rolled MQTT 3.1.1 client (Greencell EVSE telemetry, Anker cloud control) ├── modbus/ # Modbus TCP client (Anker Solix local charging control) ├── ocpp/ # OCPP 1.6J Central System (Anker Solix charging control) ├── pb/client.go # PocketBase superuser client (runtime-retargetable) @@ -185,7 +185,7 @@ GET /api/integrations/greencell PUT /api/integrations/greencell POST /a GET /api/integrations/greencell/chargers GET /api/integrations/greencell/chargers/{sn}/state -# Anker Solix charging control (Modbus TCP locally, or OCPP own/proxy mode) +# Anker Solix charging control (Anker cloud, Modbus TCP locally, or OCPP own/proxy mode) GET /api/integrations/anker-solix/chargers/{sn}/control POST /api/integrations/anker-solix/chargers/{sn}/control/token DELETE /api/integrations/anker-solix/chargers/{sn}/control/token @@ -319,17 +319,35 @@ Beyond the superadmin plugin registry, the built-in connectors are exposed per-user through `/api/integrations/*` under a **superadmin → org admin → user** cascade (each layer supplies defaults the next can override). -Anker Solix chargers can be controlled two ways, chosen per user with the control -mode. **Modbus TCP** (`internal/modbus`) dials the charger on the local network -using the register map Anker publishes for the V1; the owner enables it in the -Anker app under Settings > Integrations and saves the address it shows. It needs -no inbound connectivity, which is what makes it the workable path for a charger -behind a customer's router. The two **OCPP 1.6J** modes instead run a Central -System (`internal/ocpp`) that the charger dials back into at `GET /ocpp/{serial}` -(authenticated with OCPP Basic auth using a per-charger control token, not a -bearer token), which requires the charger to be able to reach this server. Either -way the owner can start/stop and set charge limits, with every command -rate-limited and written to a `control_audit` trail. +Anker Solix chargers can be controlled three ways, chosen per user with the +control mode, and they differ in what the deployment has to make reachable. + +The **Anker cloud** mode asks nothing of the network at all. The charger already +holds a connection open to Anker's MQTT broker — it is how the mobile app reaches +it from anywhere — so the connector joins that broker as the account +(`app/devicemanage/get_user_mqtt_info` issues a client certificate; mTLS to +`aiot-mqtt-eu.anker.com:8883`) and publishes on the same topics the app does. +Nothing is forwarded, addressed or certificated on the customer's side, which +makes it the mode for a charger somewhere else entirely; the cost is a dependency +on Anker's cloud and on an unofficial protocol, since the messages carry a binary +device frame rather than an API call. It also reports two signals no other +transport can see — the boost flag and the plug/start countdowns. + +**Modbus TCP** (`internal/modbus`) dials the charger on the local network using +the register map Anker publishes for the V1; the owner enables it in the Anker +app under Settings > Integrations and saves the address it shows. It needs no +inbound connectivity and no cloud, but it does need the server to share a network +with the charger. + +The two **OCPP 1.6J** modes instead run a Central System (`internal/ocpp`) that +the charger dials back into at `GET /ocpp/{serial}` (authenticated with OCPP +Basic auth using a per-charger control token, not a bearer token), which requires +the charger to be able to reach this server. + +Whichever is in force, the owner can start/stop and set charge limits, with every +command rate-limited and written to a `control_audit` trail. A command a +transport has no equivalent for is refused by name, saying which transport does +have it. **Greencell** takes the other route. The HabuDen wallbox has no cloud API: it is commissioned over Bluetooth in the Greencell GC app, pointed at an MQTT broker diff --git a/API Server/internal/api/integrations_ankersolix.go b/API Server/internal/api/integrations_ankersolix.go index 9329f2c..33fcff5 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -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, diff --git a/API Server/internal/api/integrations_ankersolix_control.go b/API Server/internal/api/integrations_ankersolix_control.go index 6c7a0c7..6fcb2ba 100644 --- a/API Server/internal/api/integrations_ankersolix_control.go +++ b/API Server/internal/api/integrations_ankersolix_control.go @@ -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") diff --git a/API Server/internal/api/integrations_ankersolix_mqtt.go b/API Server/internal/api/integrations_ankersolix_mqtt.go new file mode 100644 index 0000000..cceda8c --- /dev/null +++ b/API Server/internal/api/integrations_ankersolix_mqtt.go @@ -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 +} diff --git a/API Server/internal/api/integrations_ankersolix_mqtt_test.go b/API Server/internal/api/integrations_ankersolix_mqtt_test.go new file mode 100644 index 0000000..0a9d8be --- /dev/null +++ b/API Server/internal/api/integrations_ankersolix_mqtt_test.go @@ -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))) + } +} diff --git a/API Server/internal/api/integrations_ankersolix_test.go b/API Server/internal/api/integrations_ankersolix_test.go index 39e4db2..230ba39 100644 --- a/API Server/internal/api/integrations_ankersolix_test.go +++ b/API Server/internal/api/integrations_ankersolix_test.go @@ -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 } diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 0db72af..51ddea1 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -7,13 +7,12 @@ // unchanged since v3.7.0. // // Scope & limitations: -// - Read-only. Only EV-charger information is retrieved; no charge start/stop -// or configuration commands are implemented. Control is a separate concern -// and runs over OCPP (see internal/ocpp), not the cloud API. -// - Cloud only. Upstream reads a charger's live state over both the cloud and -// MQTT; we take the cloud half. Signals that exist only in MQTT — the boost -// flag and the plug/start countdowns — are therefore never set, which the -// derived state accounts for. +// - The REST half is read-only. Every endpoint below retrieves EV-charger +// information; nothing is started, stopped or configured through it, because +// Anker's REST API has no such endpoint. Control runs over one of three +// transports instead: OCPP (internal/ocpp), Modbus TCP on the local network +// (modbus.go), or the account's cloud MQTT broker (cloudmqtt.go) — the one +// path that reaches a charger behind a customer's router. // - Single device family. Capabilities target the V1 Smart EV Charger; other // Anker Power devices (solarbanks, power stations, HES) are out of scope. // - Unofficial. This talks to Anker's private mobile-app cloud API with the @@ -88,6 +87,9 @@ const ( epSiteList = "power_service/v1/site/get_site_list" // sites (systems) on the account epUserVehicles = "power_service/v1/app/vehicle/get_vehicle_list" // vehicles registered for smart charging epVehicleDetail = "power_service/v1/app/vehicle/get_vehicle_detail" // details for one registered vehicle + + // The cloud MQTT broker's own credentials endpoint is epMqttInfo, declared in + // cloudmqtt.go next to the transport that uses it. ) // EV-charger operating states, mirroring anker-solix-api's SolixEvChargerStatus. @@ -192,6 +194,8 @@ func (p *Plugin) Descriptor() plugins.Descriptor { {ID: "sites", Method: "POST", Endpoint: epSiteList, Description: "Sites (systems) registered to the account."}, {ID: "vehicles", Method: "POST", Endpoint: epUserVehicles, Description: "Vehicles registered for smart charging."}, {ID: "vehicle", Method: "POST", Endpoint: epVehicleDetail, Description: "Details for one registered vehicle (needs vehicleId)."}, + {ID: "mqtt-status", Method: "POST", Endpoint: epMqttInfo, Description: "Live state of one charger over Anker's cloud MQTT broker — the path to a charger the server cannot reach (needs sn)."}, + {ID: "mqtt-command", Method: "POST", Endpoint: epMqttInfo, Description: "Control one charger over Anker's cloud MQTT broker: start, stop, boost, skip-delay, limit (with amps) or trigger (needs sn and command)."}, }, ConfigFields: []plugins.ConfigField{ // Credentials are intentionally NOT required at the global (panel) layer, @@ -209,9 +213,10 @@ func (p *Plugin) Descriptor() plugins.Descriptor { // package (internal/ocpp) — but it is advertised here so a superadmin can // set/lock it at the global layer, and it cascades like the other fields. {Key: "controlMode", Label: "Control mode", Type: "select", Default: "off", - Help: "How DriverVault controls the charger. Off = monitoring only (default). Modbus TCP = DriverVault connects to the charger on the local network (enable it in the Anker app under Settings > Integrations); this is the only mode that works when the charger cannot reach the server. The two OCPP modes need the charger to connect in to DriverVault: Own CSMS directly, Proxy CSMS relayed to Anker's cloud.", + Help: "How DriverVault controls the charger. Off = monitoring only (default). Anker cloud = commands travel over the account's own MQTT broker, so the charger needs no reachability at all; this is the mode for a charger behind a customer's router. Modbus TCP = DriverVault connects to the charger on the local network (enable it in the Anker app under Settings > Integrations), which needs the server to share that network. The two OCPP modes need the charger to connect in to DriverVault: Own CSMS directly, Proxy CSMS relayed to Anker's cloud.", Options: []plugins.SelectOption{ {Value: "off", Label: "Off (monitoring only)"}, + {Value: "mqtt", Label: "Anker cloud (works anywhere)"}, {Value: "modbus", Label: "Modbus TCP (local network)"}, {Value: "own", Label: "Own CSMS (full control)"}, {Value: "proxy", Label: "Proxy CSMS (relay + control)"}, @@ -286,6 +291,11 @@ type invokeParams struct { Range string `json:"range"` // day | week | month | year StartDate string `json:"startDate"` // YYYY-MM-DD, or YYYY-MM / YYYY for month / year EndDate string `json:"endDate"` + + // The cloud MQTT actions: which command to send, and the current ceiling + // "limit" carries. + Command string `json:"command"` + Amps float64 `json:"amps"` } // Invoke runs a named read-only capability. The upstream response body is @@ -313,6 +323,18 @@ func (p *Plugin) Invoke(ctx context.Context, action string, params json.RawMessa } return p.chargerState(ctx, pp.SiteID, pp.SN) } + // The cloud MQTT actions address the charger itself over the account's broker + // rather than a REST endpoint, so they route to that transport instead of the + // single-endpoint dispatch below. + if action == "mqtt-status" || action == "mqtt-command" { + if pp.SN == "" { + return nil, fmt.Errorf("anker-solix: action %q requires an sn (EV charger serial)", action) + } + if action == "mqtt-status" { + return p.mqttStatus(ctx, pp.SN) + } + return p.mqttCommand(ctx, pp.SN, pp.Command, pp.Amps) + } var ( endpoint string @@ -396,7 +418,10 @@ func energyRange(r string) string { } } -// Shutdown releases pooled connections. +// Shutdown releases pooled connections. The account's shared session — its token +// and its broker connection — is deliberately left alone: the manager builds and +// tears down an instance per request, and every instance for that account shares +// it (see session.go). func (p *Plugin) Shutdown(context.Context) error { p.mu.Lock() defer p.mu.Unlock() diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go new file mode 100644 index 0000000..da78e97 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt.go @@ -0,0 +1,737 @@ +package ankersolix + +// Control over Anker's own cloud MQTT broker — the path for a charger the server +// cannot reach. +// +// The other two transports each need something a remote customer does not have. +// OCPP needs the charger to dial in to us, which means a public endpoint, a +// certificate, and a charger whose firmware accepts our CSMS. Modbus TCP needs +// the server to dial the charger, which means sharing a network with it. Most +// chargers sit behind a customer's router with neither. What they *do* have is +// the connection they already hold open to Anker: the mobile app controls them +// through it from anywhere, and it is the same broker the charger's own +// mqttStatus register reports as connected. +// +// So this transport joins that broker as the account, exactly as the app does: +// +// 1. app/devicemanage/get_user_mqtt_info hands out a client certificate and key +// for the signed-in account, the broker's address, and the AWS root the +// broker is verified against. The certificate is the credential; there is no +// username or password on the MQTT connection itself. +// 2. Commands are published to cmd/{app}/{model}/{serial}/req and the charger's +// own messages arrive on dt/{app}/{model}/{serial}/#. +// 3. Both directions carry a JSON envelope whose payload holds a base64 binary +// frame — the device's own protocol, encoded in mqttframe.go. +// +// Two consequences shape the code: +// +// - A connection is expensive (a TLS handshake with a fetched certificate) and +// the plugin manager builds a throwaway instance per request, so the broker +// connection lives in the account's shared session alongside the auth token, +// for the same reason (see session.go). It closes itself after an idle spell. +// - The charger does not publish its live state unless asked. A realtime +// trigger turns the stream on for a bounded window, after which it stops +// again — so a status read arms the trigger and waits for the next frame, +// and a second read inside the window answers from what has since arrived. +// +// Unofficial, like the rest of the cloud half: this is the mobile app's private +// transport, and Anker may change it at any time. + +import ( + "context" + "crypto/rand" + "crypto/tls" + "crypto/x509" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "math/big" + "net" + "strings" + "sync" + "time" + + "drivervault/apiserver/internal/mqtt" +) + +// epMqttInfo hands out the account's broker address and client certificate. +const epMqttInfo = "app/devicemanage/get_user_mqtt_info" + +// defaultChargerModel is the product code used when the account inventory has +// not named one. The topics carry the model, and this connector is scoped to the +// V1 Smart EV Charger, so it is the only sensible fallback. +const defaultChargerModel = "A5191" + +// Broker timings. +const ( + // mqttCredsTTL re-fetches the certificate periodically. It is issued per + // account and long-lived, but refetching costs one cloud call an hour and + // means a revoked certificate is not held forever. + mqttCredsTTL = 1 * time.Hour + + // mqttIdle closes a broker connection nothing has used for this long. The + // charger keeps publishing only while a trigger is live, so an idle + // connection is genuinely idle. + mqttIdle = 5 * time.Minute + + // mqttConnectTimeout bounds the TLS handshake and the wait for CONNACK. + mqttConnectTimeout = 15 * time.Second + + // triggerWindow is how long the charger is asked to keep streaming telemetry, + // and triggerRenew is how close to the end of that window a status read + // re-arms it rather than racing the last frame. + triggerWindow = 180 * time.Second + triggerRenew = 30 * time.Second + + // statusWait is how long a status read waits for a frame from the charger. A + // triggered charger publishes every 3-5 seconds; this allows for the trigger + // having to reach it first. + statusWait = 12 * time.Second + + // commandWait is how long a command waits for the charger's confirmation + // message. A command is fire-and-forget on the wire, so this only decides + // whether we can say the charger answered — not whether it was sent. + commandWait = 5 * time.Second + + // deviceCacheTTL is how long the account's charger list (serial to model) is + // trusted before the cloud is asked again. + deviceCacheTTL = 1 * time.Hour +) + +// The mode values the charger's 0105 message accepts, and the names this +// connector takes for them — the same names the cloud plugin already uses for +// SolixEvChargerMode. +var mqttModeValues = map[string]uint8{ + modeStartCharge: 1, + modeStopCharge: 2, + modeSkipDelay: 3, + modeBoostCharge: 4, +} + +// mqttEncodingMode is the payload's encoding_type for the mode command. It is +// not encryption — the frame is plain either way — but the charger expects the +// field on this message, so it is sent with a seed like the app's. +const mqttEncodingMode = 2 + +// mqttCredentials is what get_user_mqtt_info returns: an address to dial and a +// certificate to dial it with. +type mqttCredentials struct { + UserID string `json:"user_id"` + AppName string `json:"app_name"` + ThingName string `json:"thing_name"` + CertificateID string `json:"certificate_id"` + CertificatePE string `json:"certificate_pem"` + PrivateKey string `json:"private_key"` + EndpointAddr string `json:"endpoint_addr"` + RootCA string `json:"aws_root_ca1_pem"` +} + +// valid reports whether the credentials carry everything a connection needs. +func (c mqttCredentials) valid() bool { + return strings.TrimSpace(c.EndpointAddr) != "" && + strings.TrimSpace(c.CertificatePE) != "" && + strings.TrimSpace(c.PrivateKey) != "" +} + +// address is the broker's host:port. The endpoint is returned without a port; +// 8883 is the MQTT-over-TLS port the app uses. +func (c mqttCredentials) address() string { + host := strings.TrimSpace(c.EndpointAddr) + if _, _, err := net.SplitHostPort(host); err == nil { + return host + } + return net.JoinHostPort(host, "8883") +} + +// appName is the topic segment identifying the app the account belongs to. +func (c mqttCredentials) appName() string { + if n := strings.TrimSpace(c.AppName); n != "" { + return n + } + return "anker_power" +} + +// ---- credentials and device lookup ------------------------------------------- + +// mqttCreds returns the account's broker credentials, fetching them at most once +// per mqttCredsTTL. They are held on the shared session rather than the plugin +// instance for the same reason the auth token is: the instance does not outlive +// the request. +func (p *Plugin) mqttCreds(ctx context.Context) (mqttCredentials, error) { + s := p.sess + if s == nil { + return mqttCredentials{}, errors.New("anker-solix: plugin not initialised") + } + s.mqttMu.Lock() + defer s.mqttMu.Unlock() + + if s.mqttCreds != nil && time.Since(s.mqttCredsAt) < mqttCredsTTL { + return *s.mqttCreds, nil + } + body, err := p.apiRequest(ctx, epMqttInfo, map[string]any{}) + if err != nil { + return mqttCredentials{}, err + } + var env struct { + Data mqttCredentials `json:"data"` + } + if err := json.Unmarshal(body, &env); err != nil { + return mqttCredentials{}, fmt.Errorf("anker-solix: decode MQTT info: %w", err) + } + if !env.Data.valid() { + return mqttCredentials{}, errors.New("anker-solix: the cloud returned no MQTT certificate for this account; cloud control is not available on it") + } + s.mqttCreds, s.mqttCredsAt = &env.Data, time.Now() + return env.Data, nil +} + +// chargerModel returns the product code for a serial on this account, which the +// topics need. It doubles as the ownership check: a serial no view of the +// account reports is one this caller may not command, and saying so is better +// than publishing to a topic the broker will refuse anyway. +func (p *Plugin) chargerModel(ctx context.Context, sn string) (string, error) { + s := p.sess + if s == nil { + return "", errors.New("anker-solix: plugin not initialised") + } + sn = strings.TrimSpace(sn) + if sn == "" { + return "", errors.New("anker-solix: a charger serial is required") + } + + s.mqttMu.Lock() + model, known := s.devices[sn] + fresh := time.Since(s.devicesAt) < deviceCacheTTL + s.mqttMu.Unlock() + if known && fresh { + return model, nil + } + + // Unknown, or the list has gone stale: ask the cloud once and remember it. + doc, err := p.chargerInventory(ctx) + if err != nil { + if known { + return model, nil // the cloud is unreachable; the last list still stands + } + return "", err + } + found := map[string]string{} + for _, c := range doc.Chargers { + m := strings.ToUpper(strings.TrimSpace(c.Model)) + if m == "" { + m = defaultChargerModel + } + found[c.SN] = m + } + s.mqttMu.Lock() + s.devices, s.devicesAt = found, time.Now() + s.mqttMu.Unlock() + + if m, ok := found[sn]; ok { + return m, nil + } + return "", fmt.Errorf("anker-solix: charger %s is not on this Anker account", sn) +} + +// ---- the broker connection --------------------------------------------------- + +// mqttConn is one account's live broker connection, with the state it has +// collected from the chargers it is subscribed to. +type mqttConn struct { + client *mqtt.Client + creds mqttCredentials + sessID string + started time.Time + + mu sync.Mutex + subs map[string]bool // topic filter -> subscribed + devices map[string]*deviceState // serial -> what it has told us + lastUse time.Time + deadErr error + waiters []chan struct{} + shutdown chan struct{} +} + +// deviceState is everything one charger has reported over this connection, +// merged across message types: telemetry overwrites telemetry, settings +// overwrite settings, and neither erases the other. +type deviceState struct { + values map[string]any + telemetryAt time.Time + settingsAt time.Time + triggeredUntil time.Time +} + +// mqttClient returns the account's broker connection, opening one if there is +// none or the last one died. +func (p *Plugin) mqttClient(ctx context.Context) (*mqttConn, error) { + s := p.sess + if s == nil { + return nil, errors.New("anker-solix: plugin not initialised") + } + creds, err := p.mqttCreds(ctx) + if err != nil { + return nil, err + } + + s.mqttMu.Lock() + defer s.mqttMu.Unlock() + if c := s.mqttConn; c != nil { + if c.alive() && c.creds.CertificateID == creds.CertificateID { + c.touch() + return c, nil + } + c.close() + s.mqttConn = nil + } + c, err := dialBroker(ctx, creds) + if err != nil { + return nil, err + } + s.mqttConn = c + return c, nil +} + +// dialBroker opens the mutually-authenticated connection. The account's +// certificate is the credential, and the broker is verified against the AWS root +// the same response supplied — the connection is to Anker's own broker, so +// neither side is trusted on the strength of the other. +func dialBroker(ctx context.Context, creds mqttCredentials) (*mqttConn, error) { + cert, err := tls.X509KeyPair([]byte(creds.CertificatePE), []byte(creds.PrivateKey)) + if err != nil { + return nil, fmt.Errorf("anker-solix: the cloud's MQTT certificate could not be loaded: %w", err) + } + roots := x509.NewCertPool() + if ca := strings.TrimSpace(creds.RootCA); ca != "" { + if !roots.AppendCertsFromPEM([]byte(ca)) { + return nil, errors.New("anker-solix: the cloud's MQTT root certificate could not be parsed") + } + } else { + // No root supplied: fall back to the system pool rather than skipping + // verification, which would let anything answer for the broker. + if roots, err = x509.SystemCertPool(); err != nil { + return nil, fmt.Errorf("anker-solix: no root certificates to verify the MQTT broker: %w", err) + } + } + host, _, splitErr := net.SplitHostPort(creds.address()) + if splitErr != nil { + host = creds.EndpointAddr + } + + client, err := mqtt.Connect(ctx, mqtt.Options{ + Address: creds.address(), + TLS: true, + TLSConfig: &tls.Config{ + ServerName: host, + MinVersion: tls.VersionTLS12, + RootCAs: roots, + Certificates: []tls.Certificate{cert}, + }, + // The broker keys a session by client id and evicts the older holder, so + // the app's own connection must not be displaced: the app uses + // "{thing_name}_{5 digits}", and a different suffix is a different session. + ClientID: clientIDFor(creds), + Keepalive: 60 * time.Second, + ConnectTimeout: mqttConnectTimeout, + Buffer: 256, + }) + if err != nil { + return nil, err + } + + c := &mqttConn{ + client: client, + creds: creds, + sessID: randomSessionID(), + started: time.Now(), + subs: map[string]bool{}, + devices: map[string]*deviceState{}, + lastUse: time.Now(), + shutdown: make(chan struct{}), + } + go c.readLoop() + go c.idleLoop() + return c, nil +} + +// clientIDFor builds an identifier no other holder of this account's certificate +// is using, so joining the broker never evicts the owner's mobile app. +func clientIDFor(creds mqttCredentials) string { + thing := strings.TrimSpace(creds.ThingName) + if thing == "" { + thing = strings.TrimSpace(creds.UserID) + } + return fmt.Sprintf("%s_%05d", thing, randomBelow(100000)) +} + +// randomSessionID mimics the app's sess_id, a pair of four-digit groups. +func randomSessionID() string { + return fmt.Sprintf("%04d-%04d", randomBelow(10000), randomBelow(10000)) +} + +// randomBelow returns a non-negative integer below n, falling back to a +// clock-derived value if the system source fails. +func randomBelow(n int64) int64 { + v, err := rand.Int(rand.Reader, big.NewInt(n)) + if err != nil { + return time.Now().UnixNano() % n + } + return v.Int64() +} + +// alive reports whether the connection is still usable. +func (c *mqttConn) alive() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.deadErr == nil +} + +// touch marks the connection as in use, so the idle sweep leaves it alone. +func (c *mqttConn) touch() { + c.mu.Lock() + c.lastUse = time.Now() + c.mu.Unlock() +} + +// close ends the connection and wakes anything waiting on a message. +func (c *mqttConn) close() { + c.fail(mqtt.ErrClosed) + _ = c.client.Close() +} + +// fail records why the connection ended and releases every waiter. +func (c *mqttConn) fail(err error) { + c.mu.Lock() + if c.deadErr == nil { + c.deadErr = err + close(c.shutdown) + } + c.wakeLocked() + c.mu.Unlock() +} + +// wakeLocked releases everything waiting for a device message. Caller holds mu. +func (c *mqttConn) wakeLocked() { + for _, ch := range c.waiters { + close(ch) + } + c.waiters = nil +} + +// readLoop owns the inbound stream for the life of the connection. +func (c *mqttConn) readLoop() { + for msg := range c.client.Messages() { + c.ingest(msg) + } + err := c.client.Err() + if err == nil { + err = mqtt.ErrClosed + } + c.fail(err) +} + +// idleLoop closes a connection nothing has used for mqttIdle. The account keeps +// no state on the broker between commands, so dropping the socket costs only the +// next handshake. +func (c *mqttConn) idleLoop() { + t := time.NewTicker(mqttIdle / 2) + defer t.Stop() + for { + select { + case <-c.shutdown: + return + case <-t.C: + c.mu.Lock() + idle := time.Since(c.lastUse) + c.mu.Unlock() + if idle >= mqttIdle { + c.close() + return + } + } + } +} + +// ingest decodes one inbound message and folds it into the sending charger's +// state. Anything it cannot read is dropped: these frames come from a cloud +// connection, and a malformed one must not be recorded as a reading. +func (c *mqttConn) ingest(msg mqtt.Message) { + sn, data, ok := parseEnvelope(msg) + if !ok { + return + } + msgType, values, err := decodeFrame(data) + if err != nil || len(values) == 0 { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + st := c.devices[sn] + if st == nil { + st = &deviceState{values: map[string]any{}} + c.devices[sn] = st + } + for k, v := range values { + st.values[k] = v + } + now := time.Now() + if msgType == msgEVTelemetry { + st.telemetryAt = now + } else { + st.settingsAt = now + } + c.wakeLocked() +} + +// parseEnvelope pulls the sending serial and the binary frame out of one MQTT +// message. The payload is a JSON string inside a JSON object, and the frame is +// base64 inside that — the shape the app both sends and receives. +func parseEnvelope(msg mqtt.Message) (string, []byte, bool) { + var env struct { + Head struct { + DeviceSN string `json:"device_sn"` + } `json:"head"` + Payload string `json:"payload"` + } + if err := json.Unmarshal(msg.Payload, &env); err != nil { + return "", nil, false + } + var inner struct { + SN string `json:"sn"` + SN2 string `json:"device_sn"` + Data string `json:"data"` + } + if err := json.Unmarshal([]byte(env.Payload), &inner); err != nil { + return "", nil, false + } + sn := firstNonEmpty(inner.SN, inner.SN2, env.Head.DeviceSN, serialFromTopic(msg.Topic)) + if sn == "" || inner.Data == "" { + return "", nil, false + } + data, err := base64.StdEncoding.DecodeString(inner.Data) + if err != nil { + return "", nil, false + } + return sn, data, true +} + +// serialFromTopic reads the serial out of dt/{app}/{model}/{serial}/… , which is +// where it is when the payload does not repeat it. +func serialFromTopic(topic string) string { + parts := strings.Split(topic, "/") + if len(parts) < 4 { + return "" + } + return parts[3] +} + +func firstNonEmpty(vals ...string) string { + for _, v := range vals { + if s := strings.TrimSpace(v); s != "" { + return s + } + } + return "" +} + +// ---- topics, subscribing and publishing -------------------------------------- + +// dataTopic is the filter carrying everything one charger publishes. +func dataTopic(creds mqttCredentials, model, sn string) string { + return fmt.Sprintf("dt/%s/%s/%s/#", creds.appName(), model, sn) +} + +// commandTopic is where one charger's commands are published. +func commandTopic(creds mqttCredentials, model, sn string) string { + return fmt.Sprintf("cmd/%s/%s/%s/req", creds.appName(), model, sn) +} + +// listen subscribes to a charger's data topic, once per connection. +func (c *mqttConn) listen(ctx context.Context, model, sn string) error { + topic := dataTopic(c.creds, model, sn) + c.mu.Lock() + already := c.subs[topic] + c.mu.Unlock() + if already { + return nil + } + if err := c.client.Subscribe(ctx, topic); err != nil { + return fmt.Errorf("anker-solix: cannot listen to charger %s over the cloud: %w", sn, err) + } + c.mu.Lock() + c.subs[topic] = true + c.mu.Unlock() + return nil +} + +// publishFrame wraps a device frame in the app's envelope and publishes it to +// the charger's command topic. +func (c *mqttConn) publishFrame(ctx context.Context, model, sn string, frame []byte, encoding int) error { + now := time.Now() + seed := any(1) + inner := map[string]any{ + "device_sn": sn, + "account_id": c.creds.UserID, + "data": base64.StdEncoding.EncodeToString(frame), + } + if encoding != 0 { + inner["encoding_type"] = encoding + seed = randomSeed() + } + payload, err := json.Marshal(inner) + if err != nil { + return err + } + envelope, err := json.Marshal(map[string]any{ + "head": map[string]any{ + "version": "1.0.0.1", + "client_id": fmt.Sprintf("android-%s-%s-%s", c.creds.appName(), c.creds.UserID, c.creds.CertificateID), + "sess_id": c.sessID, + "msg_seq": 1, + "seed": seed, + "timestamp": now.Unix(), + // cmd_status 2 and cmd 17 are what the app sends on a control message; + // the charger ignores neither, and a different pair goes unanswered. + "cmd_status": 2, + "cmd": 17, + "sign_code": 1, + "device_pn": model, + "device_sn": sn, + }, + "payload": string(payload), + }) + if err != nil { + return err + } + c.touch() + return c.client.Publish(ctx, commandTopic(c.creds, model, sn), envelope) +} + +// randomSeed is the 16-byte seed the app puts in the header of an encoded +// message. +func randomSeed() string { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + binary.LittleEndian.PutUint64(b[:8], uint64(time.Now().UnixNano())) + } + return encodeHex(b[:]) +} + +// ---- waiting for the charger to answer ---------------------------------------- + +// waitFor blocks until a charger's state satisfies ready, or the deadline +// passes. It reports whether ready was met; a connection that dies while waiting +// ends the wait with the reason. +func (c *mqttConn) waitFor(ctx context.Context, sn string, ready func(*deviceState) bool, timeout time.Duration) (bool, error) { + deadline := time.After(timeout) + for { + c.mu.Lock() + if err := c.deadErr; err != nil { + c.mu.Unlock() + return false, fmt.Errorf("anker-solix: the cloud connection dropped: %w", err) + } + if st := c.devices[sn]; st != nil && ready(st) { + c.mu.Unlock() + return true, nil + } + ch := make(chan struct{}) + c.waiters = append(c.waiters, ch) + c.mu.Unlock() + + select { + case <-ch: + case <-deadline: + return false, nil + case <-ctx.Done(): + return false, ctx.Err() + } + } +} + +// snapshotOf copies a charger's collected state out from under the lock. +func (c *mqttConn) snapshotOf(sn string) (map[string]any, time.Time, time.Time, time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + st := c.devices[sn] + if st == nil { + return nil, time.Time{}, time.Time{}, time.Time{} + } + out := make(map[string]any, len(st.values)) + for k, v := range st.values { + out[k] = v + } + return out, st.telemetryAt, st.settingsAt, st.triggeredUntil +} + +// noteTrigger records how long the charger has been asked to keep streaming. +func (c *mqttConn) noteTrigger(sn string, until time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + st := c.devices[sn] + if st == nil { + st = &deviceState{values: map[string]any{}} + c.devices[sn] = st + } + st.triggeredUntil = until +} + +// ---- commands ---------------------------------------------------------------- + +// mqttTrigger asks a charger to publish live telemetry for a while. Without it +// the charger is silent, so every status read arms one. +func (p *Plugin) mqttTrigger(ctx context.Context, c *mqttConn, model, sn string, window time.Duration) error { + frame, err := encodeFrame(msgRealtimeTrigger, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, 1), + varField(0xa3, uint32(window/time.Second)), + timestampField(time.Now()), + }) + if err != nil { + return err + } + if err := c.publishFrame(ctx, model, sn, frame, 0); err != nil { + return err + } + c.noteTrigger(sn, time.Now().Add(window)) + return nil +} + +// mqttSetMode sends the start / stop / skip-delay / boost command. +func (p *Plugin) mqttSetMode(ctx context.Context, c *mqttConn, model, sn, mode string) error { + v, ok := mqttModeValues[mode] + if !ok { + return fmt.Errorf("anker-solix: %q is not one of %s, %s, %s or %s", + mode, modeStartCharge, modeStopCharge, modeSkipDelay, modeBoostCharge) + } + frame, err := encodeFrame(msgEVMode, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, v), + timestampField(time.Now()), + }) + if err != nil { + return err + } + return c.publishFrame(ctx, model, sn, frame, mqttEncodingMode) +} + +// mqttSetMaxCurrent sets the charging current ceiling, in amps. The limit is +// checked by the same rule the Modbus path uses, because the rule is the +// charger's: the transport differs, the charger does not. +func (p *Plugin) mqttSetMaxCurrent(ctx context.Context, c *mqttConn, model, sn string, amps float64) error { + if err := checkMaxCurrent(amps); err != nil { + return err + } + // The field carries deciamps, as the register does over Modbus. + frame, err := encodeFrame(msgEVSettings, []cmdField{ + rawField(0xa1, 0x22), + intField(0xa8, int16(amps*10)), + timestampField(time.Now()), + }) + if err != nil { + return err + } + return c.publishFrame(ctx, model, sn, frame, 0) +} diff --git a/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go new file mode 100644 index 0000000..30cd282 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/cloudmqtt_test.go @@ -0,0 +1,249 @@ +package ankersolix + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" + + "drivervault/apiserver/internal/mqtt" +) + +func TestMqttCredentialsAddressAndAppName(t *testing.T) { + c := mqttCredentials{EndpointAddr: "aiot-mqtt-eu.anker.com"} + if got := c.address(); got != "aiot-mqtt-eu.anker.com:8883" { + t.Errorf("address = %q, want the TLS port appended", got) + } + // A response that already carries a port must not have another appended. + c.EndpointAddr = "aiot-mqtt-eu.anker.com:8884" + if got := c.address(); got != "aiot-mqtt-eu.anker.com:8884" { + t.Errorf("address = %q, want the endpoint's own port kept", got) + } + if got := c.appName(); got != "anker_power" { + t.Errorf("appName = %q, want the default when the cloud sent none", got) + } + c.AppName = "anker_charging" + if got := c.appName(); got != "anker_charging" { + t.Errorf("appName = %q, want the cloud's own value", got) + } +} + +func TestMqttCredentialsNeedCertificateAndKey(t *testing.T) { + full := mqttCredentials{EndpointAddr: "host", CertificatePE: "cert", PrivateKey: "key"} + if !full.valid() { + t.Error("complete credentials were rejected") + } + for _, c := range []mqttCredentials{ + {CertificatePE: "cert", PrivateKey: "key"}, + {EndpointAddr: "host", PrivateKey: "key"}, + {EndpointAddr: "host", CertificatePE: "cert"}, + } { + if c.valid() { + t.Errorf("credentials missing a field were accepted: %+v", c) + } + } +} + +func TestTopicsAddressOneCharger(t *testing.T) { + c := mqttCredentials{AppName: "anker_power"} + if got := commandTopic(c, "A5191", "SN123"); got != "cmd/anker_power/A5191/SN123/req" { + t.Errorf("commandTopic = %q", got) + } + if got := dataTopic(c, "A5191", "SN123"); got != "dt/anker_power/A5191/SN123/#" { + t.Errorf("dataTopic = %q", got) + } +} + +// envelope builds a message shaped like the ones the cloud delivers: JSON, whose +// payload is itself a JSON string, whose data field is a base64 device frame. +func envelope(t *testing.T, sn string, frame []byte) mqtt.Message { + t.Helper() + inner, err := json.Marshal(map[string]any{ + "device_sn": sn, + "data": base64.StdEncoding.EncodeToString(frame), + }) + if err != nil { + t.Fatal(err) + } + outer, err := json.Marshal(map[string]any{ + "head": map[string]any{"device_sn": sn, "timestamp": 1756813256}, + "payload": string(inner), + }) + if err != nil { + t.Fatal(err) + } + return mqtt.Message{Topic: "dt/anker_power/A5191/" + sn + "/param_info", Payload: outer} +} + +func TestParseEnvelopeUnwrapsTheDeviceFrame(t *testing.T) { + frame := buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)) + sn, data, ok := parseEnvelope(envelope(t, "SN123", frame)) + if !ok { + t.Fatal("a well-formed envelope was rejected") + } + if sn != "SN123" { + t.Errorf("serial = %q, want SN123", sn) + } + if string(data) != string(frame) { + t.Errorf("frame came back changed") + } +} + +// The serial is not always repeated in the payload; the topic carries it too, +// and a message we cannot attribute to a charger must be dropped rather than +// folded into some other charger's state. +func TestParseEnvelopeFallsBackToTheTopic(t *testing.T) { + inner, _ := json.Marshal(map[string]any{"data": base64.StdEncoding.EncodeToString([]byte{1, 2, 3})}) + outer, _ := json.Marshal(map[string]any{"payload": string(inner)}) + sn, _, ok := parseEnvelope(mqtt.Message{Topic: "dt/anker_power/A5191/SN999/param_info", Payload: outer}) + if !ok || sn != "SN999" { + t.Errorf("serial = %q (ok=%v), want SN999 from the topic", sn, ok) + } + + for _, bad := range []mqtt.Message{ + {Topic: "dt/a/b/SN/x", Payload: []byte("not json")}, + {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"not json"}`)}, + {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"device_sn\":\"SN\"}"}`)}, // no data + {Topic: "dt/a/b/SN/x", Payload: []byte(`{"payload":"{\"data\":\"!!not b64\"}"}`)}, // undecodable + {Topic: "short", Payload: []byte(`{"payload":"{\"data\":\"AQID\"}"}`)}, // no serial anywhere + } { + if _, _, ok := parseEnvelope(bad); ok { + t.Errorf("a malformed envelope was accepted: %s", bad.Payload) + } + } +} + +// A charger's state is assembled from two message types that arrive at different +// times: telemetry must not erase the settings that came with the last command, +// and vice versa. +func TestIngestMergesTelemetryAndSettings(t *testing.T) { + c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + + c.ingest(envelope(t, "SN1", buildInbound(t, msgEVParams, field(0xa8, typeInt16LE, 0x40, 0x01)))) + c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)))) + + values, telemetryAt, settingsAt, _ := c.snapshotOf("SN1") + if v, _ := values["maxCurrentSetA"].(float64); v != 32 { + t.Errorf("the settings value was lost when telemetry arrived: %v", values["maxCurrentSetA"]) + } + if v, _ := values["status"].(float64); v != 2 { + t.Errorf("status = %v, want 2", values["status"]) + } + if telemetryAt.IsZero() || settingsAt.IsZero() { + t.Errorf("both halves should be timestamped: telemetry %v, settings %v", telemetryAt, settingsAt) + } + if !telemetryAt.After(settingsAt) && !telemetryAt.Equal(settingsAt) { + t.Errorf("telemetry arrived second but is stamped earlier") + } + + // A message from a charger we have no map for leaves the state untouched. + c.ingest(mqtt.Message{Topic: "dt/a/b/SN1/x", Payload: []byte("rubbish")}) + after, _, _, _ := c.snapshotOf("SN1") + if len(after) != len(values) { + t.Errorf("an unreadable message changed the charger's state") + } +} + +func TestWaitForReturnsWhenTheStateArrives(t *testing.T) { + c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + go func() { + time.Sleep(20 * time.Millisecond) + c.ingest(envelope(t, "SN1", buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)))) + }() + ok, err := c.waitFor(context.Background(), "SN1", func(st *deviceState) bool { + return !st.telemetryAt.IsZero() + }, 2*time.Second) + if err != nil || !ok { + t.Fatalf("waitFor = %v, %v; want it to see the message", ok, err) + } +} + +func TestWaitForGivesUpAndReportsADeadConnection(t *testing.T) { + c := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + ok, err := c.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 30*time.Millisecond) + if err != nil || ok { + t.Errorf("waitFor = %v, %v; want a quiet timeout", ok, err) + } + + // A connection that dies while a caller is waiting must wake it with the + // reason rather than making it sit out the whole timeout. + c2 := &mqttConn{subs: map[string]bool{}, devices: map[string]*deviceState{}, shutdown: make(chan struct{})} + go func() { + time.Sleep(20 * time.Millisecond) + c2.fail(mqtt.ErrClosed) + }() + start := time.Now() + if _, err := c2.waitFor(context.Background(), "SN1", func(*deviceState) bool { return false }, 5*time.Second); err == nil { + t.Error("a dropped connection ended the wait without an error") + } + if time.Since(start) > time.Second { + t.Error("the waiter was not woken when the connection dropped") + } +} + +// A command that cannot be sent should be refused before it costs a sign-in, a +// certificate fetch and a broker connection — so the check runs on a plugin with +// no session at all. +func TestMqttCommandValidatesBeforeReachingTheCloud(t *testing.T) { + p := &Plugin{} + for _, tc := range []struct { + command string + amps float64 + want string + }{ + {"reboot", 0, "not a cloud command"}, + {"limit", 3, "below the charger's 6 A floor"}, + {"limit", 40, "outside the charger's range"}, + } { + _, err := p.mqttCommand(context.Background(), "SN1", tc.command, tc.amps) + if err == nil { + t.Errorf("%s(%v) was accepted", tc.command, tc.amps) + continue + } + if !strings.Contains(err.Error(), tc.want) { + t.Errorf("%s(%v) failed with %q, want it to mention %q", tc.command, tc.amps, err, tc.want) + } + } + + // A valid command gets past validation and fails on the missing session + // instead, which is what proves the order. + if _, err := p.mqttCommand(context.Background(), "SN1", "start", 0); err == nil || + !strings.Contains(err.Error(), "not initialised") { + t.Errorf("start failed with %v, want it to reach the session lookup", err) + } +} + +func TestMqttModeValuesMatchTheChargerEnum(t *testing.T) { + want := map[string]uint8{ + modeStartCharge: 1, modeStopCharge: 2, modeSkipDelay: 3, modeBoostCharge: 4, + } + for mode, v := range want { + if mqttModeValues[mode] != v { + t.Errorf("%s = %d, want %d", mode, mqttModeValues[mode], v) + } + } + // Every accepted command name must resolve to one of those modes, or the + // endpoint can offer a name the transport cannot send. + for name, mode := range mqttCommands { + if _, ok := mqttModeValues[mode]; !ok { + t.Errorf("command %q maps to %q, which is not a charger mode", name, mode) + } + } +} + +func TestClientIDDoesNotCollideWithTheApp(t *testing.T) { + creds := mqttCredentials{ThingName: "abc-anker_power"} + a, b := clientIDFor(creds), clientIDFor(creds) + if !strings.HasPrefix(a, "abc-anker_power_") { + t.Errorf("client id %q does not carry the account's thing name", a) + } + if a == b { + t.Error("two connections were given the same client id, which would evict each other") + } + // With no thing name the user id stands in, so the id is still account-scoped. + if got := clientIDFor(mqttCredentials{UserID: "u1"}); !strings.HasPrefix(got, "u1_") { + t.Errorf("client id %q does not fall back to the user id", got) + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/modbus.go b/API Server/internal/plugins/builtin/ankersolix/modbus.go index b1bd0a8..24ac72a 100644 --- a/API Server/internal/plugins/builtin/ankersolix/modbus.go +++ b/API Server/internal/plugins/builtin/ankersolix/modbus.go @@ -497,17 +497,27 @@ func ModbusStopCharging(ctx context.Context, c *modbus.Client) error { return c.WriteSingle(ctx, regChargingCommand, cmdStopCharging) } -// ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register -// carries deciamps, and anything below currentPauseFloor stops the charge -// outright rather than slowing it — so that case is refused here, and a caller -// that means to pause is asked to say so. -func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, amps float64) error { +// checkMaxCurrent validates a charging current ceiling in amps. The limit is the +// charger's, not the transport's, so the cloud path applies the same rule (see +// cloudmqtt.go): below currentPauseFloor the charger stops rather than charging +// slowly, which makes a lower limit a pause in disguise — so it is refused, and +// a caller that means to pause is asked to say so. +func checkMaxCurrent(amps float64) error { if amps > 0 && amps < currentPauseFloor { return fmt.Errorf("anker-solix: %.1f A is below the charger's %.0f A floor, which pauses charging; stop the session instead", amps, currentPauseFloor) } if amps < 0 || amps > 32 { return fmt.Errorf("anker-solix: %.1f A is outside the charger's range (%.0f-32 A)", amps, currentPauseFloor) } + return nil +} + +// ModbusSetMaxCurrent sets the charging current ceiling, in amps. The register +// carries deciamps. +func ModbusSetMaxCurrent(ctx context.Context, c *modbus.Client, amps float64) error { + if err := checkMaxCurrent(amps); err != nil { + return err + } return c.WriteSingle(ctx, regMaxCurrentSet, uint16(amps*10)) } diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go new file mode 100644 index 0000000..2cc9ecb --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe.go @@ -0,0 +1,472 @@ +package ankersolix + +// The wire format Anker's cloud carries between the mobile app and the charger. +// +// An MQTT message is JSON, but only as an envelope: the part that means anything +// is a base64 field inside it holding a binary frame the device itself speaks. +// That frame is the same one the app sends, so a command is not a documented API +// call but a byte layout, reproduced here from the message maps in +// anker-solix-api (src/anker_solix_api/mqtttypes.py, mqttmap.py, mqttcmdmap.py at +// v3.8.1) and checked against a live A5191. +// +// ff 09 2-byte marker on every Anker Solix frame +// xx xx total length in bytes, little endian, counting the checksum +// 03 00 0f fixed pattern; the middle byte is 00 outbound, 01 inbound +// xx xx message type — what the frame is, per device model +// [xx] an optional counter, present on some inbound frames +// one or more data fields +// xx XOR of every preceding byte +// +// and each data field is +// +// xx field name (a1, a2, … — the model's message map names it) +// xx length of everything that follows in this field +// [xx] value type, present when the field carries more than one byte +// xx … the value +// +// The frame has no version number and no field-type registry: which name means +// what depends on the message type, which is why the maps below are per message +// type rather than one table. + +import ( + "encoding/binary" + "fmt" + "math" + "strings" + "time" +) + +// Frame markers. patternSend is what the app puts in an outbound frame; a device +// answers with 03 01 0f, which is not checked — the message type is what selects +// a decoder. +var ( + frameMarker = []byte{0xff, 0x09} + patternSend = []byte{0x03, 0x00, 0x0f} +) + +// frameHeaderLen is the marker, length, pattern and message type — everything +// before the first data field, and before any inbound counter byte. +const frameHeaderLen = 9 + +// Value types. A field longer than one byte starts with one of these; a +// single-byte field carries its value directly with no type at all. +const ( + typeString byte = 0x00 + typeUint8 byte = 0x01 + typeInt16LE byte = 0x02 + typeInt32LE byte = 0x03 // "var": four bytes, though not always one value + typeFloat32 byte = 0x05 + typeNone byte = 0xff // this package's marker for "no type byte" +) + +// typeByteMax is the largest first-value-byte still read as a value type. Field +// names start at 0xa1 and value types stop at 0x06, so the gap is wide; the +// bound matches the reference implementation's rather than narrowing it, since a +// message type we have not seen may use a type we have not seen either. +const typeByteMax byte = 0x31 + +// Message types this package speaks, for the A5191 (V1 Smart EV Charger). +// Outbound ones are commands, inbound ones are what the charger publishes back. +const ( + msgRealtimeTrigger = "0057" // ask for the fast telemetry stream + msgEVSettings = "0100" // the settings group: current limit, brightness, … + msgEVMode = "0105" // start / stop / skip delay / boost + msgEVTelemetry = "0410" // fast telemetry, only while a trigger is live + msgEVParams = "0405" // settings and identity, sent after a command + msgEVParamsAlt = "0840" // the same fields, in answer to a status request + msgEVConfirm = "0900" // the same fields again, confirming a control change + msgEVCharging = "0403" // a couple of charging parameters +) + +// mqttField is one named value inside a device message. factor scales the raw +// integer (0.1 for a decivolt, 0.001 for a watt-hour reported in kWh); unsigned +// marks the fields whose two- and four-byte types are *not* signed, which is the +// exception rather than the rule; clock marks the two-byte fields that hold a +// minute and an hour rather than a number. +type mqttField struct { + name string + factor float64 + unsigned bool + clock bool +} + +// evTelemetry decodes the 0410 message: the charger's live electrical state, +// published every few seconds but only while a realtime trigger is live. It is +// where the two signals the Modbus map has no register for — the plug and start +// countdowns — actually come from. +var evTelemetry = map[byte]mqttField{ + 0xa2: {name: "voltageL1", factor: 0.1}, + 0xa3: {name: "voltageL2", factor: 0.1}, + 0xa4: {name: "voltageL3", factor: 0.1}, + 0xa5: {name: "currentL1", factor: 0.1}, + 0xa6: {name: "currentL2", factor: 0.1}, + 0xa7: {name: "currentL3", factor: 0.1}, + 0xa8: {name: "powerTotal"}, + 0xa9: {name: "sessionSeconds"}, + 0xaa: {name: "sessionWh"}, + 0xab: {name: "sessionStartedAt", unsigned: true}, + 0xad: {name: "plugCountdownSeconds"}, + 0xae: {name: "startCountdownSeconds"}, + 0xaf: {name: "chargingWindowSeconds"}, + 0xb0: {name: "powerL1"}, + 0xb1: {name: "powerL2"}, + 0xb2: {name: "powerL3"}, + 0xb3: {name: "sessionWhL1"}, + 0xb4: {name: "sessionWhL2"}, + 0xb5: {name: "sessionWhL3"}, + 0xb8: {name: "ocppStatus"}, + 0xba: {name: "phaseMode"}, + 0xbb: {name: "status"}, +} + +// evParams decodes the 0405 message (and the 0840 and 0900 that carry the same +// fields): what the charger is *set* to, plus its identity. The charger sends it +// after a control change rather than on a schedule, so these values arrive with +// a command rather than with the telemetry stream. +var evParams = map[byte]mqttField{ + 0xa3: {name: "plugLockSwitch"}, + 0xa4: {name: "autoStartSwitch"}, + 0xa8: {name: "maxCurrentSetA", factor: 0.1}, + 0xaa: {name: "ledBrightness"}, + 0xac: {name: "autoRestartSwitch"}, + 0xad: {name: "randomDelaySwitch"}, + 0xb2: {name: "smartTouchMode"}, + 0xb4: {name: "lightOffScheduleSwitch"}, + 0xb5: {name: "lightOffStart", unsigned: true, clock: true}, + 0xb6: {name: "lightOffEnd", unsigned: true, clock: true}, + 0xb7: {name: "modbusSwitch"}, + 0xcc: {name: "modbusTimeoutSeconds"}, + 0xce: {name: "maxCurrentA", factor: 0.1}, + 0xcf: {name: "modbusPort"}, + 0xd0: {name: "ipAddress"}, + 0xd3: {name: "loadBalancing"}, + 0xd4: {name: "mainBreakerLimitA"}, + 0xd8: {name: "solarBalancing"}, + 0xd9: {name: "chargingMode"}, + 0xda: {name: "solarMinCurrentA"}, + 0xdb: {name: "phaseMode"}, + 0xdd: {name: "autoPhaseSwitch"}, + 0xdf: {name: "boostMode"}, + 0xe0: {name: "cpSignal"}, + 0xe2: {name: "plugged"}, + 0xe3: {name: "status"}, + 0xe6: {name: "scheduleSwitch"}, + 0xe7: {name: "weekStart", unsigned: true, clock: true}, + 0xe8: {name: "weekEnd", unsigned: true, clock: true}, + 0xe9: {name: "weekendStart", unsigned: true, clock: true}, + 0xea: {name: "weekendEnd", unsigned: true, clock: true}, + 0xeb: {name: "weekendMode"}, + 0xec: {name: "scheduleMode"}, + 0xfe: {name: "minCurrentA"}, +} + +// evCharging decodes the 0403 message, a pair of charging parameters the charger +// sends around a mode change. +var evCharging = map[byte]mqttField{ + 0xa5: {name: "chargingWindowSeconds"}, + 0xa6: {name: "solarMinCurrentA"}, +} + +// evMessages selects a field map by message type. A type absent from here is one +// we have no map for; its frame is still parsed, but nothing is named. +var evMessages = map[string]map[byte]mqttField{ + msgEVTelemetry: evTelemetry, + msgEVParams: evParams, + msgEVParamsAlt: evParams, + msgEVConfirm: evParams, + msgEVCharging: evCharging, +} + +// ---- outbound frames --------------------------------------------------------- + +// cmdField is one field of a command frame. typ is typeNone for the single-byte +// fields that carry no value type. +type cmdField struct { + name byte + typ byte + value []byte +} + +// rawField builds a field with no value type — the `a1 01 22` pattern that opens +// every command. +func rawField(name byte, value ...byte) cmdField { + return cmdField{name: name, typ: typeNone, value: value} +} + +// uintField builds a one-byte unsigned field. +func uintField(name byte, v uint8) cmdField { + return cmdField{name: name, typ: typeUint8, value: []byte{v}} +} + +// intField builds a two-byte little-endian signed field. +func intField(name byte, v int16) cmdField { + b := make([]byte, 2) + binary.LittleEndian.PutUint16(b, uint16(v)) + return cmdField{name: name, typ: typeInt16LE, value: b} +} + +// varField builds a four-byte little-endian field. +func varField(name byte, v uint32) cmdField { + b := make([]byte, 4) + binary.LittleEndian.PutUint32(b, v) + return cmdField{name: name, typ: typeInt32LE, value: b} +} + +// timestampField is the `fe` field every command ends with: the sender's clock, +// in whole seconds. +func timestampField(now time.Time) cmdField { + return varField(0xfe, uint32(now.Unix())) +} + +// encodeFrame builds one command frame for a message type. The caller supplies +// every field in wire order, starting with the `a1 01 22` opener each command in +// the reference maps carries and ending with the timestamp. +func encodeFrame(msgType string, fields []cmdField) ([]byte, error) { + mt, err := decodeHex(msgType) + if err != nil || len(mt) < 2 || len(mt) > 3 { + return nil, fmt.Errorf("anker-solix: %q is not a message type", msgType) + } + + body := make([]byte, 0, 32) + for _, f := range fields { + n := len(f.value) + if f.typ != typeNone { + n++ + } + if n < 1 || n > 255 { + return nil, fmt.Errorf("anker-solix: field %02x does not fit one frame field", f.name) + } + body = append(body, f.name, byte(n)) + if f.typ != typeNone { + body = append(body, f.typ) + } + body = append(body, f.value...) + } + + // The length counts the whole frame, checksum byte included. + total := frameHeaderLen + (len(mt) - 2) + len(body) + 1 + out := make([]byte, 0, total) + out = append(out, frameMarker...) + out = binary.LittleEndian.AppendUint16(out, uint16(total)) + out = append(out, patternSend...) + out = append(out, mt...) + out = append(out, body...) + return append(out, xorChecksum(out)), nil +} + +// xorChecksum is the frame's only integrity check: every byte XORed together. +func xorChecksum(b []byte) byte { + var sum byte + for _, x := range b { + sum ^= x + } + return sum +} + +// ---- inbound frames ---------------------------------------------------------- + +// decodeFrame parses one device frame and returns its message type together with +// the values its field map names. A field the map does not know is skipped +// rather than guessed at, and a frame whose checksum does not add up is +// rejected: these arrive over a cloud connection we do not control, so a +// truncated one must not be read as a charger reporting zeros. +func decodeFrame(data []byte) (string, map[string]any, error) { + if len(data) < frameHeaderLen+2 { + return "", nil, fmt.Errorf("anker-solix: device frame is %d bytes, too short to hold a header", len(data)) + } + if data[0] != frameMarker[0] || data[1] != frameMarker[1] { + return "", nil, fmt.Errorf("anker-solix: device frame does not start with the Anker marker (%02x%02x)", data[0], data[1]) + } + if n := int(binary.LittleEndian.Uint16(data[2:4])); n != len(data) { + return "", nil, fmt.Errorf("anker-solix: device frame says it is %d bytes but %d arrived", n, len(data)) + } + if xorChecksum(data) != 0 { + return "", nil, fmt.Errorf("anker-solix: device frame checksum does not match") + } + + msgType := encodeHex(data[7:9]) + + // Some inbound frames carry a counter byte between the header and the first + // data field, and nothing in the frame says which kind this is. Rather than + // guess from the byte's value — field names run high enough to be mistaken for + // a counter — try both and keep the reading whose fields tile the frame + // exactly, from the first field to the checksum with nothing left over. + raw, ok := splitFields(data, frameHeaderLen) + if !ok { + if raw, ok = splitFields(data, frameHeaderLen+1); !ok { + return "", nil, fmt.Errorf("anker-solix: device frame %s does not divide into whole data fields", msgType) + } + } + + fields := evMessages[msgType] + values := map[string]any{} + for _, r := range raw { + f, known := fields[r.name] + if !known || f.name == "" { + continue + } + if v, ok := decodeValue(r.typ, r.value, f); ok { + values[f.name] = v + } + } + return msgType, values, nil +} + +// rawFieldBytes is one data field as it sat in the frame, before its map entry +// decides what it means. +type rawFieldBytes struct { + name byte + typ byte + value []byte +} + +// splitFields walks the data fields from start and reports them only if they end +// exactly at the checksum byte. A frame read from the wrong offset runs off the +// end or stops short, which is what makes the exact fit a usable test. +func splitFields(data []byte, start int) ([]rawFieldBytes, bool) { + end := len(data) - 1 // the last byte is the checksum + var out []rawFieldBytes + for idx := start; idx < end; { + if idx+2 > end { + return nil, false + } + name := data[idx] + flen := int(data[idx+1]) + if flen == 0 || idx+2+flen > end { + return nil, false + } + body := data[idx+2 : idx+2+flen] + idx += 2 + flen + + typ, value := typeNone, body + if flen > 1 && body[0] <= typeByteMax { + typ, value = body[0], body[1:] + } + out = append(out, rawFieldBytes{name: name, typ: typ, value: value}) + } + return out, len(out) > 0 +} + +// decodeValue turns one field's bytes into a value, following the type byte the +// field carries and the scaling its map entry gives. It reports false for a +// field whose bytes do not fit its type, so a short value is dropped rather than +// read as a smaller number. +func decodeValue(typ byte, b []byte, f mqttField) (any, bool) { + if len(b) == 0 { + return nil, false + } + factor := f.factor + if factor == 0 { + factor = 1 + } + scale := func(n int64) any { + if factor == 1 { + return float64(n) + } + return round(float64(n)*factor, factor) + } + + switch typ { + case typeString: + return printable(b), true + case typeUint8: + return scale(int64(b[0])), true + case typeInt16LE: + if len(b) < 2 { + return nil, false + } + if f.clock { + // Two bytes holding a minute and an hour, least significant first. + return fmt.Sprintf("%02d:%02d", b[1], b[0]), true + } + if f.unsigned { + return scale(int64(binary.LittleEndian.Uint16(b))), true + } + return scale(int64(int16(binary.LittleEndian.Uint16(b)))), true + case typeInt32LE: + if len(b) < 4 { + return nil, false + } + if f.unsigned { + return scale(int64(binary.LittleEndian.Uint32(b))), true + } + return scale(int64(int32(binary.LittleEndian.Uint32(b)))), true + case typeFloat32: + if len(b) < 4 { + return nil, false + } + return float64(math.Float32frombits(binary.LittleEndian.Uint32(b))), true + default: + // No value type: the bytes are the number, most significant first. + var n int64 + for _, x := range b { + n = n<<8 | int64(x) + } + return scale(n), true + } +} + +// round trims the floating-point noise a factor introduces, to the precision the +// factor itself implies — 0.1 keeps one decimal, 0.001 keeps three. +func round(v, factor float64) float64 { + digits := 0 + for f := factor; f < 1 && digits < 6; f *= 10 { + digits++ + } + p := math.Pow(10, float64(digits)) + return math.Round(v*p) / p +} + +// printable keeps the readable part of a string field; the charger pads some of +// them with control bytes. +func printable(b []byte) string { + var sb strings.Builder + for _, r := range string(b) { + if r >= 0x20 && r != 0x7f { + sb.WriteRune(r) + } + } + return strings.TrimSpace(sb.String()) +} + +// ---- small hex helpers ------------------------------------------------------- + +const hexDigits = "0123456789abcdef" + +// encodeHex renders bytes as lowercase hex, which is how message types are keyed. +func encodeHex(b []byte) string { + out := make([]byte, 0, len(b)*2) + for _, x := range b { + out = append(out, hexDigits[x>>4], hexDigits[x&0x0f]) + } + return string(out) +} + +// decodeHex parses a lowercase or uppercase hex string. +func decodeHex(s string) ([]byte, error) { + if len(s)%2 != 0 { + return nil, fmt.Errorf("hex string %q has an odd length", s) + } + out := make([]byte, 0, len(s)/2) + for i := 0; i < len(s); i += 2 { + hi, err1 := hexNibble(s[i]) + lo, err2 := hexNibble(s[i+1]) + if err1 != nil || err2 != nil { + return nil, fmt.Errorf("hex string %q holds a non-hex character", s) + } + out = append(out, hi<<4|lo) + } + return out, nil +} + +func hexNibble(c byte) (byte, error) { + switch { + case c >= '0' && c <= '9': + return c - '0', nil + case c >= 'a' && c <= 'f': + return c - 'a' + 10, nil + case c >= 'A' && c <= 'F': + return c - 'A' + 10, nil + } + return 0, fmt.Errorf("not a hex digit: %q", string(rune(c))) +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go new file mode 100644 index 0000000..c92481b --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttframe_test.go @@ -0,0 +1,263 @@ +package ankersolix + +import ( + "encoding/binary" + "strings" + "testing" + "time" +) + +// The realtime trigger frame is the one example the reference implementation +// documents byte for byte, so it is the anchor for the whole encoder: marker, +// little-endian length counting the checksum, send pattern, message type, then +// the fields in order. +func TestEncodeFrameMatchesTheDocumentedTrigger(t *testing.T) { + at := time.Unix(1756813256, 0) + got, err := encodeFrame(msgRealtimeTrigger, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, 1), + varField(0xa3, 300), + timestampField(at), + }) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + + want := "ff091f0003000f0057" + // header: marker, length 31, send pattern, type 0057 + "a10122" + // a1: one byte, no value type + "a2020101" + // a2: ui 1 — updates on + "a305032c010000" + // a3: var 300 — the window in seconds + "fe0503c8d7b668" // fe: var — the sender's clock + wantWithSum := want + "21" + if h := encodeHex(got); h != wantWithSum { + // Recompute the checksum in the message so a mismatch says which half broke. + body, _ := decodeHex(want) + t.Fatalf("frame = %s\nwant %s (checksum over the body is %02x)", h, wantWithSum, xorChecksum(body)) + } + if len(got) != 31 { + t.Errorf("frame is %d bytes, want 31", len(got)) + } + if binary.LittleEndian.Uint16(got[2:4]) != uint16(len(got)) { + t.Errorf("header length %d does not match the frame's %d bytes", binary.LittleEndian.Uint16(got[2:4]), len(got)) + } +} + +// A frame is only self-consistent if XORing every byte, checksum included, +// comes to zero — which is exactly what the decoder checks. +func TestEncodeFrameChecksumClosesToZero(t *testing.T) { + frame, err := encodeFrame(msgEVMode, []cmdField{ + rawField(0xa1, 0x22), + uintField(0xa2, mqttModeValues[modeStartCharge]), + timestampField(time.Unix(1756813256, 0)), + }) + if err != nil { + t.Fatalf("encodeFrame: %v", err) + } + if sum := xorChecksum(frame); sum != 0 { + t.Errorf("XOR over the whole frame = %02x, want 00", sum) + } +} + +func TestEncodeFrameRejectsBadMessageType(t *testing.T) { + for _, mt := range []string{"", "01", "zz01", "01020304"} { + if _, err := encodeFrame(mt, []cmdField{rawField(0xa1, 0x22)}); err == nil { + t.Errorf("encodeFrame(%q) accepted a message type it should not", mt) + } + } +} + +// buildInbound assembles a device-style frame the way the charger sends one: +// the receive pattern, and no counter byte before the first field. +func buildInbound(t *testing.T, msgType string, body []byte) []byte { + t.Helper() + mt, err := decodeHex(msgType) + if err != nil { + t.Fatalf("bad message type %q: %v", msgType, err) + } + out := append([]byte{}, frameMarker...) + out = binary.LittleEndian.AppendUint16(out, uint16(frameHeaderLen+len(body)+1)) + out = append(out, 0x03, 0x01, 0x0f) + out = append(out, mt...) + out = append(out, body...) + return append(out, xorChecksum(out)) +} + +func field(name, typ byte, value ...byte) []byte { + return append([]byte{name, byte(len(value) + 1), typ}, value...) +} + +func TestDecodeFrameReadsTelemetry(t *testing.T) { + var body []byte + body = append(body, field(0xa2, typeInt16LE, 0xfd, 0x08)...) // 2301 -> 230.1 V + body = append(body, field(0xa5, typeInt16LE, 0xa0, 0x00)...) // 160 -> 16.0 A + body = append(body, field(0xa8, typeInt32LE, 0x60, 0x0e, 0x00, 0x00)...) + body = append(body, field(0xa9, typeInt32LE, 0x8d, 0x0e, 0x00, 0x00)...) + body = append(body, field(0xaa, typeInt32LE, 0xd4, 0x30, 0x00, 0x00)...) + body = append(body, field(0xae, typeInt32LE, 0x2d, 0x00, 0x00, 0x00)...) + body = append(body, []byte{0xbb, 0x01, 0x02}...) // single byte, no value type + body = append(body, field(0xb8, typeUint8, 0x02)...) + + msgType, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body)) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if msgType != msgEVTelemetry { + t.Errorf("message type = %s, want %s", msgType, msgEVTelemetry) + } + want := map[string]float64{ + "voltageL1": 230.1, + "currentL1": 16, + "powerTotal": 3680, + "sessionSeconds": 3725, + "sessionWh": 12500, + "startCountdownSeconds": 45, + "status": 2, + "ocppStatus": 2, + } + for k, v := range want { + got, ok := values[k].(float64) + if !ok { + t.Errorf("%s missing from the decoded values (%v)", k, values[k]) + continue + } + if got != v { + t.Errorf("%s = %v, want %v", k, got, v) + } + } +} + +// The settings message carries the charger's own view of its LAN side and its +// schedule, which are two- and four-byte fields read differently from the +// telemetry's: a clock field is a minute and an hour, not a number. +func TestDecodeFrameReadsSettings(t *testing.T) { + var body []byte + body = append(body, field(0xa8, typeInt16LE, 0x40, 0x01)...) // 320 -> 32.0 A + body = append(body, field(0xb7, typeUint8, 0x01)...) // Modbus TCP on + body = append(body, field(0xcf, typeInt16LE, 0xf6, 0x01)...) // port 502 + body = append(body, field(0xd0, typeString, []byte("192.168.1.44")...)...) + body = append(body, field(0xe7, typeInt16LE, 0x00, 0x16)...) // 22:00 + body = append(body, field(0xdf, typeUint8, 0x01)...) // boost running + + _, values, err := decodeFrame(buildInbound(t, msgEVParams, body)) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if v, _ := values["maxCurrentSetA"].(float64); v != 32 { + t.Errorf("maxCurrentSetA = %v, want 32", values["maxCurrentSetA"]) + } + if v, _ := values["modbusPort"].(float64); v != 502 { + t.Errorf("modbusPort = %v, want 502", values["modbusPort"]) + } + if v, _ := values["ipAddress"].(string); v != "192.168.1.44" { + t.Errorf("ipAddress = %q, want 192.168.1.44", values["ipAddress"]) + } + // The two bytes are minute then hour, so reading them as a plain little-endian + // number would give 5632 rather than a time of day. + if v, _ := values["weekStart"].(string); v != "22:00" { + t.Errorf("weekStart = %q, want 22:00", values["weekStart"]) + } + if v, _ := values["boostMode"].(float64); v != 1 { + t.Errorf("boostMode = %v, want 1", values["boostMode"]) + } +} + +// A frame reaches us over a cloud connection we do not control, so a truncated +// or corrupted one must be refused rather than read as a charger reporting +// zeros — which would silently show a charging car as idle. +func TestDecodeFrameRejectsDamagedFrames(t *testing.T) { + good := buildInbound(t, msgEVTelemetry, field(0xbb, typeUint8, 0x02)) + + corrupt := append([]byte{}, good...) + corrupt[len(corrupt)-2] ^= 0xff + if _, _, err := decodeFrame(corrupt); err == nil { + t.Error("a frame with a flipped value byte passed the checksum") + } + + truncated := append([]byte{}, good[:len(good)-3]...) + if _, _, err := decodeFrame(truncated); err == nil { + t.Error("a truncated frame was accepted") + } + + wrongMarker := append([]byte{}, good...) + wrongMarker[0] = 0xfe + if _, _, err := decodeFrame(wrongMarker); err == nil { + t.Error("a frame without the Anker marker was accepted") + } + + if _, _, err := decodeFrame([]byte{0xff, 0x09}); err == nil { + t.Error("a frame too short to hold a header was accepted") + } +} + +// Some inbound frames carry a counter byte between the header and the first +// field; skipping it wrongly would shift every field name by one. +func TestDecodeFrameSkipsTheCounterByte(t *testing.T) { + body := append([]byte{0x07}, field(0xbb, typeUint8, 0x05)...) + _, values, err := decodeFrame(buildInbound(t, msgEVTelemetry, body)) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if v, _ := values["status"].(float64); v != 5 { + t.Errorf("status = %v, want 5 (the counter byte was not skipped)", values["status"]) + } +} + +// A message type we have no map for still has to parse, so an unknown frame is +// an empty answer rather than an error that hides the ones we can read. +func TestDecodeFrameOfAnUnmappedTypeIsEmpty(t *testing.T) { + _, values, err := decodeFrame(buildInbound(t, "0400", field(0xa2, typeUint8, 0x01))) + if err != nil { + t.Fatalf("decodeFrame: %v", err) + } + if len(values) != 0 { + t.Errorf("values = %v, want none for an unmapped message type", values) + } +} + +func TestDecodeValueSignsAndScales(t *testing.T) { + // Two's-complement over two bytes: a relay reading below zero must stay below + // zero rather than wrapping to 6553.5. + v, ok := decodeValue(typeInt16LE, []byte{0xf6, 0xff}, mqttField{name: "x", factor: 0.1}) + if !ok || v.(float64) != -1 { + t.Errorf("signed 2-byte value = %v (ok=%v), want -1", v, ok) + } + // The same bytes read unsigned are a large positive number, which is what the + // fields marked unsigned actually mean. + v, ok = decodeValue(typeInt16LE, []byte{0xf6, 0xff}, mqttField{name: "x", unsigned: true}) + if !ok || v.(float64) != 65526 { + t.Errorf("unsigned 2-byte value = %v (ok=%v), want 65526", v, ok) + } + // A value shorter than its type is dropped rather than read as a smaller one. + if _, ok := decodeValue(typeInt32LE, []byte{0x01, 0x02}, mqttField{name: "x"}); ok { + t.Error("a 2-byte value was accepted for a 4-byte type") + } + // Scaling must not leave floating-point dust behind. + v, _ = decodeValue(typeInt32LE, []byte{0xd4, 0x30, 0x00, 0x00}, mqttField{name: "x", factor: 0.001}) + if v.(float64) != 12.5 { + t.Errorf("scaled value = %v, want 12.5", v) + } +} + +func TestPrintableKeepsOnlyReadableText(t *testing.T) { + if got := printable([]byte("192.168.1.44\x00\x00")); got != "192.168.1.44" { + t.Errorf("printable = %q, want %q", got, "192.168.1.44") + } +} + +func TestDecodeHexRejectsRubbish(t *testing.T) { + for _, s := range []string{"abc", "zz", "00 11"} { + if _, err := decodeHex(s); err == nil { + t.Errorf("decodeHex(%q) accepted a non-hex string", s) + } + } + b, err := decodeHex("FF09") + if err != nil || len(b) != 2 || b[0] != 0xff || b[1] != 0x09 { + t.Errorf("decodeHex(%q) = %v, %v", "FF09", b, err) + } + if s := encodeHex([]byte{0xff, 0x09}); s != "ff09" { + t.Errorf("encodeHex = %q, want ff09", s) + } + if strings.ToUpper(encodeHex([]byte{0xab})) != "AB" { + t.Errorf("encodeHex is not lowercase hex") + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go new file mode 100644 index 0000000..1c5e062 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot.go @@ -0,0 +1,371 @@ +package ankersolix + +// What a charger reports over the cloud, and the two capabilities built on it. +// +// The snapshot below deliberately borrows the field names ModbusSnapshot uses +// for the same quantities — status, voltageL1, powerTotal, sessionWh, settings — +// because they are the same charger read two ways, and a view that can render +// one should not need a second layout for the other. Where the transports differ +// the names differ with them: the cloud carries the boost flag and the plug and +// start countdowns, which no register holds, while the register map carries the +// relay temperatures and the reactive and apparent power, which no cloud message +// sends. + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" +) + +// MqttSnapshot is one charger's state as the cloud reports it. A field the +// charger has not sent stays nil rather than zero, so "not reported" and "zero" +// stay distinguishable. +type MqttSnapshot struct { + Serial string `json:"serial"` + Model string `json:"model,omitempty"` + + Status *int `json:"status,omitempty"` + StatusDesc string `json:"statusDesc,omitempty"` + + // Mode is the operational mode the charger is effectively in and ModeOptions + // the ones it can be moved to, derived exactly as the cloud view derives + // them — except that here the boost flag and the countdowns they depend on + // are actually available. + Mode string `json:"mode,omitempty"` + ModeOptions []string `json:"modeOptions,omitempty"` + + VoltageL1 *float64 `json:"voltageL1,omitempty"` + VoltageL2 *float64 `json:"voltageL2,omitempty"` + VoltageL3 *float64 `json:"voltageL3,omitempty"` + CurrentL1 *float64 `json:"currentL1,omitempty"` + CurrentL2 *float64 `json:"currentL2,omitempty"` + CurrentL3 *float64 `json:"currentL3,omitempty"` + + PowerL1 *float64 `json:"powerL1,omitempty"` + PowerL2 *float64 `json:"powerL2,omitempty"` + PowerL3 *float64 `json:"powerL3,omitempty"` + PowerTotal *float64 `json:"powerTotal,omitempty"` + + SessionSeconds *float64 `json:"sessionSeconds,omitempty"` + SessionWh *float64 `json:"sessionWh,omitempty"` + + // The countdowns the charger runs before a session: how long it will wait for + // a plug, and how long a start delay still has to go. They are why a charger + // that has been told to start can sit in "preparing" without being broken. + PlugCountdownSeconds *float64 `json:"plugCountdownSeconds,omitempty"` + StartCountdownSeconds *float64 `json:"startCountdownSeconds,omitempty"` + ChargingWindowSeconds *float64 `json:"chargingWindowSeconds,omitempty"` + + PhaseMode *int `json:"phaseMode,omitempty"` + ChargingMode *int `json:"chargingMode,omitempty"` + BoostMode *bool `json:"boostMode,omitempty"` + Plugged *bool `json:"plugged,omitempty"` + + CPSignal *int `json:"cpSignal,omitempty"` + CPSignalDesc string `json:"cpSignalDesc,omitempty"` + + OcppStatus *int `json:"ocppStatus,omitempty"` + OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` + + LoadBalancing *bool `json:"loadBalancing,omitempty"` + SolarBalancing *bool `json:"solarBalancing,omitempty"` + LEDBrightness *int `json:"ledBrightness,omitempty"` + MinCurrentA *float64 `json:"minCurrentA,omitempty"` + MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` + + Settings *MqttSettings `json:"settings,omitempty"` + + // Local reports what the charger says about its own LAN side: whether Modbus + // TCP is switched on, and at which address. It is the one answer the Modbus + // mode's setup screen otherwise has to be given by hand. + Local *MqttLocalAccess `json:"local,omitempty"` + + // TelemetryAt and SettingsAt are when each half of the snapshot last arrived; + // Live says the fast stream is currently flowing. + TelemetryAt string `json:"telemetryAt,omitempty"` + SettingsAt string `json:"settingsAt,omitempty"` + Live bool `json:"live"` +} + +// MqttSettings is what the charger is set to, as opposed to what it is doing — +// the same distinction ModbusSettings draws over the register map. +type MqttSettings struct { + MaxCurrentA *float64 `json:"maxCurrentA,omitempty"` + AutoStart *bool `json:"autoStart,omitempty"` + AutoRestart *bool `json:"autoRestart,omitempty"` + RandomDelay *bool `json:"randomDelay,omitempty"` + PlugLock *bool `json:"plugLock,omitempty"` + ScheduleEnabled *bool `json:"scheduleEnabled,omitempty"` + WeekStart string `json:"weekStart,omitempty"` + WeekEnd string `json:"weekEnd,omitempty"` + WeekendStart string `json:"weekendStart,omitempty"` + WeekendEnd string `json:"weekendEnd,omitempty"` + MainBreakerLimitA *float64 `json:"mainBreakerLimitA,omitempty"` + SolarMinCurrentA *float64 `json:"solarMinCurrentA,omitempty"` + AutoPhaseSwitching *bool `json:"autoPhaseSwitching,omitempty"` +} + +// MqttLocalAccess is the charger's own view of its Modbus TCP server. +type MqttLocalAccess struct { + ModbusEnabled *bool `json:"modbusEnabled,omitempty"` + Host string `json:"host,omitempty"` + Port *int `json:"port,omitempty"` + TimeoutSeconds *int `json:"timeoutSeconds,omitempty"` +} + +// ---- the capabilities -------------------------------------------------------- + +// mqttStatus reads one charger's state over the cloud. The charger publishes +// nothing unless asked, so this arms the telemetry trigger and waits for the +// next frame; inside an already-armed window the frame that has since arrived +// answers immediately. +func (p *Plugin) mqttStatus(ctx context.Context, sn string) (json.RawMessage, error) { + model, err := p.chargerModel(ctx, sn) + if err != nil { + return nil, err + } + conn, err := p.mqttClient(ctx) + if err != nil { + return nil, err + } + if err := conn.listen(ctx, model, sn); err != nil { + return nil, err + } + + // Re-arm whenever the window is spent or close to it, so a poll never lands + // in the gap between the last frame and the trigger expiring. + _, _, _, triggered := conn.snapshotOf(sn) + if time.Until(triggered) < triggerRenew { + if err := p.mqttTrigger(ctx, conn, model, sn, triggerWindow); err != nil { + return nil, err + } + } + + // Anything older than the trigger's own interval is stale; wait for the next. + cutoff := time.Now().Add(-triggerRenew) + live, err := conn.waitFor(ctx, sn, func(st *deviceState) bool { + return st.telemetryAt.After(cutoff) + }, statusWait) + if err != nil { + return nil, err + } + + values, telemetryAt, settingsAt, _ := conn.snapshotOf(sn) + if len(values) == 0 { + return nil, fmt.Errorf("anker-solix: charger %s did not answer over the cloud; it may be offline", sn) + } + snap := projectMqttSnapshot(sn, model, values) + snap.Live = live + if !telemetryAt.IsZero() { + snap.TelemetryAt = telemetryAt.UTC().Format(time.RFC3339) + } + if !settingsAt.IsZero() { + snap.SettingsAt = settingsAt.UTC().Format(time.RFC3339) + } + return json.Marshal(snap) +} + +// mqttCommandDoc is what a cloud command answers with. Confirmed says the +// charger sent a message back within commandWait: publishing is fire-and-forget, +// so an unconfirmed command is not a failed one — it is one whose effect has not +// been seen yet. +type mqttCommandDoc struct { + Serial string `json:"serial"` + Command string `json:"command"` + Status string `json:"status"` + Confirmed bool `json:"confirmed"` + Detail string `json:"detail,omitempty"` +} + +// mqttCommands maps the names this transport accepts to the charger mode each +// one asks for. The short names are what the control endpoint sends; the long +// ones are the mode names the snapshot reports in modeOptions, so a caller can +// send back what it was offered. +var mqttCommands = map[string]string{ + "start": modeStartCharge, + modeStartCharge: modeStartCharge, + "stop": modeStopCharge, + modeStopCharge: modeStopCharge, + "boost": modeBoostCharge, + modeBoostCharge: modeBoostCharge, + "skip-delay": modeSkipDelay, + modeSkipDelay: modeSkipDelay, +} + +// mqttCommand issues one control command over the cloud. +func (p *Plugin) mqttCommand(ctx context.Context, sn, command string, amps float64) (json.RawMessage, error) { + // Validate before touching the cloud: a mistyped command should not cost a + // sign-in, a broker connection and a certificate fetch to be told no. + command = strings.ToLower(strings.TrimSpace(command)) + mode, isMode := mqttCommands[command] + switch { + case isMode: + case command == "limit": + if err := checkMaxCurrent(amps); err != nil { + return nil, err + } + case command == "trigger": + default: + return nil, fmt.Errorf("anker-solix: %q is not a cloud command (start, stop, boost, skip-delay, limit, trigger)", command) + } + + model, err := p.chargerModel(ctx, sn) + if err != nil { + return nil, err + } + conn, err := p.mqttClient(ctx) + if err != nil { + return nil, err + } + // Listen before commanding: the charger confirms a control change with a + // message, and a subscription made afterwards would miss it. + if err := conn.listen(ctx, model, sn); err != nil { + return nil, err + } + _, _, before, _ := conn.snapshotOf(sn) + + switch { + case isMode: + err = p.mqttSetMode(ctx, conn, model, sn, mode) + case command == "limit": + err = p.mqttSetMaxCurrent(ctx, conn, model, sn, amps) + default: + err = p.mqttTrigger(ctx, conn, model, sn, triggerWindow) + } + if err != nil { + return nil, err + } + + // The charger answers a control change with a settings message. Waiting for + // it turns "published" into "the charger has it". + confirmed, waitErr := conn.waitFor(ctx, sn, func(st *deviceState) bool { + return st.settingsAt.After(before) + }, commandWait) + doc := mqttCommandDoc{Serial: sn, Command: command, Status: "accepted", Confirmed: confirmed} + if waitErr != nil { + // The command left; only the confirmation did not. Say so rather than + // reporting a failure the charger may well have acted on. + doc.Detail = "sent, but the cloud connection dropped before the charger confirmed it" + } else if !confirmed { + doc.Detail = "sent; the charger has not confirmed it yet" + } + return json.Marshal(doc) +} + +// ---- projection -------------------------------------------------------------- + +// projectMqttSnapshot turns the named values collected from a charger's messages +// into the snapshot. Every read is by name and optional: a message type we have +// not seen simply leaves its fields unset. +func projectMqttSnapshot(sn, model string, v map[string]any) MqttSnapshot { + snap := MqttSnapshot{Serial: sn, Model: model} + + num := func(key string) *float64 { + f, ok := v[key].(float64) + if !ok { + return nil + } + return &f + } + whole := func(key string) *int { + f, ok := v[key].(float64) + if !ok { + return nil + } + n := int(f) + return &n + } + flag := func(key string) *bool { + f, ok := v[key].(float64) + if !ok { + return nil + } + b := f != 0 + return &b + } + text := func(key string) string { + s, _ := v[key].(string) + return strings.TrimSpace(s) + } + + snap.VoltageL1, snap.VoltageL2, snap.VoltageL3 = num("voltageL1"), num("voltageL2"), num("voltageL3") + snap.CurrentL1, snap.CurrentL2, snap.CurrentL3 = num("currentL1"), num("currentL2"), num("currentL3") + snap.PowerL1, snap.PowerL2, snap.PowerL3 = num("powerL1"), num("powerL2"), num("powerL3") + snap.PowerTotal = num("powerTotal") + snap.SessionSeconds, snap.SessionWh = num("sessionSeconds"), num("sessionWh") + snap.PlugCountdownSeconds = num("plugCountdownSeconds") + snap.StartCountdownSeconds = num("startCountdownSeconds") + snap.ChargingWindowSeconds = num("chargingWindowSeconds") + snap.PhaseMode, snap.ChargingMode = whole("phaseMode"), whole("chargingMode") + snap.BoostMode, snap.Plugged = flag("boostMode"), flag("plugged") + snap.LoadBalancing, snap.SolarBalancing = flag("loadBalancing"), flag("solarBalancing") + snap.LEDBrightness = whole("ledBrightness") + snap.MinCurrentA, snap.MaxCurrentA = num("minCurrentA"), num("maxCurrentA") + + if s := whole("status"); s != nil { + snap.Status, snap.StatusDesc = s, statusName(*s) + } + if s := whole("ocppStatus"); s != nil { + snap.OcppStatus, snap.OcppStatusDesc = s, ocppStatusNames[*s] + } + if s := whole("cpSignal"); s != nil { + snap.CPSignal, snap.CPSignalDesc = s, cpSignalNames[*s] + } + + // The mode the cloud view can only guess at, with the two countdowns and the + // boost flag it never sees. + if snap.StatusDesc != "" { + boost := snap.BoostMode != nil && *snap.BoostMode + snap.Mode = chargerMode(snap.StatusDesc, boost, intOrZero(snap.PlugCountdownSeconds), intOrZero(snap.StartCountdownSeconds)) + snap.ModeOptions = chargerModeOptions(snap.Mode, snap.StatusDesc) + } + + set := &MqttSettings{ + MaxCurrentA: num("maxCurrentSetA"), + AutoStart: flag("autoStartSwitch"), + AutoRestart: flag("autoRestartSwitch"), + RandomDelay: flag("randomDelaySwitch"), + MainBreakerLimitA: num("mainBreakerLimitA"), + SolarMinCurrentA: num("solarMinCurrentA"), + AutoPhaseSwitching: flag("autoPhaseSwitch"), + WeekStart: text("weekStart"), + WeekEnd: text("weekEnd"), + WeekendStart: text("weekendStart"), + WeekendEnd: text("weekendEnd"), + } + // Both of these read 1 for on and 2 for off, which is the charger's own + // convention on these two registers and nowhere else. + if s := whole("plugLockSwitch"); s != nil { + b := *s == 1 + set.PlugLock = &b + } + if s := whole("scheduleSwitch"); s != nil { + b := *s == 1 + set.ScheduleEnabled = &b + } + if *set != (MqttSettings{}) { + snap.Settings = set + } + + local := &MqttLocalAccess{ + ModbusEnabled: flag("modbusSwitch"), + Host: text("ipAddress"), + Port: whole("modbusPort"), + TimeoutSeconds: whole("modbusTimeoutSeconds"), + } + if *local != (MqttLocalAccess{}) { + snap.Local = local + } + return snap +} + +// intOrZero reads an optional number as an int, treating "not reported" as zero +// — which is what the mode derivation means by a countdown that is not running. +func intOrZero(v *float64) int { + if v == nil { + return 0 + } + return int(*v) +} diff --git a/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go new file mode 100644 index 0000000..3baa502 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/mqttsnapshot_test.go @@ -0,0 +1,146 @@ +package ankersolix + +import ( + "slices" + "testing" +) + +func TestProjectSnapshotNamesTheChargerState(t *testing.T) { + snap := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "voltageL1": 230.1, + "currentL1": 16.0, + "powerTotal": 3680.0, + "sessionSeconds": 3725.0, + "sessionWh": 12500.0, + "status": 2.0, + "ocppStatus": 2.0, + "cpSignal": 5.0, + "phaseMode": 1.0, + }) + + if snap.Serial != "SN1" || snap.Model != "A5191" { + t.Errorf("snapshot identifies %s/%s", snap.Serial, snap.Model) + } + if snap.StatusDesc != stateCharging { + t.Errorf("statusDesc = %q, want %q", snap.StatusDesc, stateCharging) + } + if snap.OcppStatusDesc != "connected" { + t.Errorf("ocppStatusDesc = %q, want connected", snap.OcppStatusDesc) + } + // The control-pilot names are the charger's own, shared with the Modbus map. + if snap.CPSignalDesc != cpSignalNames[5] { + t.Errorf("cpSignalDesc = %q, want %q", snap.CPSignalDesc, cpSignalNames[5]) + } + if snap.VoltageL1 == nil || *snap.VoltageL1 != 230.1 { + t.Errorf("voltageL1 = %v", snap.VoltageL1) + } + // A quantity the charger did not send stays nil, so a view can tell "not + // reported" from "zero" — an unplugged charger really does read 0 A. + if snap.VoltageL2 != nil { + t.Errorf("voltageL2 = %v, want nil for a value that was not sent", *snap.VoltageL2) + } + if snap.Settings != nil { + t.Errorf("settings = %+v, want none until a settings message arrives", snap.Settings) + } + if snap.Local != nil { + t.Errorf("local = %+v, want none until a settings message arrives", snap.Local) + } +} + +// The mode is the one thing the cloud REST view can only approximate: it has no +// boost flag and no countdowns, so a charger waiting out a start delay reads to +// it as simply started. Over MQTT those fields exist, and the mode must use them. +func TestProjectSnapshotDerivesModeFromTheMqttOnlySignals(t *testing.T) { + waiting := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "status": 1.0, // preparing + "startCountdownSeconds": 45.0, + }) + if waiting.Mode != modeWaitStart { + t.Errorf("mode = %q, want %q while a start delay is running", waiting.Mode, modeWaitStart) + } + if !slices.Contains(waiting.ModeOptions, modeSkipDelay) { + t.Errorf("modeOptions = %v, want the delay to be skippable", waiting.ModeOptions) + } + + plugging := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "status": 1.0, + "plugCountdownSeconds": 60.0, + }) + if plugging.Mode != modeWaitPlug { + t.Errorf("mode = %q, want %q while it waits for a plug", plugging.Mode, modeWaitPlug) + } + + boosting := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "status": 2.0, // charging + "boostMode": 1.0, + }) + if boosting.Mode != modeBoostCharge { + t.Errorf("mode = %q, want %q while boost is running", boosting.Mode, modeBoostCharge) + } + + idle := projectMqttSnapshot("SN1", "A5191", map[string]any{"status": 0.0}) + if idle.Mode != modeStopCharge { + t.Errorf("mode = %q, want %q in standby", idle.Mode, modeStopCharge) + } + if !slices.Contains(idle.ModeOptions, modeStartCharge) { + t.Errorf("modeOptions = %v, want a standby charger to be startable", idle.ModeOptions) + } +} + +// Two of the charger's settings read 1 for on and 2 for off, which is the +// opposite of every other flag it sends: read as booleans they would both come +// back on. +func TestProjectSnapshotHandlesTheInvertedSwitches(t *testing.T) { + off := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "plugLockSwitch": 2.0, + "scheduleSwitch": 2.0, + }) + if off.Settings == nil { + t.Fatal("settings missing") + } + if off.Settings.PlugLock == nil || *off.Settings.PlugLock { + t.Errorf("plugLock = %v, want off for the charger's value 2", off.Settings.PlugLock) + } + if off.Settings.ScheduleEnabled == nil || *off.Settings.ScheduleEnabled { + t.Errorf("scheduleEnabled = %v, want off for the charger's value 2", off.Settings.ScheduleEnabled) + } + + on := projectMqttSnapshot("SN1", "A5191", map[string]any{"plugLockSwitch": 1.0}) + if on.Settings == nil || on.Settings.PlugLock == nil || !*on.Settings.PlugLock { + t.Errorf("plugLock = %v, want on for the charger's value 1", on.Settings) + } +} + +// The settings message carries the charger's own view of its LAN side, which is +// the address the Modbus mode otherwise has to be told by hand. +func TestProjectSnapshotReportsLocalAccess(t *testing.T) { + snap := projectMqttSnapshot("SN1", "A5191", map[string]any{ + "modbusSwitch": 1.0, + "ipAddress": "192.168.1.44", + "modbusPort": 502.0, + "modbusTimeoutSeconds": 60.0, + "maxCurrentSetA": 32.0, + }) + if snap.Local == nil { + t.Fatal("local access missing") + } + if snap.Local.ModbusEnabled == nil || !*snap.Local.ModbusEnabled { + t.Errorf("modbusEnabled = %v, want on", snap.Local.ModbusEnabled) + } + if snap.Local.Host != "192.168.1.44" { + t.Errorf("host = %q", snap.Local.Host) + } + if snap.Local.Port == nil || *snap.Local.Port != 502 { + t.Errorf("port = %v, want 502", snap.Local.Port) + } + if snap.Settings == nil || snap.Settings.MaxCurrentA == nil || *snap.Settings.MaxCurrentA != 32 { + t.Errorf("settings.maxCurrentA = %+v, want 32", snap.Settings) + } +} + +func TestProjectSnapshotOfNothingIsEmpty(t *testing.T) { + snap := projectMqttSnapshot("SN1", "A5191", map[string]any{}) + if snap.Status != nil || snap.Mode != "" || snap.Settings != nil || snap.Local != nil { + t.Errorf("an empty message set produced state: %+v", snap) + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/session.go b/API Server/internal/plugins/builtin/ankersolix/session.go index a89578d..90d3c81 100644 --- a/API Server/internal/plugins/builtin/ankersolix/session.go +++ b/API Server/internal/plugins/builtin/ankersolix/session.go @@ -25,8 +25,9 @@ import ( "time" ) -// session is one account's cloud state: the token it holds, and how long to -// leave its login alone after a refusal. +// session is one account's cloud state: the token it holds, how long to leave +// its login alone after a refusal, and the broker connection its chargers are +// commanded over. type session struct { mu sync.Mutex // guards tok and the backoff below, and serialises the login exchange tok *tokenInfo @@ -35,6 +36,17 @@ type session struct { loginRetryAt time.Time loginFails int + // The cloud MQTT half (cloudmqtt.go), under its own lock: a broker connection + // takes a TLS handshake and a fetched certificate to open, so it outlives the + // plugin instance for the same reason the token does. mqttMu guards all four + // fields and is never held while mu is. + mqttMu sync.Mutex + mqttCreds *mqttCredentials + mqttCredsAt time.Time + mqttConn *mqttConn + devices map[string]string // charger serial -> product code, from the account's inventory + devicesAt time.Time + // lastUse is touched and read under sessionsMu, never under mu. lastUse time.Time } @@ -80,11 +92,25 @@ func pruneSessionsLocked() { cutoff := time.Now().Add(-sessionIdle) for k, s := range sessions { if s.lastUse.Before(cutoff) { + // A pruned session must not leave its broker connection open; nothing + // else holds a reference to close it afterwards. + s.closeMqtt() delete(sessions, k) } } } +// closeMqtt drops the session's broker connection, if it holds one. +func (s *session) closeMqtt() { + s.mqttMu.Lock() + c := s.mqttConn + s.mqttConn = nil + s.mqttMu.Unlock() + if c != nil { + c.close() + } +} + // noteLogin records the outcome of a login attempt and, on failure, how long to // leave the account alone: one Anker has already disabled gets the full penalty, // anything else backs off exponentially up to loginRetryMax. Caller holds s.mu. diff --git a/Phone App/assets/i18n/da.json b/Phone App/assets/i18n/da.json index 12b7df2..3b98a07 100644 --- a/Phone App/assets/i18n/da.json +++ b/Phone App/assets/i18n/da.json @@ -177,6 +177,11 @@ "lineToLine": "Mellem faser", "power": "Samlet effekt", "sessionDuration": "Sessionens længde", + "mode": "Tilstand", + "plugCountdown": "Venter på stik", + "startCountdown": "Starter om", + "autoStart": "Start automatisk", + "scheduleWindow": "Ladevindue", "cpSignal": "Control pilot", "cpVoltage": "Pilotspænding", "phaseMode": "Kører på", @@ -434,10 +439,12 @@ "controlOff": "Fra (kun overvågning)", "controlOwn": "Eget CSMS (fuld styring)", "controlProxy": "Proxy-CSMS (videresendelse + styring)", + "controlCloud": "Anker-sky (virker overalt)", "controlModbus": "Modbus TCP (lokalt netværk)", "controlTitle": "Laderstyring (OCPP)", "controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.", "controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlCloudHint": "DriverVault sender kommandoer gennem din Anker-kontos egen skyforbindelse til laderen, så laderen slet ikke behøver at kunne nås: intet at videresende, ingen adresse, intet token. Det er tilstanden til en lader på et andet netværk. Den virker kun, så længe Ankers sky gør.", "controlModbusHint": "DriverVault kontakter selv laderen på dit lokale netværk, så der er ingen adresse at pege laderen mod her. Slå Modbus TCP til i Anker-appen, og indtast den adresse, den viser, ved siden af laderens betjening på Opladning-siden.", "controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.", "chargerSerial": "Laderens serienummer", @@ -458,6 +465,14 @@ "chargerUnnamed": "Lader uden navn", "chargerOffline": "Offline", "chargerUse": "Brug denne", + "modes": { + "start_charge": "Lader", + "stop_charge": "Stoppet", + "skip_delay": "Spring forsinkelse over", + "boost_charge": "Boost", + "wait_plug": "Venter på stik", + "wait_start": "Venter på start" + }, "states": { "standby": "Standby", "preparing": "Forbereder", diff --git a/Phone App/assets/i18n/en.json b/Phone App/assets/i18n/en.json index 3e3ac32..636f34e 100644 --- a/Phone App/assets/i18n/en.json +++ b/Phone App/assets/i18n/en.json @@ -177,6 +177,11 @@ "lineToLine": "Line to line", "power": "Total power", "sessionDuration": "Session length", + "mode": "Mode", + "plugCountdown": "Waiting for a plug", + "startCountdown": "Starting in", + "autoStart": "Start automatically", + "scheduleWindow": "Charging window", "cpSignal": "Control pilot", "cpVoltage": "Pilot voltage", "phaseMode": "Running on", @@ -314,10 +319,12 @@ "controlOff": "Off (monitoring only)", "controlOwn": "Own CSMS (full control)", "controlProxy": "Proxy CSMS (relay + control)", + "controlCloud": "Anker cloud (works anywhere)", "controlModbus": "Modbus TCP (local network)", "controlTitle": "Charger control (OCPP)", "controlOwnHint": "The charger connects directly to DriverVault as its Central System. Point the charger's OCPP backend at the endpoint below.", "controlProxyHint": "DriverVault relays to Anker's cloud and can inject commands. Point the charger's OCPP backend at the endpoint below.", + "controlCloudHint": "DriverVault sends commands through your Anker account’s own cloud connection to the charger, so the charger needs no reachability at all: nothing to forward, no address, no token. This is the mode for a charger on someone else’s network. It works only while Anker’s cloud does.", "controlModbusHint": "DriverVault dials the charger on your local network, so there is nothing to point at an endpoint here. Enable Modbus TCP in the Anker app, then enter the address it shows beside the charger's controls on the Charging page.", "controlProvisionSteps": "In the Anker app (or the charger's OCPP settings), set the OCPP backend URL to the endpoint and the authorization key to the token above.", "chargerSerial": "Charger serial", @@ -338,6 +345,14 @@ "chargerUnnamed": "Unnamed charger", "chargerOffline": "Offline", "chargerUse": "Use this one", + "modes": { + "start_charge": "Charging", + "stop_charge": "Stopped", + "skip_delay": "Skip delay", + "boost_charge": "Boost", + "wait_plug": "Waiting for a plug", + "wait_start": "Waiting to start" + }, "states": { "standby": "Standby", "preparing": "Preparing", diff --git a/Phone App/assets/i18n/pl.json b/Phone App/assets/i18n/pl.json index 188342b..8fcea25 100644 --- a/Phone App/assets/i18n/pl.json +++ b/Phone App/assets/i18n/pl.json @@ -179,6 +179,11 @@ "lineToLine": "Międzyfazowe", "power": "Moc całkowita", "sessionDuration": "Czas sesji", + "mode": "Tryb", + "plugCountdown": "Czeka na podłączenie", + "startCountdown": "Start za", + "autoStart": "Start automatyczny", + "scheduleWindow": "Okno ładowania", "cpSignal": "Control pilot", "cpVoltage": "Napięcie pilota", "phaseMode": "Pracuje na", @@ -440,10 +445,12 @@ "controlOff": "Wyłączone (tylko monitorowanie)", "controlOwn": "Własny CSMS (pełne sterowanie)", "controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)", + "controlCloud": "Chmura Anker (działa wszędzie)", "controlModbus": "Modbus TCP (sieć lokalna)", "controlTitle": "Sterowanie ładowarką (OCPP)", "controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.", "controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlCloudHint": "DriverVault wysyła polecenia przez połączenie chmurowe Twojego konta Anker z ładowarką, więc ładowarka w ogóle nie musi być osiągalna: nic do przekierowania, żaden adres, żaden token. To tryb dla ładowarki w cudzej sieci. Działa tylko wtedy, gdy działa chmura Ankera.", "controlModbusHint": "DriverVault sam łączy się z ładowarką w sieci lokalnej, więc nie ma tu adresu, który trzeba jej podać. Włącz Modbus TCP w aplikacji Anker, a potem wpisz pokazany adres obok sterowania ładowarką na stronie Ładowanie.", "controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.", "chargerSerial": "Numer seryjny ładowarki", @@ -464,6 +471,14 @@ "chargerUnnamed": "Ładowarka bez nazwy", "chargerOffline": "Offline", "chargerUse": "Użyj tej", + "modes": { + "start_charge": "Ładowanie", + "stop_charge": "Zatrzymana", + "skip_delay": "Pomiń opóźnienie", + "boost_charge": "Boost", + "wait_plug": "Czeka na podłączenie", + "wait_start": "Czeka na start" + }, "states": { "standby": "Czuwanie", "preparing": "Przygotowanie", diff --git a/Phone App/lib/models.dart b/Phone App/lib/models.dart index 7bca7e2..59366af 100644 --- a/Phone App/lib/models.dart +++ b/Phone App/lib/models.dart @@ -1067,7 +1067,7 @@ class AnkerControl { final bool hasToken; // a per-charger token has been generated final String tokenHint; // last chars of the token, for display final bool connected; // the charger is reachable over the active transport - final String controlMode; // off | own | proxy | modbus + final String controlMode; // off | mqtt | modbus | own | proxy final String connectorStatus; // OCPP connector status, e.g. "Charging" final int meterWh; // last OCPP meter reading in watt-hours @@ -1081,8 +1081,10 @@ class AnkerControl { /// Better than a generic hint, because the server has already tried. final String detail; - /// The Modbus register snapshot, when the active transport produced one. - final ModbusStatus? modbus; + /// The charger's own snapshot, when the active transport produced one — + /// Modbus registers or the cloud's device messages, which name the same + /// quantities the same way. + final ChargerStatus? device; const AnkerControl({ this.endpoint = "", @@ -1095,7 +1097,7 @@ class AnkerControl { this.modbusHost = "", this.modbusPort = 502, this.detail = "", - this.modbus, + this.device, }); factory AnkerControl.fromJson(Map j) { @@ -1114,37 +1116,46 @@ class AnkerControl { modbusHost: _asStr(j["modbusHost"]), modbusPort: port == 0 ? 502 : port, detail: _asStr(j["detail"]), - modbus: mode == "modbus" && status is Map - ? ModbusStatus(Map.from(status)) + device: (mode == "modbus" || mode == "mqtt") && status is Map + ? ChargerStatus(Map.from(status)) : null, ); } bool get isModbus => controlMode == "modbus"; - /// The energy this card shows. The two transports word a charging session - /// differently — an OCPP snapshot counts a meter, a Modbus one counts the + /// The Anker cloud path: commands ride the connection the charger already + /// holds to Anker, so nothing on the customer's side has to be reachable. + bool get isCloud => controlMode == "mqtt"; + + /// Both of those read the charger itself and answer with its own snapshot, + /// where OCPP answers with the session our CSMS is holding. + bool get readsDevice => isModbus || isCloud; + + /// The energy this card shows. The transports word a charging session + /// differently — an OCPP snapshot counts a meter, the charger's own counts the /// session — and both land in the same tile. - double get meterKwh => (isModbus ? (modbus?.sessionWh ?? 0) : meterWh) / 1000.0; + double get meterKwh => (readsDevice ? (device?.sessionWh ?? 0) : meterWh) / 1000.0; /// What the charger says it is doing, in whichever transport's words. String get statusLabel { - final label = isModbus ? (modbus?.statusDesc ?? "") : connectorStatus; + final label = readsDevice ? (device?.statusDesc ?? "") : connectorStatus; return label.isEmpty ? "—" : label; } } -/// The Anker charger's Modbus register snapshot. +/// The Anker charger's own snapshot, read over Modbus TCP or over Anker's cloud. /// -/// A wrapper over the raw JSON rather than forty declared fields: the register -/// map is the server's to describe, every value is optional (a charger on older -/// firmware answers a shorter block), and a reading added to the snapshot -/// upstream surfaces here without a change to this file. The getters name what -/// the Charging page reads, and each keeps null distinct from zero — a relay -/// that reported no temperature is not a relay at 0 °C. -class ModbusStatus { +/// A wrapper over the raw JSON rather than forty declared fields: what a +/// transport reports is the server's to describe, every value is optional (a +/// charger on older firmware answers a shorter register block, and the cloud +/// sends its settings only after a command), and a reading added upstream +/// surfaces here without a change to this file. The getters name what the +/// Charging page reads, and each keeps null distinct from zero — a relay that +/// reported no temperature is not a relay at 0 °C. +class ChargerStatus { final Map raw; - const ModbusStatus(this.raw); + const ChargerStatus(this.raw); double? number(String key) => _asDoubleOrNull(raw[key]); int? integer(String key) => _asIntOrNull(raw[key]); @@ -1164,6 +1175,21 @@ class ModbusStatus { String get statusDesc => text("statusDesc"); int get sessionWh => _asInt(raw["sessionWh"]); + /// The modes the charger can be moved to from the one it is in. Only the + /// cloud transport derives them — it is the only one that can see the boost + /// flag and the countdowns the derivation depends on. + List get modeOptions { + final v = raw["modeOptions"]; + return v is List ? v.map(_asStr).where((s) => s.isNotEmpty).toList() : const []; + } + + /// What the charger says about its own local side, when the transport carries + /// it: the address the Modbus mode otherwise has to be given by hand. + Map get local { + final v = raw["local"]; + return v is Map ? Map.from(v) : const {}; + } + /// The alarm words, as they arrive: the spec defers what the individual bits /// mean to a list Anker does not publish, so which word is set is still the /// thing to report. diff --git a/Phone App/lib/screens/charging_screen.dart b/Phone App/lib/screens/charging_screen.dart index 2862261..6c9df8f 100644 --- a/Phone App/lib/screens/charging_screen.dart +++ b/Phone App/lib/screens/charging_screen.dart @@ -1016,7 +1016,7 @@ class _HomeTabState extends State<_HomeTab> { /// Falling back to the text box shows the serial that is actually in force. bool _manualSerial = false; - String _mode = "off"; // effective control mode (off | own | proxy | modbus) + String _mode = "off"; // effective control mode (off | mqtt | modbus | own | proxy) AnkerControl? _ctl; String? _ctlError; String _busy = ""; // action name currently in flight @@ -1051,6 +1051,20 @@ class _HomeTabState extends State<_HomeTab> { bool get _active => _mode != "off"; bool get _connected => _ctl?.connected == true; bool get _isModbus => _mode == "modbus"; + + /// The Anker cloud path: both ends meet at the broker the charger already + /// talks to, so there is nothing to address and nothing to install — only an + /// account to be signed in to. It is the mode for a charger somewhere else. + bool get _isCloud => _mode == "mqtt"; + + /// Those two read the charger itself and answer with its own snapshot, where + /// OCPP answers with the session our CSMS holds. Anything both can report is + /// named the same in both, so one set of readouts serves them; what each can + /// be told still differs, which is what the buttons below branch on. + bool get _readsDevice => _isModbus || _isCloud; + + /// The charger's snapshot, whichever transport read it. + ChargerStatus? get _dev => _ctl?.device; bool get _canImport => _providers.any((p) => p.connected); bool get _serialInList => @@ -1407,7 +1421,7 @@ class _HomeTabState extends State<_HomeTab> { final cards = { "control": _active && _connected ? _controlCard(context) : null, "connection": _active ? _connectionCard(context) : null, - "readings": _isModbus && _connected ? _readingsCard(context) : null, + "readings": _readsDevice && _connected ? _readingsCard(context) : null, "info": _infoCard(context), }; @@ -1442,6 +1456,32 @@ class _HomeTabState extends State<_HomeTab> { ), ), ]), + // A charger told to start can sit in "preparing" for a good while, and + // these say why: it is waiting for a plug, or counting down a start + // delay. Only the cloud transport can see them. + if (_countdown("plugCountdownSeconds") != null || + _countdown("startCountdownSeconds") != null) ...[ + const SizedBox(height: 8), + Row(children: [ + if (_countdown("plugCountdownSeconds") != null) + Expanded( + child: _MetricTile( + value: _countdown("plugCountdownSeconds")!, + label: t("charging.modbus.plugCountdown"), + ), + ), + if (_countdown("plugCountdownSeconds") != null && + _countdown("startCountdownSeconds") != null) + const SizedBox(width: 8), + if (_countdown("startCountdownSeconds") != null) + Expanded( + child: _MetricTile( + value: _countdown("startCountdownSeconds")!, + label: t("charging.modbus.startCountdown"), + ), + ), + ]), + ], const SizedBox(height: 12), Row(children: [ Expanded( @@ -1480,9 +1520,10 @@ class _HomeTabState extends State<_HomeTab> { child: Text(t("charging.control.applyLimit")), ), ), - // Clearing a limit is an OCPP command; the register map takes an - // explicit ceiling, so in Modbus mode there is nothing to clear to. - if (!_isModbus) ...[ + // Clearing a limit is an OCPP command. Both of the transports that + // talk to the charger itself take an explicit ceiling, so there is + // nothing for them to clear to. + if (!_readsDevice) ...[ const SizedBox(width: 8), Expanded( child: OutlinedButton( @@ -1493,8 +1534,10 @@ class _HomeTabState extends State<_HomeTab> { ], ]), - // Boost is a Modbus command, and lasts for the current session only. - if (_isModbus) ...[ + // Boost lasts for the current session only, and is a command the + // charger itself takes — over the register map or over the cloud, but + // never over OCPP. + if (_readsDevice) ...[ const SizedBox(height: 12), SizedBox( width: double.infinity, @@ -1505,9 +1548,22 @@ class _HomeTabState extends State<_HomeTab> { ), ], - // Reset reboots the charger over OCPP; the register map has no - // equivalent, so the button is not offered on the local path. - if (!_isModbus) ...[ + // Skipping a start delay is only offered while one is running, and only + // the cloud transport knows that it is. + if (_dev?.modeOptions.contains("skip_delay") ?? false) ...[ + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton( + onPressed: _busy == "skip-delay" ? null : () => _action("skip-delay"), + child: Text(t("charging.control.skipDelay")), + ), + ), + ], + + // Reset reboots the charger over OCPP; neither the register map nor the + // cloud has an equivalent, so the button is not offered there. + if (!_readsDevice) ...[ const SizedBox(height: 12), if (!_resetPrompt) SizedBox( @@ -1720,16 +1776,42 @@ class _HomeTabState extends State<_HomeTab> { ), ], - // Why there is nothing to control yet. In Modbus mode the server has - // already tried to reach the charger and says what it found, which beats - // a generic hint. + // The cloud path has nothing to set up: the account is the credential, + // and it is entered in Settings. What it does have to say is what the + // charger reports about its own local side, which is the address the + // Modbus mode would otherwise have to be told. + if (_isCloud) ...[ + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50, + borderRadius: BorderRadius.circular(DriverVault.radiusControl), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(t("charging.control.cloudNote"), style: TextStyle(fontSize: 11, color: muted)), + if (_localAccess != null) ...[ + const SizedBox(height: 6), + Text( + t("charging.control.cloudLocalFound", params: {"address": _localAccess!}), + style: DriverVault.mono(context, size: 11), + ), + ], + ], + ), + ), + ], + + // Why there is nothing to control yet. Reading the charger directly, the + // server has already tried and says what it found, which beats a generic + // hint. if (!_connected) Padding( padding: const EdgeInsets.only(top: 12), child: Text( - (_ctl?.detail.isNotEmpty ?? false) - ? _ctl!.detail - : t(_isModbus ? "charging.control.modbusHint" : "charging.control.connectHint"), + (_ctl?.detail.isNotEmpty ?? false) ? _ctl!.detail : t(_hintKey), style: TextStyle(fontSize: 12, color: muted), ), ), @@ -1748,11 +1830,38 @@ class _HomeTabState extends State<_HomeTab> { _host.text.trim() != (_ctl?.modbusHost ?? "") || (int.tryParse(_port.text.trim()) ?? 502) != (_ctl?.modbusPort ?? 502); - /// What the charger reports over Modbus. Its own card rather than a tail on - /// the control one: control is for acting on the charger, and this is a long - /// read that pushed the buttons off the screen. + /// A countdown the charger is running, as minutes and seconds. Only shown + /// while it is actually running: zero is not a countdown, it is the absence + /// of one. + String? _countdown(String key) { + final v = _dev?.integer(key); + if (v == null || v <= 0) return null; + final m = v ~/ 60; + return m > 0 ? "$m min ${v % 60} s" : "$v s"; + } + + /// The charger's own local address, when the cloud snapshot carries it: the + /// address the Modbus mode has to be given by hand, discovered instead. + String? get _localAccess { + final local = _dev?.local ?? const {}; + final host = local["host"]; + if (local["modbusEnabled"] != true || host is! String || host.isEmpty) return null; + final port = local["port"]; + return port is num && port != 502 ? "$host:${port.toInt()}" : host; + } + + /// Why nothing is connected yet, in the terms of the transport in force. + String get _hintKey { + if (_isModbus) return "charging.control.modbusHint"; + if (_isCloud) return "charging.control.cloudHint"; + return "charging.control.connectHint"; + } + + /// What the charger reports about itself, over whichever transport reads it. + /// Its own card rather than a tail on the control one: control is for acting + /// on the charger, and this is a long read that pushed the buttons off screen. Widget _readingsCard(BuildContext context) { - final s = _ctl?.modbus; + final s = _dev; return _FoldCard( title: t("charging.modbus.title"), open: _isOpen("readings"), @@ -1764,7 +1873,7 @@ class _HomeTabState extends State<_HomeTab> { ); } - List _readingSections(BuildContext context, ModbusStatus s) { + List _readingSections(BuildContext context, ChargerStatus s) { final phases = _phaseRows(s); final live = _liveRows(s); final settings = _settingRows(s); @@ -1897,6 +2006,23 @@ class _HomeTabState extends State<_HomeTab> { return label == key ? "$v" : label; } + /// The operational mode the charger is in, in the integration's own + /// vocabulary. Only the cloud transport derives it. + String? _modeLabel(String slug) { + if (slug.isEmpty) return null; + final key = "settings.integrations.modes.$slug"; + final label = t(key); + return label == key ? slug.replaceAll("_", " ") : label; + } + + /// The charging window the charger's schedule allows, when it reports one. + String? _window(ChargerStatus s) { + final from = s.settings["weekStart"]; + final to = s.settings["weekEnd"]; + if (from is! String || to is! String || from.isEmpty || to.isEmpty) return null; + return "$from–$to"; + } + String? _sessionLength(int? seconds) { if (seconds == null) return null; final h = seconds ~/ 3600; @@ -1909,12 +2035,15 @@ class _HomeTabState extends State<_HomeTab> { if (value != null && value.isNotEmpty) (t("charging.modbus.$key"), value), ]; - List<(String, String)> _liveRows(ModbusStatus s) { + List<(String, String)> _liveRows(ChargerStatus s) { final r1 = s.number("relay1TempC"); final r2 = s.number("relay2TempC"); return _rows([ + ("mode", _modeLabel(s.text("mode"))), ("power", _unit(s.number("powerTotal"), 0, "W")), ("sessionDuration", _sessionLength(s.integer("sessionSeconds"))), + ("plugCountdown", _countdown("plugCountdownSeconds")), + ("startCountdown", _countdown("startCountdownSeconds")), ("cpSignal", s.text("cpSignalDesc")), ("cpVoltage", _unit(s.number("cpVoltage"), 2, "V")), ("phaseMode", _enumLabel("phaseMode", s.integer("phaseMode"))), @@ -1928,14 +2057,19 @@ class _HomeTabState extends State<_HomeTab> { ]); } - List<(String, String)> _settingRows(ModbusStatus s) { + List<(String, String)> _settingRows(ChargerStatus s) { final timeout = s.settingInt("timeoutSeconds"); final led = s.integer("ledBrightness"); return _rows([ ("maxCurrentSet", _unit(s.setting("maxCurrentA"), 1, "A")), ("timeout", timeout == null ? null : "$timeout s"), ("phaseSetting", _enumLabel("phaseSet", s.settingInt("phaseSetting"))), - ("boostSet", _yesNo(s.settingFlag("boost"))), + // The transports name the same thing differently: the control block has a + // boost register that was written, the cloud reports a boost that is + // running. Either answers "is it boosting". + ("boostSet", _yesNo(s.settingFlag("boost") ?? s.flag("boostMode"))), + ("autoStart", _yesNo(s.settingFlag("autoStart"))), + ("scheduleWindow", _window(s)), ("lastCommand", _enumLabel("command", s.settingInt("lastCommand"))), ("chargingMode", _enumLabel("chargingMode", s.integer("chargingMode"))), ("loadBalancing", _yesNo(s.flag("loadBalancing"))), @@ -1944,7 +2078,7 @@ class _HomeTabState extends State<_HomeTab> { ]); } - List<(String, String)> _deviceRows(ModbusStatus s) { + List<(String, String)> _deviceRows(ChargerStatus s) { final product = s.integer("productNumber"); final min = s.integer("minCurrentA"); final max = s.integer("maxCurrentA"); @@ -1963,7 +2097,7 @@ class _HomeTabState extends State<_HomeTab> { /// The per-phase matrix, or nothing at all when the charger reported none of /// it. A cell with no reading is a dash, so the columns still line up. - List> _phaseRows(ModbusStatus s) { + List> _phaseRows(ChargerStatus s) { String cell(double? v, int digits, String unit) => v == null ? "—" : "${v.toStringAsFixed(digits)} $unit"; final any = ["voltageL1", "currentL1", "powerL1"].any((k) => s.raw[k] != null); @@ -1983,7 +2117,7 @@ class _HomeTabState extends State<_HomeTab> { /// Line-to-line voltages only mean anything on a three-phase supply, so they /// are shown when the charger reports one rather than as three more zeroes. - List _lineVoltages(ModbusStatus s) { + List _lineVoltages(ChargerStatus s) { final pairs = [ ("L1–L2", s.number("voltageL1L2")), ("L2–L3", s.number("voltageL2L3")), @@ -1995,7 +2129,7 @@ class _HomeTabState extends State<_HomeTab> { ]; } - List _alarmWords(ModbusStatus s) { + List _alarmWords(ChargerStatus s) { if (!s.alarm) return const []; final words = s.alarms; return [ diff --git a/Phone App/lib/screens/settings_screen.dart b/Phone App/lib/screens/settings_screen.dart index 603430a..6430da7 100644 --- a/Phone App/lib/screens/settings_screen.dart +++ b/Phone App/lib/screens/settings_screen.dart @@ -1595,9 +1595,10 @@ class _IntegrationsTabState extends State<_IntegrationsTab> { showEffectiveWhenLocked: true, options: [ ("off", "settings.integrations.controlOff"), + ("mqtt", "settings.integrations.controlCloud"), + ("modbus", "settings.integrations.controlModbus"), ("own", "settings.integrations.controlOwn"), ("proxy", "settings.integrations.controlProxy"), - ("modbus", "settings.integrations.controlModbus"), ], ), ], @@ -2038,17 +2039,29 @@ class _IntegrationCardState extends State<_IntegrationCard> { // The chargers on the account. In an OCPP mode the list lives inside the // provisioning card below, where picking one fills the serial; Modbus has // no such card, so there it stays out here. - if (_c.anker && view.enabled && (view.controlMode == "off" || view.controlMode == "modbus")) + if (_c.anker && + view.enabled && + (view.controlMode == "off" || + view.controlMode == "modbus" || + view.controlMode == "mqtt")) const Padding(padding: EdgeInsets.only(top: 16), child: _AnkerChargers()), // Modbus is reached rather than provisioned: what it needs is the - // charger's own address, which is asked for beside the controls. + // charger's own address, which is asked for beside the controls. The cloud + // mode asks for nothing beyond the account above — which is the whole + // point of it — so it says so rather than showing an empty setup card. if (_c.anker && view.enabled && view.controlMode == "modbus") Padding( padding: const EdgeInsets.only(top: 16), child: Text(t("settings.integrations.controlModbusHint"), style: TextStyle(fontSize: 12, color: muted)), ), + if (_c.anker && view.enabled && view.controlMode == "mqtt") + Padding( + padding: const EdgeInsets.only(top: 16), + child: Text(t("settings.integrations.controlCloudHint"), + style: TextStyle(fontSize: 12, color: muted)), + ), // OCPP control provisioning (Anker only, in the modes the charger dials). if (_c.anker && (view.controlMode == "own" || view.controlMode == "proxy")) diff --git a/README.md b/README.md index f7e48e4..f15df97 100644 --- a/README.md +++ b/README.md @@ -70,9 +70,12 @@ The Web and Phone apps are at feature parity. connected account (MyToyota today), choosing what to pull in, and read everything that service knows about it from a dedicated first tab on the car. Generic over providers: the next manufacturer is one adapter in the API Server. -- **EV charging control** — for Anker Solix chargers the API Server runs an - **OCPP 1.6J Central System**; in own/proxy mode the charger dials back in and - the owner can start/stop and set limits from the Charging screen. +- **EV charging control** — for Anker Solix chargers the owner can start, stop + and limit charging from the Charging screen over whichever of three transports + their control mode picks: **Anker's own cloud** (commands ride the connection + the charger already holds to Anker, so nothing has to be reachable — the mode + for a charger on a customer's network), **Modbus TCP** on the local network, or + an **OCPP 1.6J Central System** the charger dials back into. - **Translated UI** — the interface reads its text from per-language files (English, Polish, Danish today), with English as the fallback for any untranslated string. See [TRANSLATIONS.md](TRANSLATIONS.md). diff --git a/Web App/web/src/api.js b/Web App/web/src/api.js index c8a570d..8dad465 100644 --- a/Web App/web/src/api.js +++ b/Web App/web/src/api.js @@ -332,13 +332,15 @@ export const api = { // Anker Solix control (per charger), over whichever transport the user's // control mode selects. getAnkerControl returns the control mode, connection // status, and a live status snapshot — an OCPP session snapshot in own/proxy - // mode, a Modbus register snapshot in modbus mode. + // mode, the charger's own snapshot in modbus and mqtt mode. // - // The two modes are provisioned differently, and each has its own pair here: - // OCPP needs a token the operator installs into the charger (ankerControlToken - // / ankerControlRevoke), Modbus needs the charger's address on the local - // network (ankerControlAddress / ankerControlForgetAddress). A charger may - // hold both; setting one leaves the other alone. + // The modes are provisioned differently. OCPP needs a token the operator + // installs into the charger (ankerControlToken / ankerControlRevoke); Modbus + // needs the charger's address on the local network (ankerControlAddress / + // ankerControlForgetAddress); the Anker cloud mode needs neither, because it + // signs in as the account and reaches the charger through Anker's own broker — + // which is why it is the mode for a charger the server cannot route to. A + // charger may hold both bindings; setting one leaves the other alone. getAnkerControl: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control`), ankerControlToken: (sn) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/token`, { method: "POST" }), @@ -353,7 +355,9 @@ export const api = { request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/control/address`, { method: "DELETE" }), // One control command. Over OCPP: start, stop, limit, clear-limit, // availability, reset, unlock, trigger, config. Over Modbus TCP: start, stop, - // limit, boost, phase, timeout, status. + // limit, boost, phase, timeout, status. Over the Anker cloud: start, stop, + // limit, boost, skip-delay, status. A command a transport cannot send is + // refused by name, saying which transport can. ankerControlAction: (sn, action, body = {}) => request(`/integrations/anker-solix/chargers/${encodeURIComponent(sn)}/${action}`, { method: "POST", diff --git a/Web App/web/src/i18n/da.json b/Web App/web/src/i18n/da.json index 151791a..b655da6 100644 --- a/Web App/web/src/i18n/da.json +++ b/Web App/web/src/i18n/da.json @@ -136,6 +136,11 @@ "lineToLine": "Mellem faser", "power": "Samlet effekt", "sessionDuration": "Sessionens længde", + "mode": "Tilstand", + "plugCountdown": "Venter på stik", + "startCountdown": "Starter om", + "autoStart": "Start automatisk", + "scheduleWindow": "Ladevindue", "chargingStatus": "Ladestatus", "sessionEnergy": "Sessionsenergi", "cpSignal": "Control pilot", @@ -410,10 +415,12 @@ "controlOff": "Fra (kun overvågning)", "controlOwn": "Eget CSMS (fuld styring)", "controlProxy": "Proxy-CSMS (videresendelse + styring)", + "controlCloud": "Anker-sky (virker overalt)", "controlModbus": "Modbus TCP (lokalt netværk)", "controlTitle": "Laderstyring (OCPP)", "controlOwnHint": "Laderen forbinder direkte til DriverVault som sit centralsystem. Peg laderens OCPP-backend på endepunktet nedenfor.", "controlProxyHint": "DriverVault videresender til Ankers sky og kan indsætte kommandoer. Peg laderens OCPP-backend på endepunktet nedenfor.", + "controlCloudHint": "DriverVault sender kommandoer gennem din Anker-kontos egen skyforbindelse til laderen, så laderen slet ikke behøver at kunne nås: intet at videresende, ingen adresse, intet token. Det er tilstanden til en lader på et andet netværk. Den virker kun, så længe Ankers sky gør.", "controlModbusHint": "DriverVault kontakter selv laderen på dit lokale netværk, så der er ingen adresse at pege laderen mod her. Slå Modbus TCP til i Anker-appen, og indtast den adresse, den viser, ved siden af laderens betjening på Opladning-siden.", "controlProvisionSteps": "I Anker-appen (eller laderens OCPP-indstillinger) sættes OCPP-backend-URL'en til endepunktet og autorisationsnøglen til tokenet ovenfor.", "chargerSerial": "Laderens serienummer", @@ -435,6 +442,14 @@ "chargerOnline": "Online", "chargerOffline": "Offline", "chargerUse": "Brug denne", + "modes": { + "start_charge": "Lader", + "stop_charge": "Stoppet", + "skip_delay": "Spring forsinkelse over", + "boost_charge": "Boost", + "wait_plug": "Venter på stik", + "wait_start": "Venter på start" + }, "states": { "standby": "Standby", "preparing": "Forbereder", diff --git a/Web App/web/src/i18n/en.json b/Web App/web/src/i18n/en.json index 37addc0..7d2f635 100644 --- a/Web App/web/src/i18n/en.json +++ b/Web App/web/src/i18n/en.json @@ -122,6 +122,11 @@ "lineToLine": "Line to line", "power": "Total power", "sessionDuration": "Session length", + "mode": "Mode", + "plugCountdown": "Waiting for a plug", + "startCountdown": "Starting in", + "autoStart": "Start automatically", + "scheduleWindow": "Charging window", "chargingStatus": "Charging status", "sessionEnergy": "Session energy", "cpSignal": "Control pilot", @@ -409,10 +414,12 @@ "controlOff": "Off (monitoring only)", "controlOwn": "Own CSMS (full control)", "controlProxy": "Proxy CSMS (relay + control)", + "controlCloud": "Anker cloud (works anywhere)", "controlModbus": "Modbus TCP (local network)", "controlTitle": "Charger control (OCPP)", "controlOwnHint": "The charger connects directly to DriverVault as its Central System. Point the charger's OCPP backend at the endpoint below.", "controlProxyHint": "DriverVault relays to Anker's cloud and can inject commands. Point the charger's OCPP backend at the endpoint below.", + "controlCloudHint": "DriverVault sends commands through your Anker account’s own cloud connection to the charger, so the charger needs no reachability at all: nothing to forward, no address, no token. This is the mode for a charger on someone else’s network. It works only while Anker’s cloud does.", "controlModbusHint": "DriverVault dials the charger on your local network, so there is nothing to point at an endpoint here. Enable Modbus TCP in the Anker app, then enter the address it shows beside the charger's controls on the Charging page.", "controlProvisionSteps": "In the Anker app (or the charger's OCPP settings), set the OCPP backend URL to the endpoint and the authorization key to the token above.", "chargerSerial": "Charger serial", @@ -434,6 +441,14 @@ "chargerOnline": "Online", "chargerOffline": "Offline", "chargerUse": "Use this one", + "modes": { + "start_charge": "Charging", + "stop_charge": "Stopped", + "skip_delay": "Skip delay", + "boost_charge": "Boost", + "wait_plug": "Waiting for a plug", + "wait_start": "Waiting to start" + }, "states": { "standby": "Standby", "preparing": "Preparing", diff --git a/Web App/web/src/i18n/pl.json b/Web App/web/src/i18n/pl.json index 1141d63..680fe2b 100644 --- a/Web App/web/src/i18n/pl.json +++ b/Web App/web/src/i18n/pl.json @@ -138,6 +138,11 @@ "lineToLine": "Międzyfazowe", "power": "Moc całkowita", "sessionDuration": "Czas sesji", + "mode": "Tryb", + "plugCountdown": "Czeka na podłączenie", + "startCountdown": "Start za", + "autoStart": "Start automatyczny", + "scheduleWindow": "Okno ładowania", "chargingStatus": "Status ładowania", "sessionEnergy": "Energia sesji", "cpSignal": "Control pilot", @@ -414,10 +419,12 @@ "controlOff": "Wyłączone (tylko monitorowanie)", "controlOwn": "Własny CSMS (pełne sterowanie)", "controlProxy": "CSMS pośredniczący (przekazywanie + sterowanie)", + "controlCloud": "Chmura Anker (działa wszędzie)", "controlModbus": "Modbus TCP (sieć lokalna)", "controlTitle": "Sterowanie ładowarką (OCPP)", "controlOwnHint": "Ładowarka łączy się bezpośrednio z DriverVault jako swoim systemem centralnym. Ustaw backend OCPP ładowarki na poniższy adres.", "controlProxyHint": "DriverVault przekazuje ruch do chmury Anker i może wysyłać własne polecenia. Ustaw backend OCPP ładowarki na poniższy adres.", + "controlCloudHint": "DriverVault wysyła polecenia przez połączenie chmurowe Twojego konta Anker z ładowarką, więc ładowarka w ogóle nie musi być osiągalna: nic do przekierowania, żaden adres, żaden token. To tryb dla ładowarki w cudzej sieci. Działa tylko wtedy, gdy działa chmura Ankera.", "controlModbusHint": "DriverVault sam łączy się z ładowarką w sieci lokalnej, więc nie ma tu adresu, który trzeba jej podać. Włącz Modbus TCP w aplikacji Anker, a potem wpisz pokazany adres obok sterowania ładowarką na stronie Ładowanie.", "controlProvisionSteps": "W aplikacji Anker (lub w ustawieniach OCPP ładowarki) ustaw adres backendu OCPP na powyższy endpoint, a klucz autoryzacji na powyższy token.", "chargerSerial": "Numer seryjny ładowarki", @@ -439,6 +446,14 @@ "chargerOnline": "Online", "chargerOffline": "Offline", "chargerUse": "Użyj tej", + "modes": { + "start_charge": "Ładowanie", + "stop_charge": "Zatrzymana", + "skip_delay": "Pomiń opóźnienie", + "boost_charge": "Boost", + "wait_plug": "Czeka na podłączenie", + "wait_start": "Czeka na start" + }, "states": { "standby": "Czuwanie", "preparing": "Przygotowanie", diff --git a/Web App/web/src/views/Charging.vue b/Web App/web/src/views/Charging.vue index 876ddb8..38ddda6 100644 --- a/Web App/web/src/views/Charging.vue +++ b/Web App/web/src/views/Charging.vue @@ -295,31 +295,43 @@ const ctlActive = computed(() => ctlMode.value !== "off"); const ctlConnected = computed(() => !!ctl.value?.connected); // Modbus is the local path: we dial the charger rather than wait for it to dial -// us, so what the card asks for is an address rather than a token, and what it -// can offer differs — boost is a Modbus command, reset and clear-limit are OCPP -// ones the register map has no equivalent for. +// us, so what the card asks for is an address rather than a token. const ctlIsModbus = computed(() => ctlMode.value === "modbus"); -// The two transports report a charging session in different words: an OCPP -// session snapshot counts a meter in Wh, a Modbus snapshot counts the session's -// own energy. Both land in the same tile. +// The Anker cloud path: both ends meet at the broker the charger already talks +// to, so there is nothing to address and nothing to install — only an account to +// be signed in to. It is the mode for a charger that is somewhere else. +const ctlIsCloud = computed(() => ctlMode.value === "mqtt"); + +// Those two read the charger itself and answer with its own snapshot, where OCPP +// answers with the session our CSMS is holding. What a snapshot carries differs +// between them — the register map has the relay temperatures, the cloud has the +// plug and start countdowns — but anything both can report is named the same in +// both, so one set of readouts serves them and a missing value simply drops its +// row. What each can be *told* still differs: reset and clear-limit are OCPP, +// the timeout and phase registers are Modbus, skipping a start delay is cloud. +const ctlReadsDevice = computed(() => ctlIsModbus.value || ctlIsCloud.value); + +// The transports report a charging session in different words: an OCPP session +// snapshot counts a meter in Wh, a snapshot read from the charger counts the +// session's own energy. Both land in the same tile. const ctlMeterKwh = computed(() => { const s = ctl.value?.status || {}; - const wh = ctlIsModbus.value ? s.sessionWh : s.meterWh; + const wh = ctlReadsDevice.value ? s.sessionWh : s.meterWh; return ((wh || 0) / 1000).toFixed(2); }); const ctlStatusLabel = computed(() => { const s = ctl.value?.status || {}; - return (ctlIsModbus.value ? s.statusDesc : s.connectorStatus) || "—"; + return (ctlReadsDevice.value ? s.statusDesc : s.connectorStatus) || "—"; }); -// --- The Modbus snapshot, grouped for reading --- +// --- The charger's own snapshot, grouped for reading --- // -// The local path reports far more than the OCPP one: one poll carries metering, -// the control settings and the charger's identity. Shown as a flat list that is -// a wall of forty numbers, so it is sorted the way it gets asked about — what -// the charger is doing, what it is set to, and what it is. -const mb = computed(() => (ctlIsModbus.value && ctl.value?.status) || {}); +// Reading the charger directly reports far more than OCPP does: one poll carries +// metering, the control settings and the charger's identity. Shown as a flat +// list that is a wall of forty numbers, so it is sorted the way it gets asked +// about — what the charger is doing, what it is set to, and what it is. +const dev = computed(() => (ctlReadsDevice.value && ctl.value?.status) || {}); const isSet = (v) => v !== undefined && v !== null; const unit = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : null); @@ -335,6 +347,25 @@ const enumLabel = (prefix, v) => { return text === key ? String(v) : text; }; +// The operational mode the charger is in, in the integration's own vocabulary. +// The cloud path is the only one that derives it — it is the only transport that +// can see the boost flag and the countdowns the mode depends on. +function modeLabel(slug) { + if (!slug) return null; + const key = `settings.integrations.modes.${slug}`; + const text = t(key); + return text === key ? slug.replace(/_/g, " ") : text; +} + +// A countdown the charger is running, as minutes and seconds. Only shown while +// it is actually running: zero is not a countdown, it is the absence of one. +function countdown(sec) { + if (!isSet(sec) || sec <= 0) return null; + const m = Math.floor(sec / 60); + const r = Math.round(sec % 60); + return m > 0 ? `${m} min ${r} s` : `${r} s`; +} + function sessionLength(sec) { if (!isSet(sec)) return null; const h = Math.floor(sec / 3600); @@ -347,25 +378,28 @@ function sessionLength(sec) { // did anything. Both are Modbus registers; the OCPP status carries neither, so // there the tiles are absent rather than empty, which is also what happens on a // charger whose firmware does not report them. -const ctlPower = computed(() => unit(mb.value.powerTotal, 0, "W")); -const ctlSessionTime = computed(() => sessionLength(mb.value.sessionSeconds)); +const ctlPower = computed(() => unit(dev.value.powerTotal, 0, "W")); +const ctlSessionTime = computed(() => sessionLength(dev.value.sessionSeconds)); // Pairs with no value drop out: a charger on older firmware, or one that refused // the control block, should show a shorter list rather than a column of dashes. const rows = (pairs) => pairs.filter(([, v]) => isSet(v) && v !== "").map(([k, v]) => ({ label: label(k), value: v })); -const modbusLive = computed(() => { - const s = mb.value; +const deviceLive = computed(() => { + const s = dev.value; return rows([ // What the charger says it is doing, and the session's own energy. Both are // registers of their own, and both were readable only from the tiles in the // control card — a readout that leaves out the two numbers the page puts in // front of you is not the full readout it claims to be. ["chargingStatus", s.statusDesc], + ["mode", modeLabel(s.mode)], ["power", unit(s.powerTotal, 0, "W")], ["sessionDuration", sessionLength(s.sessionSeconds)], ["sessionEnergy", isSet(s.sessionWh) ? `${(s.sessionWh / 1000).toFixed(2)} kWh` : null], + ["plugCountdown", countdown(s.plugCountdownSeconds)], + ["startCountdown", countdown(s.startCountdownSeconds)], ["cpSignal", s.cpSignalDesc], ["cpVoltage", unit(s.cpVoltage, 2, "V")], ["phaseMode", enumLabel("phaseMode", s.phaseMode)], @@ -377,14 +411,19 @@ const modbusLive = computed(() => { ]); }); -const modbusSettings = computed(() => { - const s = mb.value; +const deviceSettings = computed(() => { + const s = dev.value; const set = s.settings || {}; return rows([ ["maxCurrentSet", unit(set.maxCurrentA, 1, "A")], ["timeout", isSet(set.timeoutSeconds) ? `${set.timeoutSeconds} s` : null], ["phaseSetting", enumLabel("phaseSet", set.phaseSetting)], - ["boostSet", yesNo(set.boost)], + // The two transports name the same thing differently: the control block has + // a boost register that was written, the cloud reports a boost that is + // running. Either answers "is it boosting". + ["boostSet", yesNo(isSet(set.boost) ? set.boost : s.boostMode)], + ["autoStart", yesNo(set.autoStart)], + ["scheduleWindow", set.weekStart && set.weekEnd ? `${set.weekStart}–${set.weekEnd}` : null], ["lastCommand", enumLabel("command", set.lastCommand)], ["chargingMode", enumLabel("chargingMode", s.chargingMode)], ["loadBalancing", yesNo(s.loadBalancing)], @@ -409,19 +448,41 @@ const draftPhase = ref(0); // every write, so the form ends up showing what the charger took — which is not // always what was asked for, since it clamps the current to its own rating. function syncSettingsDraft() { - const set = mb.value.settings || {}; + const set = dev.value.settings || {}; if (isSet(set.maxCurrentA)) draftAmps.value = Math.round(set.maxCurrentA); if (isSet(set.timeoutSeconds)) draftSeconds.value = set.timeoutSeconds; if (isSet(set.phaseSetting)) draftPhase.value = set.phaseSetting; } -const boostOn = computed(() => !!(mb.value.settings || {}).boost); +const boostOn = computed(() => !!(dev.value.settings || {}).boost); + +// The countdowns beside the start button, and the button that cuts one short. +// A delay can only be skipped while it is running, which is what modeOptions +// says — offering it the rest of the time would be a button that does nothing. +const ctlPlugCountdown = computed(() => countdown(dev.value.plugCountdownSeconds)); +const ctlStartCountdown = computed(() => countdown(dev.value.startCountdownSeconds)); +const ctlCanSkipDelay = computed(() => (dev.value.modeOptions || []).includes("skip_delay")); + +// What the charger says about its own local side, when the cloud snapshot +// carries it: the address the Modbus mode has to be given by hand, discovered. +const ctlLocalAccess = computed(() => { + const local = dev.value.local; + if (!local?.modbusEnabled || !local.host) return ""; + return local.port && local.port !== 502 ? `${local.host}:${local.port}` : local.host; +}); + +// Why nothing is connected yet, in the terms of the transport in force. +const ctlHintKey = computed(() => { + if (ctlIsModbus.value) return "charging.control.modbusHint"; + if (ctlIsCloud.value) return "charging.control.cloudHint"; + return "charging.control.connectHint"; +}); // The charger pauses below 6 A rather than charging slowly, and the server // refuses that case outright, so the slider does not offer it. The ceiling comes // from the charger's own rating where it reports one. const LIMIT_FLOOR = 6; -const limitCeiling = computed(() => Math.round(mb.value.maxCurrentA || 32)); +const limitCeiling = computed(() => Math.round(dev.value.maxCurrentA || 32)); // The timeout the charger falls back on its own strategy after. The spec's floor // is "more than five seconds"; a minute of slack above it is a sane lower bound @@ -430,8 +491,8 @@ const TIMEOUT_FLOOR = 6; // The settings rows there is no register to write. Same labels and formatting as // the readings card's settings block, minus the four that have controls above. -const modbusSettingsReported = computed(() => { - const s = mb.value; +const deviceSettingsReported = computed(() => { + const s = dev.value; const set = s.settings || {}; return rows([ ["lastCommand", enumLabel("command", set.lastCommand)], @@ -442,8 +503,8 @@ const modbusSettingsReported = computed(() => { ]); }); -const modbusDevice = computed(() => { - const s = mb.value; +const deviceIdentity = computed(() => { + const s = dev.value; return rows([ ["model", s.model], ["serial", s.serial], @@ -460,8 +521,8 @@ const modbusDevice = computed(() => { // The per-phase readings are a matrix, not a list: three phases against five // measurements. A table says that; twenty labelled pairs hide it. -const modbusPhases = computed(() => { - const s = mb.value; +const devicePhases = computed(() => { + const s = dev.value; const cell = (v, digits, u) => (isSet(v) ? `${Number(v).toFixed(digits)} ${u}` : "—"); const any = ["voltageL1", "currentL1", "powerL1"].some((k) => isSet(s[k])); if (!any) return []; @@ -477,8 +538,8 @@ const modbusPhases = computed(() => { // Line-to-line voltages only mean anything on a three-phase supply, so they are // shown when the charger reports one rather than as three more zeroes. -const modbusLineVoltages = computed(() => { - const s = mb.value; +const deviceLineVoltages = computed(() => { + const s = dev.value; const pairs = [ ["L1–L2", s.voltageL1L2], ["L2–L3", s.voltageL2L3], @@ -490,7 +551,7 @@ const modbusLineVoltages = computed(() => { // The spec defers the alarm bits to a list it does not publish, so the words are // shown as they arrive: which one is set is still the thing to report. const modbusAlarms = computed(() => { - const s = mb.value; + const s = dev.value; if (!s.alarm || !Array.isArray(s.alarms)) return []; return s.alarms .map((w, i) => ({ n: i + 1, hex: "0x" + w.toString(16).toUpperCase().padStart(4, "0"), set: w !== 0 })) @@ -1087,6 +1148,17 @@ onMounted(async () => {
{{ ctlSessionTime }}
{{ t("charging.modbus.sessionDuration") }}
+ +
+
{{ ctlPlugCountdown }}
+
{{ t("charging.modbus.plugCountdown") }}
+
+
+
{{ ctlStartCountdown }}
+
{{ t("charging.modbus.startCountdown") }}
+
@@ -1098,22 +1170,31 @@ onMounted(async () => {
- +
- +
- + - + + + +