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 }