diff --git a/API Server/README.md b/API Server/README.md index 7ba5939..fc89b1b 100644 --- a/API Server/README.md +++ b/API Server/README.md @@ -31,6 +31,7 @@ internal/ ├── 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) +├── 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) └── plugins/ # plugin system — see plugins/README.md @@ -120,7 +121,7 @@ other users `read` or `write` access. Every car/service/part handler is gated by | `car_shares` | grants another user access to a car | car, user, `permission` (read \| write) | | `organizations` | tenants | name (unique) | | `home_chargers` | the chargers a user owns, imported from a connected charger service | name, serial, vendor, model, site_name, power_kw, connector, `provider`, `provider_charger_id`, owner | -| `control_audit` | OCPP control-command audit trail | user, charger, action, result, timestamp | +| `control_audit` | charger control-command audit trail | user, charger, action, result, timestamp | | `users` | login + profile (built-in auth collection) | name, email, avatar, `role` (user \| admin \| superadmin), `organization`, bio, theme, locale, date_format, currency, font_size, deletion_requested_at | Every record collection except `car_shares` / `organizations` / `control_audit` @@ -184,10 +185,12 @@ GET /api/integrations/greencell PUT /api/integrations/greencell POST /a GET /api/integrations/greencell/chargers GET /api/integrations/greencell/chargers/{sn}/state -# Anker Solix OCPP charging control (own/proxy mode + a live CSMS session) +# Anker Solix charging control (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 +PUT /api/integrations/anker-solix/chargers/{sn}/control/address # local Modbus address +DELETE /api/integrations/anker-solix/chargers/{sn}/control/address POST /api/integrations/anker-solix/chargers/{sn}/{action} GET /ocpp/{serial} # charger dials in here (OCPP Basic auth, not bearer) @@ -314,13 +317,19 @@ can be registered at runtime with no rebuild. 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). For Anker Solix -chargers the server additionally runs an **OCPP 1.6J Central System** -(`internal/ocpp`): when the owner sets a control mode of own/proxy, the charger -dials back in at `GET /ocpp/{serial}` (authenticated with OCPP Basic auth using a -per-charger control token, not a bearer token) and the owner can start/stop and -set charge limits, with every command rate-limited and written to a -`control_audit` trail. +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. **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 55c6f0b..9329f2c 100644 --- a/API Server/internal/api/integrations_ankersolix.go +++ b/API Server/internal/api/integrations_ankersolix.go @@ -28,10 +28,14 @@ const ( ankerPlugin = "anker-solix" ankerSecretMask = "••••••••" - // OCPP control modes for the Anker Solix charger (see internal/ocpp). - ankerControlOff = "off" // monitoring only (default) - ankerControlOwn = "own" // DriverVault is the charger's Central System - ankerControlProxy = "proxy" // DriverVault relays to Anker's cloud and injects + // 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. + 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 ) // normalizeControlMode maps a raw control-mode value to a recognized mode, or "" @@ -43,6 +47,8 @@ func normalizeControlMode(v string) string { return ankerControlOwn case ankerControlProxy: return ankerControlProxy + case ankerControlModbus: + return ankerControlModbus case ankerControlOff: return ankerControlOff default: @@ -60,7 +66,8 @@ type ankerConfig struct { ControlMode string `json:"controlMode"` } -// ankerChargerBinding records a per-charger OCPP control token. The operator +// ankerChargerBinding is what we know about one charger the caller controls: +// its OCPP control token, its local Modbus address, or both. The operator // installs the token into the charger (as its OCPP Basic-auth password); we keep // only its SHA-256 hash and a short hint, never the plaintext — the token is // shown to the owner exactly once, at generation. User-layer only, not a cascade @@ -69,6 +76,14 @@ type ankerChargerBinding struct { TokenHash string `json:"tokenHash,omitempty"` // sha256(token), lowercase hex TokenHint string `json:"tokenHint,omitempty"` // last 4 chars, for the UI AddedAt string `json:"addedAt,omitempty"` + + // ModbusHost and ModbusPort address the charger's local Modbus TCP server, + // which the owner enables in the Anker app (Settings > Integrations > Modbus + // TCP; the app then shows this address). They are per charger rather than per + // account because they are a LAN address, not a credential — and unlike the + // OCPP token they are not a secret, so they are stored and shown verbatim. + ModbusHost string `json:"modbusHost,omitempty"` + ModbusPort int `json:"modbusPort,omitempty"` // 0 means the standard 502 } // ankerStored is what we persist per user/org under pluginSettings.ankerSolix. diff --git a/API Server/internal/api/integrations_ankersolix_control.go b/API Server/internal/api/integrations_ankersolix_control.go index aa4d00f..6c7a0c7 100644 --- a/API Server/internal/api/integrations_ankersolix_control.go +++ b/API Server/internal/api/integrations_ankersolix_control.go @@ -377,7 +377,26 @@ func (s *Server) handleAnkerControlStatus(w http.ResponseWriter, r *http.Request "endpoint": s.ocppEndpoint(r, sn), "hasToken": binding.TokenHash != "", "tokenHint": binding.TokenHint, // last-4 only; the token is shown once at generation + "modbusHost": binding.ModbusHost, + "modbusPort": binding.ModbusPort, } + + // 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 { + snap, ok := s.ankerModbusSnapshot(r.Context(), binding) + body["connected"] = ok + if ok { + body["status"] = snap + } else if strings.TrimSpace(binding.ModbusHost) == "" { + body["detail"] = "No local address saved yet. Enable Modbus TCP in the Anker app under Settings > Integrations, then save the address it shows." + } else { + body["detail"] = "The charger did not answer on " + ankerModbusConfig(binding).Address() + ". Check that it is powered on, on this network, and that Modbus TCP is still enabled in the Anker app." + } + writeJSON(w, http.StatusOK, body) + return + } + if sess, ok := s.ocpp.SessionFor(sn); ok { body["connected"] = true body["status"] = sess.Snapshot() @@ -470,13 +489,33 @@ func (s *Server) handleAnkerControlRevoke(w http.ResponseWriter, r *http.Request writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "revoked": true}) } -// handleAnkerControlAction issues one OCPP command to a connected charger. +// ankerControlBody is the union of everything a control action may be given. +// Which fields matter depends on the action, and on the transport: the OCPP +// actions take connector ids and transaction ids, the Modbus ones do not. +type ankerControlBody struct { + IdTag string `json:"idTag"` + ConnectorID int `json:"connectorId"` + TransactionID int `json:"transactionId"` + Amps float64 `json:"amps"` + Operative *bool `json:"operative"` + Hard bool `json:"hard"` + RequestedMessage string `json:"requestedMessage"` + Key string `json:"key"` + Value string `json:"value"` + Confirm bool `json:"confirm"` + Password string `json:"password"` + On *bool `json:"on"` // boost + PhaseMode *int `json:"phase"` // 0 automatic, 1 single, 2 three + Seconds int `json:"seconds"` // Modbus control timeout +} + +// handleAnkerControlAction issues one command to a charger, over whichever +// transport the resolved control mode selects. func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request) { - sess, ok := s.ankerControlSession(w, r) + who, res, binding, ok := s.ankerControlGate(w, r) if !ok { return } - who := caller(r) sn := r.PathValue("sn") action := r.PathValue("action") @@ -487,19 +526,7 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request return } - var body struct { - IdTag string `json:"idTag"` - ConnectorID int `json:"connectorId"` - TransactionID int `json:"transactionId"` - Amps float64 `json:"amps"` - Operative *bool `json:"operative"` - Hard bool `json:"hard"` - RequestedMessage string `json:"requestedMessage"` - Key string `json:"key"` - Value string `json:"value"` - Confirm bool `json:"confirm"` - Password string `json:"password"` - } + var body ankerControlBody if r.Body != nil { _ = json.NewDecoder(r.Body).Decode(&body) } @@ -521,6 +548,17 @@ func (s *Server) handleAnkerControlAction(w http.ResponseWriter, r *http.Request } } + if res.eff.ControlMode == ankerControlModbus { + s.ankerModbusAction(w, r, who, binding, sn, action, body) + return + } + + sess, ok := s.ocpp.SessionFor(sn) + if !ok { + writeError(w, http.StatusConflict, "charger is not connected to the control backend") + return + } + ctx := r.Context() var ( status string @@ -623,40 +661,67 @@ func controlAuditParams(action string, connectorID int, amps float64, hard bool, return p } -// ankerControlSession applies the full gate (cascade + control mode + a live -// session the caller actually owns) and returns the charger's session. -func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) { +// ankerControlGate applies everything a control request must satisfy before any +// 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. +func (s *Server) ankerControlGate(w http.ResponseWriter, r *http.Request) (*callerIdentity, ankerResolution, ankerChargerBinding, bool) { + var ( + res ankerResolution + binding ankerChargerBinding + ) who := caller(r) if who == nil { writeError(w, http.StatusUnauthorized, "not authenticated") - return nil, false + return nil, res, binding, false } sn := r.PathValue("sn") userRaw := s.userPluginSettings(r.Context(), who.ID) - res := s.resolveAnker(r.Context(), who, userRaw) + res = s.resolveAnker(r.Context(), who, userRaw) switch { case !res.available: writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled by the administrator") - return nil, false + return nil, res, binding, false case !res.orgEnabled: writeError(w, http.StatusForbidden, "the Anker Solix integration is disabled for your organization") - return nil, false + return nil, res, binding, false case !res.enabled: writeError(w, http.StatusForbidden, "enable the Anker Solix integration in Settings first") - return nil, false + return nil, res, binding, false } if res.eff.ControlMode == ankerControlOff { - writeError(w, http.StatusConflict, "control mode is off; choose Own or Proxy CSMS to send commands") - return nil, false + writeError(w, http.StatusConflict, "control mode is off; choose Modbus TCP or a CSMS mode to send commands") + return nil, res, binding, false } - // The caller must own this charger (have a token bound to it), so a serial - // alone can't be used to reach someone else's charger. - if ankerBindingFor(userRaw, sn).TokenHash == "" { + + binding = ankerBindingFor(userRaw, sn) + 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") + return nil, res, binding, false + } + return who, res, binding, true + } + // A serial alone can't be used to reach someone else's charger. + if binding.TokenHash == "" { writeError(w, http.StatusNotFound, "no control token for this charger; generate one first") + return nil, res, binding, false + } + return who, res, binding, true +} + +// ankerControlSession applies the gate and returns the charger's live OCPP +// session. +func (s *Server) ankerControlSession(w http.ResponseWriter, r *http.Request) (*ocpp.Session, bool) { + if _, _, _, ok := s.ankerControlGate(w, r); !ok { return nil, false } - sess, ok := s.ocpp.SessionFor(sn) + sess, ok := s.ocpp.SessionFor(r.PathValue("sn")) if !ok { writeError(w, http.StatusConflict, "charger is not connected to the control backend") return nil, false diff --git a/API Server/internal/api/integrations_ankersolix_modbus.go b/API Server/internal/api/integrations_ankersolix_modbus.go new file mode 100644 index 0000000..dad9949 --- /dev/null +++ b/API Server/internal/api/integrations_ankersolix_modbus.go @@ -0,0 +1,242 @@ +package api + +// The local half of the Anker Solix control plane. Where the OCPP path in +// integrations_ankersolix_control.go waits for the charger to dial in to us, +// this one dials the charger: Modbus TCP on its own network, using the register +// map Anker publishes for the V1 (see the plugin's modbus.go). That inversion is +// the whole point — a charger behind a customer's router can be controlled this +// way without any inbound reachability, a public endpoint or a TLS certificate. +// +// The trade-off is that the server must share a network with the charger, so +// this mode is selectable per user rather than assumed. + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "strings" + "time" + + "drivervault/apiserver/internal/modbus" + "drivervault/apiserver/internal/plugins/builtin/ankersolix" +) + +// modbusActionTimeout bounds one dial-plus-exchange. The charger is on the LAN, +// so this is generous; it exists so an unreachable address fails the request +// rather than holding it open. +const modbusActionTimeout = 10 * time.Second + +// ankerModbusConfig reads a charger's local address out of its stored binding. +func ankerModbusConfig(b ankerChargerBinding) ankersolix.ModbusConfig { + return ankersolix.ModbusConfig{Host: strings.TrimSpace(b.ModbusHost), Port: b.ModbusPort} +} + +// ankerModbusAction issues one control command over Modbus TCP. The gate, +// rate limit, destructive-action confirmation and audit have already run in +// handleAnkerControlAction; this decides what to write and reports the outcome. +func (s *Server) ankerModbusAction(w http.ResponseWriter, r *http.Request, who *callerIdentity, + binding ankerChargerBinding, sn, action string, body ankerControlBody) { + + // Actions the register map has no equivalent for. Saying which transport is + // missing 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 local Modbus connection cannot send it. Switch the control mode to a CSMS mode to use it.") + return + case "clear-limit": + // The register takes an explicit ceiling and the charger clamps anything + // above its rating, so "no limit" would mean writing a value we would have + // to guess. Asking for the real one is better than guessing wrong. + writeError(w, http.StatusBadRequest, + "the local connection has no \"clear limit\" command; send \"limit\" with the amps you want instead") + return + } + + ctx, cancel := context.WithTimeout(r.Context(), modbusActionTimeout) + defer cancel() + + client, err := ankersolix.ModbusDial(ctx, ankerModbusConfig(binding)) + if err != nil { + s.auditControl(who, sn, action, nil, "error", err) + writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()}) + return + } + defer client.Close() + + var ( + result any + params = map[string]any{"transport": "modbus"} + ) + switch action { + case "start": + err = ankersolix.ModbusStartCharging(ctx, client) + case "stop": + err = ankersolix.ModbusStopCharging(ctx, client) + case "limit": + params["amps"] = body.Amps + err = ankersolix.ModbusSetMaxCurrent(ctx, client, body.Amps) + case "boost": + on := body.On == nil || *body.On + params["on"] = on + err = ankersolix.ModbusSetBoost(ctx, client, on) + case "phase": + if body.PhaseMode == nil { + writeError(w, http.StatusBadRequest, "phase requires \"phase\": 0 (automatic), 1 (single) or 2 (three)") + return + } + params["phase"] = *body.PhaseMode + err = ankersolix.ModbusSetPhaseMode(ctx, client, *body.PhaseMode) + case "timeout": + params["seconds"] = body.Seconds + err = ankersolix.ModbusSetTimeout(ctx, client, body.Seconds) + case "status": + var snap ankersolix.ModbusSnapshot + snap, err = ankersolix.ModbusRead(ctx, client, true) + result = snap + default: + writeError(w, http.StatusBadRequest, "unknown control action: "+action) + return + } + + outcome := "accepted" + if err != nil { + outcome = "error" + } + s.auditControl(who, sn, action, params, outcome, err) + + if err != nil { + // A register the charger refused is a bad request, not a bad gateway: the + // link worked and the charger said no. + status := http.StatusBadGateway + var mbErr *modbus.Error + if errors.As(err, &mbErr) { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]any{"error": err.Error()}) + return + } + + resp := map[string]any{"status": outcome} + if result != nil { + resp["result"] = result + } + writeJSON(w, http.StatusOK, resp) +} + +// handleAnkerControlAddress saves (or clears, on DELETE) the local address of one +// charger's Modbus TCP server — the address the Anker app shows once Modbus is +// enabled on the device. +func (s *Server) handleAnkerControlAddress(w http.ResponseWriter, r *http.Request) { + who := caller(r) + if who == nil { + writeError(w, http.StatusUnauthorized, "not authenticated") + return + } + if !s.pb.Configured() { + writeError(w, http.StatusServiceUnavailable, "integration settings not configured on the server") + return + } + sn := strings.TrimSpace(r.PathValue("sn")) + if sn == "" { + writeError(w, http.StatusBadRequest, "missing charger serial") + return + } + + var host string + var port int + if r.Method != http.MethodDelete { + var body struct { + Host string `json:"host"` + Port int `json:"port"` + } + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&body) + } + host = strings.TrimSpace(body.Host) + port = body.Port + if err := validateModbusAddress(host, port); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + } + + userRaw := s.userPluginSettings(r.Context(), who.ID) + newDoc := mergeAnker(userRaw, func(as *ankerStored) { + if as.ControlChargers == nil { + as.ControlChargers = map[string]ankerChargerBinding{} + } + // Keep whatever else the binding holds: a charger may carry both an OCPP + // token and a local address, and switching control mode must not discard + // the other one. + b := as.ControlChargers[sn] + b.ModbusHost, b.ModbusPort = host, port + if b.AddedAt == "" { + b.AddedAt = time.Now().UTC().Format(time.RFC3339) + } + as.ControlChargers[sn] = b + }) + if err := s.pb.Update(r.Context(), s.usersCollection(), who.ID, + map[string]any{"pluginSettings": newDoc}, nil); err != nil { + writePBError(w, err) + return + } + + action := "address.set" + if host == "" { + action = "address.clear" + } + s.auditControl(who, sn, action, map[string]any{"host": host, "port": port}, "ok", nil) + writeJSON(w, http.StatusOK, map[string]any{"serial": sn, "modbusHost": host, "modbusPort": port}) +} + +// validateModbusAddress checks a user-supplied charger address. Loopback is +// refused because this address is one the server dials on the caller's behalf, +// and 127.0.0.1 would point it at services running on the server itself rather +// than at a charger. Other addresses are allowed: the charger is expected on a +// private LAN, but a routed or VPN deployment is legitimate too. +func validateModbusAddress(host string, port int) error { + if host == "" { + return errors.New("the charger's local address is required") + } + if port < 0 || port > 65535 { + return fmt.Errorf("%d is not a valid port", port) + } + if strings.ContainsAny(host, " /\\?#@") { + return errors.New("the address should be just a hostname or IP, with no scheme or path") + } + if ip := net.ParseIP(host); ip != nil { + if ip.IsLoopback() || ip.IsUnspecified() { + return errors.New("that address points at the server itself, not at a charger") + } + } else if strings.EqualFold(host, "localhost") { + return errors.New("that address points at the server itself, not at a charger") + } + return nil +} + +// ankerModbusSnapshot reads a charger's live state for the status endpoint. It +// is best effort: a charger that is switched off or on another network simply +// has no snapshot, which is not an error in a status report. +func (s *Server) ankerModbusSnapshot(ctx context.Context, binding ankerChargerBinding) (ankersolix.ModbusSnapshot, bool) { + if strings.TrimSpace(binding.ModbusHost) == "" { + return ankersolix.ModbusSnapshot{}, false + } + ctx, cancel := context.WithTimeout(ctx, modbusActionTimeout) + defer cancel() + + client, err := ankersolix.ModbusDial(ctx, ankerModbusConfig(binding)) + if err != nil { + return ankersolix.ModbusSnapshot{}, false + } + defer client.Close() + + snap, err := ankersolix.ModbusRead(ctx, client, true) + if err != nil { + return ankersolix.ModbusSnapshot{}, false + } + return snap, true +} diff --git a/API Server/internal/api/integrations_ankersolix_modbus_test.go b/API Server/internal/api/integrations_ankersolix_modbus_test.go new file mode 100644 index 0000000..c01c999 --- /dev/null +++ b/API Server/internal/api/integrations_ankersolix_modbus_test.go @@ -0,0 +1,71 @@ +package api + +import ( + "strings" + "testing" +) + +func TestValidateModbusAddressAcceptsLANAddresses(t *testing.T) { + for _, tc := range []struct { + host string + port int + }{ + {"10.2.1.55", 502}, + {"192.168.1.40", 0}, // 0 means the standard port + {"charger.local", 502}, + {"fd00::1", 502}, + } { + if err := validateModbusAddress(tc.host, tc.port); err != nil { + t.Errorf("validateModbusAddress(%q, %d) = %v, want nil", tc.host, tc.port, err) + } + } +} + +// The server dials this address on the caller's behalf, so an address that +// resolves back to the server would point it at its own services. +func TestValidateModbusAddressRefusesLoopback(t *testing.T) { + for _, host := range []string{"127.0.0.1", "::1", "localhost", "LocalHost", "0.0.0.0"} { + err := validateModbusAddress(host, 502) + if err == nil { + t.Errorf("validateModbusAddress(%q) was accepted", host) + continue + } + if !strings.Contains(err.Error(), "server itself") { + t.Errorf("validateModbusAddress(%q) = %v, want it to say why", host, err) + } + } +} + +func TestValidateModbusAddressRejectsMalformed(t *testing.T) { + cases := []struct { + name string + host string + port int + }{ + {"empty host", "", 502}, + {"a URL rather than a host", "http://10.2.1.55/", 502}, + {"host with a path", "10.2.1.55/modbus", 502}, + {"port above the range", "10.2.1.55", 70000}, + {"negative port", "10.2.1.55", -1}, + } + for _, tc := range cases { + if err := validateModbusAddress(tc.host, tc.port); err == nil { + t.Errorf("%s was accepted", tc.name) + } + } +} + +func TestAnkerModbusConfigTrimsAndDefaults(t *testing.T) { + cfg := ankerModbusConfig(ankerChargerBinding{ModbusHost: " 10.2.1.55 "}) + if cfg.Host != "10.2.1.55" { + t.Errorf("Host = %q, want it trimmed", cfg.Host) + } + if got := cfg.Address(); got != "10.2.1.55:502" { + t.Errorf("Address = %q, want the standard port filled in", got) + } + + cfg = ankerModbusConfig(ankerChargerBinding{ModbusHost: "10.2.1.55", ModbusPort: 1502}) + if got := cfg.Address(); got != "10.2.1.55:1502" { + t.Errorf("Address = %q, want 10.2.1.55:1502", got) + } +} diff --git a/API Server/internal/api/integrations_ankersolix_test.go b/API Server/internal/api/integrations_ankersolix_test.go index d9b410e..39e4db2 100644 --- a/API Server/internal/api/integrations_ankersolix_test.go +++ b/API Server/internal/api/integrations_ankersolix_test.go @@ -15,8 +15,10 @@ func TestNormalizeControlMode(t *testing.T) { "off": "off", "own": "own", "proxy": "proxy", + "modbus": "modbus", "OWN": "own", " Proxy": "proxy", + "Modbus ": "modbus", "": "", // unset — cascade continues to the next layer "bogus": "", // unknown — treated as unset } diff --git a/API Server/internal/api/server.go b/API Server/internal/api/server.go index f493430..7e9ea7d 100644 --- a/API Server/internal/api/server.go +++ b/API Server/internal/api/server.go @@ -451,6 +451,8 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("GET /api/integrations/anker-solix/chargers/{sn}/control", s.handleAnkerControlStatus) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlToken) mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/token", s.handleAnkerControlRevoke) + mux.HandleFunc("PUT /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress) + mux.HandleFunc("DELETE /api/integrations/anker-solix/chargers/{sn}/control/address", s.handleAnkerControlAddress) mux.HandleFunc("POST /api/integrations/anker-solix/chargers/{sn}/{action}", s.handleAnkerControlAction) // OCPP WebSocket endpoint the charger dials out to (own/proxy modes). It sits diff --git a/API Server/internal/modbus/client.go b/API Server/internal/modbus/client.go new file mode 100644 index 0000000..62e830d --- /dev/null +++ b/API Server/internal/modbus/client.go @@ -0,0 +1,284 @@ +// Package modbus is a minimal Modbus TCP client, written against the +// MODBUS Application Protocol Specification V1.1b3 and the MODBUS Messaging on +// TCP/IP Implementation Guide V1.0b rather than pulled in as a dependency: the +// whole API server is stdlib-only, so this sits beside internal/mqtt's +// hand-rolled MQTT and internal/ocpp's hand-rolled RFC 6455 WebSocket for the +// same reason. +// +// It is deliberately scoped to what the Anker SOLIX V1 EV charger needs +// (internal/plugins/builtin/ankersolix): read a contiguous block of holding +// registers, and write one register to issue a command. Concretely that means: +// +// - Function codes 0x03 (Read Holding Registers) and 0x06 (Write Single +// Register) only. The charger exposes its whole map — read-only and +// read-write alike — in one 2xxxx address space, which is the holding +// register convention. +// - One request in flight at a time. Modbus TCP allows pipelining by +// transaction identifier; nothing here needs it, and a strictly synchronous +// exchange means a desynchronised peer cannot silently mismatch replies. +// - No reconnect and no pooling. The charger accepts at most two simultaneous +// clients (Anker's Modbus spec, §"Activate Modbus TCP"), one of which an +// operator may well be using for a debugging tool, so a Client is meant to +// be opened for one exchange and closed again rather than held open. +// +// A Client is safe for concurrent use; requests are serialized. +package modbus + +import ( + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "net" + "sync" + "time" +) + +// Defaults applied by Options.normalize when a field is left zero. +const ( + defaultUnitID = 1 + defaultTimeout = 5 * time.Second + defaultConnectTimeout = 5 * time.Second +) + +// ErrClosed is returned once the connection is gone. +var ErrClosed = errors.New("modbus: connection closed") + +// Protocol constants. The MBAP header is seven bytes — transaction id (2), +// protocol id (2, always zero for Modbus), length (2), unit id (1) — and is +// followed by the PDU, whose first byte is the function code. +const ( + mbapLen = 7 + protocolID = 0 + fcReadHold = 0x03 + fcWriteReg = 0x06 + excMask = 0x80 // set on the echoed function code when the reply is an exception + maxReadRegs = 125 // §6.3: the largest quantity one FC03 request may ask for +) + +// maxPDU bounds what this client will read back from a peer. The spec's own +// ceiling is 253 bytes of PDU, so anything larger is a broken or hostile peer +// and the stream can no longer be framed. +const maxPDU = 253 + +// Options configures a connection. +type Options struct { + // Address is the charger's host:port. Required. Anker's Modbus TCP server + // listens on port 502. + Address string + // UnitID addresses a device behind a gateway. The charger answers directly, + // so this is 1 unless a deployment puts a bridge in the way. + UnitID byte + // Timeout bounds one request/response exchange. + Timeout time.Duration + // ConnectTimeout bounds the TCP dial. + ConnectTimeout time.Duration +} + +func (o *Options) normalize() { + if o.UnitID == 0 { + o.UnitID = defaultUnitID + } + if o.Timeout <= 0 { + o.Timeout = defaultTimeout + } + if o.ConnectTimeout <= 0 { + o.ConnectTimeout = defaultConnectTimeout + } +} + +// Error is a Modbus exception response: the server understood the frame and +// refused it. It is distinct from a transport failure, because it says something +// about the request (a bad address, an out-of-range value) rather than the link. +type Error struct { + Function byte + Code byte +} + +func (e *Error) Error() string { + return fmt.Sprintf("modbus: server rejected function 0x%02x: %s", e.Function, exceptionText(e.Code)) +} + +// exceptionText names the exception codes in §7 that this client can provoke. +func exceptionText(code byte) string { + switch code { + case 0x01: + return "illegal function" + case 0x02: + return "illegal data address" + case 0x03: + return "illegal data value" + case 0x04: + return "server device failure" + case 0x05: + return "acknowledge (request accepted, still processing)" + case 0x06: + return "server device busy" + case 0x0B: + return "gateway target device failed to respond" + default: + return fmt.Sprintf("exception code 0x%02x", code) + } +} + +// Client is a connected Modbus TCP session. +type Client struct { + conn net.Conn + unitID byte + timeout time.Duration + + mu sync.Mutex // serializes exchanges; one request is in flight at a time + nextTx uint16 + closed bool +} + +// Connect dials the server. The returned Client owns the connection; call Close +// when done. +func Connect(ctx context.Context, opt Options) (*Client, error) { + opt.normalize() + if opt.Address == "" { + return nil, errors.New("modbus: server address is required") + } + d := net.Dialer{Timeout: opt.ConnectTimeout} + conn, err := d.DialContext(ctx, "tcp", opt.Address) + if err != nil { + return nil, fmt.Errorf("modbus: cannot reach %s: %w", opt.Address, err) + } + return &Client{conn: conn, unitID: opt.UnitID, timeout: opt.Timeout}, nil +} + +// Close releases the connection. It is safe to call more than once, which +// matters because callers defer it and may also close on an error path. +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil + } + c.closed = true + return c.conn.Close() +} + +// ReadHolding reads count consecutive holding registers starting at addr +// (function code 0x03). Register values are returned in the order read. +func (c *Client) ReadHolding(ctx context.Context, addr, count uint16) ([]uint16, error) { + if count == 0 || count > maxReadRegs { + return nil, fmt.Errorf("modbus: cannot read %d registers in one request (1-%d)", count, maxReadRegs) + } + req := make([]byte, 5) + req[0] = fcReadHold + binary.BigEndian.PutUint16(req[1:], addr) + binary.BigEndian.PutUint16(req[3:], count) + + pdu, err := c.exchange(ctx, req) + if err != nil { + return nil, err + } + // Reply PDU: function code, byte count, then two bytes per register. + if len(pdu) < 2 { + return nil, errors.New("modbus: truncated read reply") + } + n := int(pdu[1]) + if n != int(count)*2 || len(pdu) < 2+n { + return nil, fmt.Errorf("modbus: read reply carries %d bytes, want %d", len(pdu)-2, int(count)*2) + } + out := make([]uint16, count) + for i := range out { + out[i] = binary.BigEndian.Uint16(pdu[2+i*2:]) + } + return out, nil +} + +// WriteSingle writes one holding register (function code 0x06). The server +// echoes the address and value it applied, which is verified here: a charger +// that clamps a value reports the clamp rather than silently diverging. +func (c *Client) WriteSingle(ctx context.Context, addr, value uint16) error { + req := make([]byte, 5) + req[0] = fcWriteReg + binary.BigEndian.PutUint16(req[1:], addr) + binary.BigEndian.PutUint16(req[3:], value) + + pdu, err := c.exchange(ctx, req) + if err != nil { + return err + } + if len(pdu) < 5 { + return errors.New("modbus: truncated write reply") + } + if got := binary.BigEndian.Uint16(pdu[1:]); got != addr { + return fmt.Errorf("modbus: write reply echoes register %d, want %d", got, addr) + } + if got := binary.BigEndian.Uint16(pdu[3:]); got != value { + return fmt.Errorf("modbus: register %d accepted %d, not the %d requested", addr, got, value) + } + return nil +} + +// exchange sends one PDU and returns the reply PDU, having checked the MBAP +// header, the transaction identifier and the exception bit. +func (c *Client) exchange(ctx context.Context, pdu []byte) ([]byte, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closed { + return nil, ErrClosed + } + + // A deadline drawn from both the context and the configured timeout, so a + // cancelled caller and a mute peer are both bounded. + deadline := time.Now().Add(c.timeout) + if d, ok := ctx.Deadline(); ok && d.Before(deadline) { + deadline = d + } + if err := c.conn.SetDeadline(deadline); err != nil { + return nil, err + } + + c.nextTx++ + tx := c.nextTx + + frame := make([]byte, mbapLen+len(pdu)) + binary.BigEndian.PutUint16(frame[0:], tx) + binary.BigEndian.PutUint16(frame[2:], protocolID) + // Length counts the unit id plus the PDU, per the messaging guide §3.1.3. + binary.BigEndian.PutUint16(frame[4:], uint16(len(pdu)+1)) + frame[6] = c.unitID + copy(frame[mbapLen:], pdu) + + if _, err := c.conn.Write(frame); err != nil { + return nil, fmt.Errorf("modbus: sending request: %w", err) + } + + var header [mbapLen]byte + if _, err := io.ReadFull(c.conn, header[:]); err != nil { + return nil, fmt.Errorf("modbus: reading reply header: %w", err) + } + if got := binary.BigEndian.Uint16(header[2:]); got != protocolID { + return nil, fmt.Errorf("modbus: reply has protocol id %d, want 0", got) + } + length := int(binary.BigEndian.Uint16(header[4:])) + if length < 2 || length-1 > maxPDU { + return nil, fmt.Errorf("modbus: reply announces %d bytes, which is not a valid PDU length", length) + } + body := make([]byte, length-1) // less the unit id, already in the header + if _, err := io.ReadFull(c.conn, body); err != nil { + return nil, fmt.Errorf("modbus: reading reply: %w", err) + } + if got := binary.BigEndian.Uint16(header[0:]); got != tx { + // Synchronous exchange, so this is a desynchronised stream rather than a + // late reply to something else; the connection is no longer trustworthy. + return nil, fmt.Errorf("modbus: reply is for transaction %d, want %d", got, tx) + } + + fc := body[0] + if fc&excMask != 0 { + if len(body) < 2 { + return nil, errors.New("modbus: exception reply carries no code") + } + return nil, &Error{Function: fc &^ excMask, Code: body[1]} + } + if fc != pdu[0] { + return nil, fmt.Errorf("modbus: reply is for function 0x%02x, want 0x%02x", fc, pdu[0]) + } + return body, nil +} diff --git a/API Server/internal/modbus/client_test.go b/API Server/internal/modbus/client_test.go new file mode 100644 index 0000000..93be4d2 --- /dev/null +++ b/API Server/internal/modbus/client_test.go @@ -0,0 +1,284 @@ +package modbus + +import ( + "context" + "encoding/binary" + "errors" + "io" + "net" + "testing" +) + +// serve runs a one-connection fake Modbus server. handle receives the request +// PDU and returns the reply PDU; returning nil closes the connection instead, +// which is how a mute or dying peer is simulated. The request frames the server +// saw are sent back on the returned channel once the connection ends. +func serve(t *testing.T, handle func(pdu []byte) []byte) (addr string, seen <-chan [][]byte) { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + t.Cleanup(func() { _ = ln.Close() }) + + frames := make(chan [][]byte, 1) + go func() { + var got [][]byte + defer func() { frames <- got }() + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + for { + var header [mbapLen]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return + } + length := int(binary.BigEndian.Uint16(header[4:])) + body := make([]byte, length-1) + if _, err := io.ReadFull(conn, body); err != nil { + return + } + got = append(got, append(append([]byte{}, header[:]...), body...)) + + reply := handle(body) + if reply == nil { + return + } + out := make([]byte, mbapLen+len(reply)) + copy(out[0:], header[0:2]) // echo the transaction id + binary.BigEndian.PutUint16(out[2:], protocolID) + binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1)) + out[6] = header[6] + copy(out[mbapLen:], reply) + if _, err := conn.Write(out); err != nil { + return + } + } + }() + return ln.Addr().String(), frames +} + +func dial(t *testing.T, addr string) *Client { + t.Helper() + c, err := Connect(context.Background(), Options{Address: addr}) + if err != nil { + t.Fatalf("connect: %v", err) + } + t.Cleanup(func() { _ = c.Close() }) + return c +} + +// readReply builds a well-formed FC03 reply carrying the given register values. +func readReply(values ...uint16) []byte { + out := []byte{fcReadHold, byte(len(values) * 2)} + for _, v := range values { + out = binary.BigEndian.AppendUint16(out, v) + } + return out +} + +func TestReadHoldingDecodesRegisters(t *testing.T) { + addr, seen := serve(t, func(pdu []byte) []byte { + return readReply(0x0102, 0x0304, 0xFFFF) + }) + c := dial(t, addr) + + got, err := c.ReadHolding(context.Background(), 20053, 3) + if err != nil { + t.Fatalf("ReadHolding: %v", err) + } + want := []uint16{0x0102, 0x0304, 0xFFFF} + if len(got) != len(want) { + t.Fatalf("got %d registers, want %d", len(got), len(want)) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("register %d = 0x%04x, want 0x%04x", i, got[i], want[i]) + } + } + + _ = c.Close() + frames := <-seen + if len(frames) != 1 { + t.Fatalf("server saw %d frames, want 1", len(frames)) + } + f := frames[0] + if pid := binary.BigEndian.Uint16(f[2:]); pid != protocolID { + t.Errorf("protocol id = %d, want 0", pid) + } + // Length counts the unit id plus the five-byte PDU. + if l := binary.BigEndian.Uint16(f[4:]); l != 6 { + t.Errorf("length field = %d, want 6", l) + } + if f[6] != defaultUnitID { + t.Errorf("unit id = %d, want %d", f[6], defaultUnitID) + } + if f[mbapLen] != fcReadHold { + t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcReadHold) + } + if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 20053 { + t.Errorf("address = %d, want 20053", a) + } + if n := binary.BigEndian.Uint16(f[mbapLen+3:]); n != 3 { + t.Errorf("quantity = %d, want 3", n) + } +} + +func TestReadHoldingRejectsBadCounts(t *testing.T) { + addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) }) + c := dial(t, addr) + + for _, count := range []uint16{0, maxReadRegs + 1} { + if _, err := c.ReadHolding(context.Background(), 20000, count); err == nil { + t.Errorf("count %d was accepted, want a refusal before the wire", count) + } + } +} + +func TestReadHoldingRejectsShortPayload(t *testing.T) { + // The server claims three registers but sends two. + addr, _ := serve(t, func(pdu []byte) []byte { + return []byte{fcReadHold, 6, 0x00, 0x01, 0x00, 0x02} + }) + c := dial(t, addr) + + if _, err := c.ReadHolding(context.Background(), 20000, 3); err == nil { + t.Fatal("a truncated payload was accepted") + } +} + +func TestWriteSingleEchoesAddressAndValue(t *testing.T) { + addr, seen := serve(t, func(pdu []byte) []byte { + return append([]byte{fcWriteReg}, pdu[1:5]...) // echo address + value + }) + c := dial(t, addr) + + if err := c.WriteSingle(context.Background(), 21000, 1); err != nil { + t.Fatalf("WriteSingle: %v", err) + } + + _ = c.Close() + frames := <-seen + if len(frames) != 1 { + t.Fatalf("server saw %d frames, want 1", len(frames)) + } + f := frames[0] + if f[mbapLen] != fcWriteReg { + t.Errorf("function code = 0x%02x, want 0x%02x", f[mbapLen], fcWriteReg) + } + if a := binary.BigEndian.Uint16(f[mbapLen+1:]); a != 21000 { + t.Errorf("address = %d, want 21000", a) + } + if v := binary.BigEndian.Uint16(f[mbapLen+3:]); v != 1 { + t.Errorf("value = %d, want 1", v) + } +} + +// A charger that clamps a written value reports the clamp. That must surface as +// an error rather than be mistaken for the value having been applied. +func TestWriteSingleRejectsClampedValue(t *testing.T) { + addr, _ := serve(t, func(pdu []byte) []byte { + reply := append([]byte{fcWriteReg}, pdu[1:5]...) + binary.BigEndian.PutUint16(reply[3:], 160) // clamped to 16.0 A + return reply + }) + c := dial(t, addr) + + err := c.WriteSingle(context.Background(), 21001, 320) + if err == nil { + t.Fatal("a clamped write was reported as successful") + } +} + +func TestWriteSingleRejectsWrongAddressEcho(t *testing.T) { + addr, _ := serve(t, func(pdu []byte) []byte { + reply := append([]byte{fcWriteReg}, pdu[1:5]...) + binary.BigEndian.PutUint16(reply[1:], 29999) + return reply + }) + c := dial(t, addr) + + if err := c.WriteSingle(context.Background(), 21000, 1); err == nil { + t.Fatal("a write echoing the wrong register was accepted") + } +} + +func TestExceptionReplyBecomesError(t *testing.T) { + addr, _ := serve(t, func(pdu []byte) []byte { + return []byte{fcReadHold | excMask, 0x02} // illegal data address + }) + c := dial(t, addr) + + _, err := c.ReadHolding(context.Background(), 29999, 1) + var mbErr *Error + if !errors.As(err, &mbErr) { + t.Fatalf("error = %v, want a *modbus.Error", err) + } + if mbErr.Function != fcReadHold { + t.Errorf("Function = 0x%02x, want 0x%02x", mbErr.Function, fcReadHold) + } + if mbErr.Code != 0x02 { + t.Errorf("Code = 0x%02x, want 0x02", mbErr.Code) + } +} + +// A reply carrying someone else's transaction id means the stream is out of +// step, so it must not be handed back as this request's answer. +func TestMismatchedTransactionIDIsRejected(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + defer ln.Close() + go func() { + conn, err := ln.Accept() + if err != nil { + return + } + defer conn.Close() + var header [mbapLen]byte + if _, err := io.ReadFull(conn, header[:]); err != nil { + return + } + body := make([]byte, int(binary.BigEndian.Uint16(header[4:]))-1) + if _, err := io.ReadFull(conn, body); err != nil { + return + } + reply := readReply(1) + out := make([]byte, mbapLen+len(reply)) + binary.BigEndian.PutUint16(out[0:], binary.BigEndian.Uint16(header[0:])+7) // wrong + binary.BigEndian.PutUint16(out[2:], protocolID) + binary.BigEndian.PutUint16(out[4:], uint16(len(reply)+1)) + out[6] = header[6] + copy(out[mbapLen:], reply) + _, _ = conn.Write(out) + }() + + c := dial(t, ln.Addr().String()) + if _, err := c.ReadHolding(context.Background(), 20000, 1); err == nil { + t.Fatal("a reply for another transaction was accepted") + } +} + +func TestClosedClientRefusesRequests(t *testing.T) { + addr, _ := serve(t, func(pdu []byte) []byte { return readReply(0) }) + c := dial(t, addr) + if err := c.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + if _, err := c.ReadHolding(context.Background(), 20000, 1); !errors.Is(err, ErrClosed) { + t.Fatalf("error = %v, want ErrClosed", err) + } + // Close is deferred by callers and may also run on an error path. + if err := c.Close(); err != nil { + t.Fatalf("second Close: %v", err) + } +} + +func TestConnectRequiresAddress(t *testing.T) { + if _, err := Connect(context.Background(), Options{}); err == nil { + t.Fatal("an empty address was accepted") + } +} diff --git a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go index 181c809..0db72af 100644 --- a/API Server/internal/plugins/builtin/ankersolix/ankersolix.go +++ b/API Server/internal/plugins/builtin/ankersolix/ankersolix.go @@ -209,9 +209,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 over OCPP. Off = monitoring only (default). Own CSMS = the charger connects directly to DriverVault. Proxy CSMS = DriverVault relays to Anker's cloud and can inject commands.", + 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.", Options: []plugins.SelectOption{ {Value: "off", Label: "Off (monitoring only)"}, + {Value: "modbus", Label: "Modbus TCP (local network)"}, {Value: "own", Label: "Own CSMS (full control)"}, {Value: "proxy", Label: "Proxy CSMS (relay + control)"}, }}, diff --git a/API Server/internal/plugins/builtin/ankersolix/modbus.go b/API Server/internal/plugins/builtin/ankersolix/modbus.go new file mode 100644 index 0000000..527b41e --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/modbus.go @@ -0,0 +1,397 @@ +package ankersolix + +// Local control over Modbus TCP, the one path Anker documents publicly for this +// charger ("Anker SOLIX V1 Smart EV Charger Modbus Protocol", V1.0.0, +// 30-11-2025). It is the counterpart to the read-only cloud plugin in +// ankersolix.go: same device, same status enum, but reached over the LAN with no +// account, no cloud round-trip and no inbound connection for the charger to make +// — which is what rules OCPP out wherever the charger cannot dial us. +// +// The owner enables it in the Anker app under Settings > Integrations > Modbus +// TCP; the app then shows the charger's local IP and port. Two caveats from the +// spec that shape the code here: +// +// - At most two clients may be connected at once, and an operator's debugging +// tool may well be one of them. Connections are therefore opened per +// operation and closed again, never pooled. +// - Charging pauses on its own whenever the current is set below 6 A, so a +// limit under that floor is a pause, not a slow charge. SetMaxCurrent says so +// rather than letting a caller discover it. +// +// The spec tabulates addresses, types and gains but does not name the function +// codes; the whole map lives in one 2xxxx space with RO and RW entries side by +// side, which is the holding-register convention, so FC03/FC06 is what this uses. + +import ( + "context" + "encoding/binary" + "fmt" + "strings" + "time" + + "drivervault/apiserver/internal/modbus" +) + +// Register addresses from the protocol spec. Identity and configuration sit in +// 20000-20040, live measurements and state in 20041-20100, and the writable +// controls in 21000-21005. +const ( + regProductNumber = 20000 // UINT16 + regModelName = 20001 // STRING, 10 registers + regSerialNumber = 20011 // STRING, 12 registers + regSoftwareVersion = 20023 // STRING, 6 registers + regHardwareVersion = 20029 // STRING, 6 registers + regRatedPower = 20035 // INT32, W + regMinOutCurrent = 20037 // INT32 + regMaxOutCurrent = 20039 // INT32 + + regAlarm1 = 20041 // UINT16 x12, each bit an alarm + + regVoltageL1N = 20053 // UINT16, gain 10 + regCurrentL1 = 20059 // UINT16, gain 100 + regPowerL1 = 20062 // UINT32, W + regPowerTotal = 20068 // UINT32, W + regSessionSec = 20082 // UINT32, s + regSessionWh = 20084 // UINT32, Wh + + regPWMEnabled = 20086 + regPhaseMode = 20087 + regChargingMode = 20088 // 0 solar+grid, 1 solar only + regLoadBalancing = 20089 + regSolarBalancing = 20090 + regCPVoltage = 20091 + regCPSignal = 20092 + regRelay1Temp = 20093 // INT16, degC + regRelay2Temp = 20094 // INT16, degC + regBoostMode = 20095 + regLEDBrightness = 20096 // % + regChargingStatus = 20097 // same 0-8 enum as the cloud's operating_state + regOCPPStatus = 20099 // 0 not connected, 1 connecting, 2 connected + regMQTTStatus = 20100 // 0 not connected, 1 connected + + regChargingCommand = 21000 // 1 start, 2 stop + regMaxCurrentSet = 21001 // gain 10, i.e. deciamps + regBoostSet = 21002 // 1 on, for the current session only + regTimeoutSet = 21003 // seconds, must exceed 5 + regPhaseCountSet = 21005 // 0 automatic, 1 fixed single, 2 fixed three +) + +// The two blocks read in one request each. Both are well inside FC03's limit of +// 125 registers, and splitting them keeps the hot path (live state) small. +const ( + identityStart = regProductNumber + identityCount = 41 // 20000-20040 + liveStart = regAlarm1 + liveCount = 60 // 20041-20100 +) + +// Charging command values for regChargingCommand. +const ( + cmdStartCharging = 1 + cmdStopCharging = 2 +) + +// currentPauseFloor is the current below which the charger stops drawing power +// on its own, per the spec's note on the maximum-current register. +const currentPauseFloor = 6.0 + +// cpSignalNames decodes regCPSignal — the control-pilot state, which says what +// the vehicle side of the cable is doing independently of the charger's own +// status. +var cpSignalNames = map[int]string{ + 0: "A (12V, not connected)", 3: "B1 (9V)", 4: "B2 (9V)", + 5: "C1 (6V, charging)", 6: "C2 (6V, charging)", 7: "error", + 8: "D1 (3V)", 9: "D2 (3V)", 10: "E (0V)", 11: "F (-12V)", +} + +// The two connection-status registers do not share an encoding: OCPP has a +// three-state one (and matches SolixOcppConnectionStatus in the cloud plugin), +// while MQTT is a plain boolean. Decoding both through one table would report a +// connected broker as "connecting". +var ( + ocppStatusNames = map[int]string{0: "disconnected", 1: "connecting", 2: "connected"} + mqttStatusNames = map[int]string{0: "disconnected", 1: "connected"} +) + +// ModbusConfig addresses one charger's local Modbus TCP server. +type ModbusConfig struct { + Host string + Port int + UnitID byte +} + +// Address is the host:port this charger is dialled at, defaulting to Modbus's +// standard port when none was saved. +func (c ModbusConfig) Address() string { + port := c.Port + if port == 0 { + port = 502 + } + return fmt.Sprintf("%s:%d", strings.TrimSpace(c.Host), port) +} + +// ModbusSnapshot is the charger's live state as the register map reports it. A +// field the charger did not supply stays nil rather than zero, matching how +// accountCharger treats a value no cloud view knew. +type ModbusSnapshot struct { + // Identity, present only when the identity block was read too. + Model string `json:"model,omitempty"` + Serial string `json:"serial,omitempty"` + Firmware string `json:"firmware,omitempty"` + Hardware string `json:"hardware,omitempty"` + + Status *int `json:"status,omitempty"` + StatusDesc string `json:"statusDesc,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 *uint32 `json:"powerL1,omitempty"` + PowerL2 *uint32 `json:"powerL2,omitempty"` + PowerL3 *uint32 `json:"powerL3,omitempty"` + PowerTotal *uint32 `json:"powerTotal,omitempty"` + + SessionSeconds *uint32 `json:"sessionSeconds,omitempty"` + SessionWh *uint32 `json:"sessionWh,omitempty"` + + PhaseMode *int `json:"phaseMode,omitempty"` + ChargingMode *int `json:"chargingMode,omitempty"` + LoadBalancing *bool `json:"loadBalancing,omitempty"` + SolarBalancing *bool `json:"solarBalancing,omitempty"` + BoostMode *bool `json:"boostMode,omitempty"` + LEDBrightness *int `json:"ledBrightness,omitempty"` + CPSignal *int `json:"cpSignal,omitempty"` + CPSignalDesc string `json:"cpSignalDesc,omitempty"` + + Relay1TempC *int `json:"relay1TempC,omitempty"` + Relay2TempC *int `json:"relay2TempC,omitempty"` + + OcppStatus *int `json:"ocppStatus,omitempty"` + OcppStatusDesc string `json:"ocppStatusDesc,omitempty"` + MqttStatus *int `json:"mqttStatus,omitempty"` + MqttStatusDesc string `json:"mqttStatusDesc,omitempty"` + + // Alarms holds the twelve alarm words verbatim. The spec defers the bit + // meanings to a separate alarm list, so they are surfaced undecoded rather + // than guessed at; Alarm reports whether any bit is set at all. + Alarms []uint16 `json:"alarms,omitempty"` + Alarm bool `json:"alarm"` +} + +// ModbusDial opens a connection to one charger. Callers must Close it; the +// charger only tolerates two clients at a time. +func ModbusDial(ctx context.Context, cfg ModbusConfig) (*modbus.Client, error) { + if strings.TrimSpace(cfg.Host) == "" { + return nil, fmt.Errorf("anker-solix: the charger's local IP address is required for Modbus control") + } + return modbus.Connect(ctx, modbus.Options{ + Address: cfg.Address(), + UnitID: cfg.UnitID, + Timeout: 5 * time.Second, + ConnectTimeout: 5 * time.Second, + }) +} + +// ModbusRead returns the charger's live state. withIdentity also reads the +// slower-moving identity block (model, serial, firmware), which is worth a +// second request on a first poll but not on every one. +func ModbusRead(ctx context.Context, c *modbus.Client, withIdentity bool) (ModbusSnapshot, error) { + var snap ModbusSnapshot + + live, err := c.ReadHolding(ctx, liveStart, liveCount) + if err != nil { + return snap, err + } + decodeLive(&snap, live) + + if withIdentity { + ident, err := c.ReadHolding(ctx, identityStart, identityCount) + if err != nil { + // Identity is a nicety; live state is the point. Report what we have. + return snap, nil + } + decodeIdentity(&snap, ident) + } + return snap, nil +} + +// decodeLive fills the snapshot from the 20041-20100 block. +func decodeLive(snap *ModbusSnapshot, regs []uint16) { + at := func(addr int) (uint16, bool) { + i := addr - liveStart + if i < 0 || i >= len(regs) { + return 0, false + } + return regs[i], true + } + u32 := func(addr int) (uint32, bool) { + hi, ok1 := at(addr) + lo, ok2 := at(addr + 1) + if !ok1 || !ok2 { + return 0, false + } + return uint32(hi)<<16 | uint32(lo), true + } + scaled := func(addr int, gain float64) *float64 { + v, ok := at(addr) + if !ok { + return nil + } + f := float64(v) / gain + return &f + } + word := func(addr int) *uint32 { + v, ok := u32(addr) + if !ok { + return nil + } + return &v + } + num := func(addr int) *int { + v, ok := at(addr) + if !ok { + return nil + } + n := int(v) + return &n + } + flag := func(addr int) *bool { + v, ok := at(addr) + if !ok { + return nil + } + b := v != 0 + return &b + } + degrees := func(addr int) *int { + v, ok := at(addr) + if !ok { + return nil + } + n := int(int16(v)) // signed: the relays can read below zero + return &n + } + + snap.VoltageL1 = scaled(regVoltageL1N, 10) + snap.VoltageL2 = scaled(regVoltageL1N+1, 10) + snap.VoltageL3 = scaled(regVoltageL1N+2, 10) + snap.CurrentL1 = scaled(regCurrentL1, 100) + snap.CurrentL2 = scaled(regCurrentL1+1, 100) + snap.CurrentL3 = scaled(regCurrentL1+2, 100) + + snap.PowerL1 = word(regPowerL1) + snap.PowerL2 = word(regPowerL1 + 2) + snap.PowerL3 = word(regPowerL1 + 4) + snap.PowerTotal = word(regPowerTotal) + snap.SessionSeconds = word(regSessionSec) + snap.SessionWh = word(regSessionWh) + + snap.PhaseMode = num(regPhaseMode) + snap.ChargingMode = num(regChargingMode) + snap.LoadBalancing = flag(regLoadBalancing) + snap.SolarBalancing = flag(regSolarBalancing) + snap.BoostMode = flag(regBoostMode) + snap.LEDBrightness = num(regLEDBrightness) + snap.Relay1TempC = degrees(regRelay1Temp) + snap.Relay2TempC = degrees(regRelay2Temp) + + if v := num(regCPSignal); v != nil { + snap.CPSignal, snap.CPSignalDesc = v, cpSignalNames[*v] + } + if v := num(regChargingStatus); v != nil { + snap.Status, snap.StatusDesc = v, statusName(*v) + } + if v := num(regOCPPStatus); v != nil { + snap.OcppStatus, snap.OcppStatusDesc = v, ocppStatusNames[*v] + } + if v := num(regMQTTStatus); v != nil { + snap.MqttStatus, snap.MqttStatusDesc = v, mqttStatusNames[*v] + } + + for addr := regAlarm1; addr < regAlarm1+12; addr++ { + v, ok := at(addr) + if !ok { + break + } + snap.Alarms = append(snap.Alarms, v) + if v != 0 { + snap.Alarm = true + } + } +} + +// decodeIdentity fills the snapshot from the 20000-20040 block. +func decodeIdentity(snap *ModbusSnapshot, regs []uint16) { + text := func(addr, count int) string { + i := addr - identityStart + if i < 0 || i+count > len(regs) { + return "" + } + b := make([]byte, 0, count*2) + for _, r := range regs[i : i+count] { + b = binary.BigEndian.AppendUint16(b, r) + } + return strings.TrimSpace(strings.TrimRight(string(b), "\x00")) + } + snap.Model = text(regModelName, 10) + snap.Serial = text(regSerialNumber, 12) + snap.Firmware = text(regSoftwareVersion, 6) + snap.Hardware = text(regHardwareVersion, 6) +} + +// ModbusStartCharging asks the charger to begin a session. +func ModbusStartCharging(ctx context.Context, c *modbus.Client) error { + return c.WriteSingle(ctx, regChargingCommand, cmdStartCharging) +} + +// ModbusStopCharging asks the charger to end the current session. +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 { + 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 c.WriteSingle(ctx, regMaxCurrentSet, uint16(amps*10)) +} + +// ModbusSetBoost turns boost on for the current session; the charger clears it +// again when the session ends. +func ModbusSetBoost(ctx context.Context, c *modbus.Client, on bool) error { + var v uint16 + if on { + v = 1 + } + return c.WriteSingle(ctx, regBoostSet, v) +} + +// ModbusSetPhaseMode fixes the charger to single- or three-phase, or hands the +// choice back to it. 0 automatic, 1 fixed single-phase, 2 fixed three-phase. +func ModbusSetPhaseMode(ctx context.Context, c *modbus.Client, mode int) error { + if mode < 0 || mode > 2 { + return fmt.Errorf("anker-solix: phase mode %d is not one of 0 (automatic), 1 (single) or 2 (three)", mode) + } + return c.WriteSingle(ctx, regPhaseCountSet, uint16(mode)) +} + +// ModbusSetTimeout sets the control timeout: if no Modbus client writes within +// it, the charger falls back to its own strategy. The spec requires more than +// five seconds. +func ModbusSetTimeout(ctx context.Context, c *modbus.Client, seconds int) error { + if seconds <= 5 { + return fmt.Errorf("anker-solix: the Modbus timeout must be more than 5 seconds, not %d", seconds) + } + return c.WriteSingle(ctx, regTimeoutSet, uint16(seconds)) +} diff --git a/API Server/internal/plugins/builtin/ankersolix/modbus_test.go b/API Server/internal/plugins/builtin/ankersolix/modbus_test.go new file mode 100644 index 0000000..06cae86 --- /dev/null +++ b/API Server/internal/plugins/builtin/ankersolix/modbus_test.go @@ -0,0 +1,237 @@ +package ankersolix + +import ( + "context" + "encoding/binary" + "strings" + "testing" +) + +// liveBlock builds a 20041-20100 register block with every register zero, ready +// for a test to set the ones it cares about by address. +type liveBlock []uint16 + +func newLiveBlock() liveBlock { return make(liveBlock, liveCount) } + +func (b liveBlock) set(addr int, v uint16) { b[addr-liveStart] = v } + +func (b liveBlock) set32(addr int, v uint32) { + b[addr-liveStart] = uint16(v >> 16) + b[addr-liveStart+1] = uint16(v) +} + +func TestDecodeLiveScalesMeasurements(t *testing.T) { + b := newLiveBlock() + b.set(regVoltageL1N, 2301) // 230.1 V, gain 10 + b.set(regVoltageL1N+1, 2312) // 231.2 V + b.set(regVoltageL1N+2, 2298) // 229.8 V + b.set(regCurrentL1, 1600) // 16.00 A, gain 100 + b.set(regCurrentL1+1, 1598) // 15.98 A + b.set(regCurrentL1+2, 0) + b.set32(regPowerL1, 3680) + b.set32(regPowerTotal, 11040) + b.set32(regSessionSec, 3725) + b.set32(regSessionWh, 12500) + + var snap ModbusSnapshot + decodeLive(&snap, b) + + if snap.VoltageL1 == nil || *snap.VoltageL1 != 230.1 { + t.Errorf("VoltageL1 = %v, want 230.1", snap.VoltageL1) + } + if snap.VoltageL3 == nil || *snap.VoltageL3 != 229.8 { + t.Errorf("VoltageL3 = %v, want 229.8", snap.VoltageL3) + } + if snap.CurrentL1 == nil || *snap.CurrentL1 != 16.0 { + t.Errorf("CurrentL1 = %v, want 16.0", snap.CurrentL1) + } + if snap.CurrentL2 == nil || *snap.CurrentL2 != 15.98 { + t.Errorf("CurrentL2 = %v, want 15.98", snap.CurrentL2) + } + // The 32-bit values straddle two registers; a swapped pair would read as a + // wildly different number rather than a near miss. + if snap.PowerL1 == nil || *snap.PowerL1 != 3680 { + t.Errorf("PowerL1 = %v, want 3680", snap.PowerL1) + } + if snap.PowerTotal == nil || *snap.PowerTotal != 11040 { + t.Errorf("PowerTotal = %v, want 11040", snap.PowerTotal) + } + if snap.SessionSeconds == nil || *snap.SessionSeconds != 3725 { + t.Errorf("SessionSeconds = %v, want 3725", snap.SessionSeconds) + } + if snap.SessionWh == nil || *snap.SessionWh != 12500 { + t.Errorf("SessionWh = %v, want 12500", snap.SessionWh) + } +} + +func TestDecodeLiveNamesStates(t *testing.T) { + b := newLiveBlock() + b.set(regChargingStatus, 2) // charging + b.set(regCPSignal, 5) // C1, vehicle drawing + b.set(regOCPPStatus, 2) // connected + b.set(regMQTTStatus, 1) // connected + b.set(regBoostMode, 1) + b.set(regLoadBalancing, 0) + b.set(regLEDBrightness, 70) + + var snap ModbusSnapshot + decodeLive(&snap, b) + + // The Modbus status enum is the same one the cloud reports, so it must decode + // to the same names the rest of the plugin already uses. + if snap.Status == nil || *snap.Status != 2 || snap.StatusDesc != stateCharging { + t.Errorf("status = %v/%q, want 2/%q", snap.Status, snap.StatusDesc, stateCharging) + } + if snap.CPSignalDesc != cpSignalNames[5] { + t.Errorf("CPSignalDesc = %q, want %q", snap.CPSignalDesc, cpSignalNames[5]) + } + if snap.OcppStatusDesc != "connected" { + t.Errorf("OcppStatusDesc = %q, want connected", snap.OcppStatusDesc) + } + // Register value 1 means "connected" for MQTT but only "connecting" for OCPP; + // the two registers must not be decoded through one table. + if snap.MqttStatusDesc != "connected" { + t.Errorf("MqttStatusDesc = %q, want connected", snap.MqttStatusDesc) + } + if snap.BoostMode == nil || !*snap.BoostMode { + t.Errorf("BoostMode = %v, want true", snap.BoostMode) + } + if snap.LoadBalancing == nil || *snap.LoadBalancing { + t.Errorf("LoadBalancing = %v, want false", snap.LoadBalancing) + } + if snap.LEDBrightness == nil || *snap.LEDBrightness != 70 { + t.Errorf("LEDBrightness = %v, want 70", snap.LEDBrightness) + } +} + +func TestDecodeLiveReadsNegativeTemperatures(t *testing.T) { + b := newLiveBlock() + below := int16(-7) + b.set(regRelay1Temp, uint16(below)) + b.set(regRelay2Temp, 41) + + var snap ModbusSnapshot + decodeLive(&snap, b) + + if snap.Relay1TempC == nil || *snap.Relay1TempC != -7 { + t.Errorf("Relay1TempC = %v, want -7", snap.Relay1TempC) + } + if snap.Relay2TempC == nil || *snap.Relay2TempC != 41 { + t.Errorf("Relay2TempC = %v, want 41", snap.Relay2TempC) + } +} + +func TestDecodeLiveFlagsAlarms(t *testing.T) { + quiet := newLiveBlock() + var snap ModbusSnapshot + decodeLive(&snap, quiet) + if snap.Alarm { + t.Error("Alarm set with every alarm word zero") + } + if len(snap.Alarms) != 12 { + t.Errorf("got %d alarm words, want 12", len(snap.Alarms)) + } + + noisy := newLiveBlock() + noisy.set(regAlarm1+7, 0x0040) + var snap2 ModbusSnapshot + decodeLive(&snap2, noisy) + if !snap2.Alarm { + t.Error("Alarm not set although an alarm bit is on") + } + if snap2.Alarms[7] != 0x0040 { + t.Errorf("alarm word 8 = 0x%04x, want 0x0040", snap2.Alarms[7]) + } +} + +// A short block must leave fields absent rather than decode whatever follows. +func TestDecodeLiveToleratesShortBlock(t *testing.T) { + var snap ModbusSnapshot + decodeLive(&snap, []uint16{1, 2, 3}) + if snap.Status != nil { + t.Errorf("Status = %v, want nil from a truncated block", snap.Status) + } + if snap.PowerTotal != nil { + t.Errorf("PowerTotal = %v, want nil from a truncated block", snap.PowerTotal) + } +} + +func TestDecodeIdentityReadsStrings(t *testing.T) { + regs := make([]uint16, identityCount) + put := func(addr int, s string, count int) { + b := []byte(s) + for len(b) < count*2 { + b = append(b, 0) + } + for i := 0; i < count; i++ { + regs[addr-identityStart+i] = binary.BigEndian.Uint16(b[i*2:]) + } + } + put(regModelName, "A5191", 10) + put(regSerialNumber, "V1SN0123456789AB", 12) + put(regSoftwareVersion, "1.2.3", 6) + put(regHardwareVersion, "1.0", 6) + + var snap ModbusSnapshot + decodeIdentity(&snap, regs) + + if snap.Model != "A5191" { + t.Errorf("Model = %q, want A5191", snap.Model) + } + if snap.Serial != "V1SN0123456789AB" { + t.Errorf("Serial = %q, want V1SN0123456789AB", snap.Serial) + } + if snap.Firmware != "1.2.3" { + t.Errorf("Firmware = %q, want 1.2.3", snap.Firmware) + } + if snap.Hardware != "1.0" { + t.Errorf("Hardware = %q, want 1.0", snap.Hardware) + } +} + +// Below 6 A the charger stops instead of charging slowly, so a limit in that +// range has to be refused rather than quietly turned into a pause. +func TestModbusSetMaxCurrentRefusesBelowFloor(t *testing.T) { + err := ModbusSetMaxCurrent(context.Background(), nil, 4) + if err == nil { + t.Fatal("4 A was accepted") + } + if !strings.Contains(err.Error(), "pauses charging") { + t.Errorf("error = %q, want it to explain the pause", err) + } +} + +func TestModbusSetMaxCurrentRefusesOutOfRange(t *testing.T) { + for _, amps := range []float64{-1, 40} { + if err := ModbusSetMaxCurrent(context.Background(), nil, amps); err == nil { + t.Errorf("%.0f A was accepted", amps) + } + } +} + +func TestModbusSetPhaseModeRejectsUnknownMode(t *testing.T) { + if err := ModbusSetPhaseMode(context.Background(), nil, 3); err == nil { + t.Fatal("phase mode 3 was accepted") + } +} + +func TestModbusSetTimeoutEnforcesFloor(t *testing.T) { + if err := ModbusSetTimeout(context.Background(), nil, 5); err == nil { + t.Fatal("a 5 second timeout was accepted, but the spec requires more than 5") + } +} + +func TestModbusConfigDefaultsToPort502(t *testing.T) { + if got := (ModbusConfig{Host: "10.2.1.55"}).Address(); got != "10.2.1.55:502" { + t.Errorf("address = %q, want 10.2.1.55:502", got) + } + if got := (ModbusConfig{Host: "10.2.1.55", Port: 1502}).Address(); got != "10.2.1.55:1502" { + t.Errorf("address = %q, want 10.2.1.55:1502", got) + } +} + +func TestModbusDialRequiresHost(t *testing.T) { + if _, err := ModbusDial(context.Background(), ModbusConfig{}); err == nil { + t.Fatal("an empty host was accepted") + } +} diff --git a/API Server/panel/src/App.vue b/API Server/panel/src/App.vue index c3de40b..9bcd65f 100644 --- a/API Server/panel/src/App.vue +++ b/API Server/panel/src/App.vue @@ -200,10 +200,12 @@ const integrationsApi = [ { method: "PUT", path: "/api/integrations/anker-solix", desc: "Save the caller's own layer (user or org scope)" }, { method: "POST", path: "/api/integrations/anker-solix/health", desc: "Live probe with the resolved credentials" }, { method: "GET", path: "/api/integrations/anker-solix/chargers", desc: "EV chargers on the linked Anker account" }, - { method: "GET", path: "/api/integrations/anker-solix/chargers/{sn}/control", desc: "Control mode, token state and live CSMS session for one charger" }, + { method: "GET", path: "/api/integrations/anker-solix/chargers/{sn}/control", desc: "Control mode, token state and live session or Modbus snapshot for one charger" }, { method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/control/token", desc: "(Re)issue the charger's control token" }, { method: "DELETE", path: "/api/integrations/anker-solix/chargers/{sn}/control/token", desc: "Revoke the control token" }, - { method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/{action}", desc: "One OCPP command: start, stop, limit, clear-limit, availability, reset, unlock, trigger, config" }, + { method: "PUT", path: "/api/integrations/anker-solix/chargers/{sn}/control/address", desc: "Save the charger's local Modbus TCP address" }, + { method: "DELETE", path: "/api/integrations/anker-solix/chargers/{sn}/control/address", desc: "Forget the charger's local Modbus TCP address" }, + { method: "POST", path: "/api/integrations/anker-solix/chargers/{sn}/{action}", desc: "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" }, { method: "GET", path: "/api/integrations/greencell", desc: "Resolved Greencell settings (secrets masked)" }, { method: "PUT", path: "/api/integrations/greencell", desc: "Save the caller's own layer (user or org scope)" }, { method: "POST", path: "/api/integrations/greencell/health", desc: "Live probe: connect to the broker and broadcast for devices" },