diff --git a/charger/chargepoint.go b/charger/chargepoint.go new file mode 100644 index 0000000000..db6a05fa8f --- /dev/null +++ b/charger/chargepoint.go @@ -0,0 +1,134 @@ +package charger + +import ( + "fmt" + "strconv" + "time" + + "github.com/evcc-io/evcc/api" + cpkg "github.com/evcc-io/evcc/charger/chargepoint" + "github.com/evcc-io/evcc/util" +) + +var _ api.Charger = (*ChargePoint)(nil) + +func init() { + registry.Add("chargepoint", NewChargePointFromConfig) +} + +// ChargePoint implements the api.Charger interface for ChargePoint Home Flex chargers. +type ChargePoint struct { + *cpkg.API + deviceID int + enabled bool + statusG util.Cacheable[cpkg.HomeChargerStatus] +} + +// NewChargePointFromConfig creates a ChargePoint charger from generic config. +func NewChargePointFromConfig(other map[string]any) (api.Charger, error) { + cc := struct { + DeviceID int + User string + Password string + MinCurrent int64 + MaxCurrent int64 + Cache time.Duration + }{ + MinCurrent: 8, + MaxCurrent: 48, + Cache: 30 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.User == "" || cc.Password == "" { + return nil, api.ErrMissingCredentials + } + + return NewChargePoint(cc.DeviceID, cc.User, cc.Password, cc.MinCurrent, cc.MaxCurrent, cc.Cache) +} + +// NewChargePoint creates a ChargePoint Home Flex charger. +func NewChargePoint(deviceID int, user, password string, minCurrent, maxCurrent int64, cache time.Duration) (api.Charger, error) { + log := util.NewLogger("chargepoint").Redact(user, password) + + identity, err := cpkg.NewIdentity(log, user, password) + if err != nil { + return nil, fmt.Errorf("identity: %w", err) + } + + api := cpkg.NewAPI(log, identity) + + id := "" + if deviceID != 0 { + id = strconv.Itoa(deviceID) + } + foundID, err := ensureChargerEx(id, api.HomeChargerIDs, func(v int) (string, error) { + return strconv.Itoa(v), nil + }) + if err != nil { + return nil, fmt.Errorf("charger: %w", err) + } + deviceID = foundID + + cp := &ChargePoint{ + API: api, + deviceID: deviceID, + } + + cp.statusG = util.ResettableCached(func() (cpkg.HomeChargerStatus, error) { + return cp.API.HomeChargerStatus(cp.deviceID) + }, cache) + + return cp, nil +} + +// Status implements the api.Charger interface. +func (c *ChargePoint) Status() (api.ChargeStatus, error) { + res, err := c.statusG.Get() + if err != nil { + return api.StatusNone, err + } + + switch { + case res.ChargingStatus == "CHARGING": + return api.StatusC, nil + case res.IsPluggedIn: + return api.StatusB, nil // Connected + default: + return api.StatusA, nil // Disconnected + } +} + +// Enabled implements the api.Charger interface. +func (c *ChargePoint) Enabled() (bool, error) { + return verifyEnabled(c, c.enabled) +} + +// Enable implements the api.Charger interface. +func (c *ChargePoint) Enable(enable bool) error { + api := c.API.StopCharging + if enable { + api = c.API.StartCharging + } + err := api(c.deviceID) + if err != nil { + return err + } + + c.enabled = enable + c.statusG.Reset() + return nil +} + +// MaxCurrent implements the api.Charger interface. +func (c *ChargePoint) MaxCurrent(current int64) error { + if err := c.API.SetAmperageLimit(c.deviceID, current); err != nil { + return err + } + + c.statusG.Reset() + return nil +} diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go new file mode 100644 index 0000000000..2f4cab1243 --- /dev/null +++ b/charger/chargepoint/api.go @@ -0,0 +1,247 @@ +package chargepoint + +import ( + "errors" + "fmt" + "net/http" + "time" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +// wsUserAgent is the User-Agent for webservices.chargepoint.com calls, +// matching the iOS app's WKWebView requests. +const wsUserAgent = "ChargePoint/664 (iPhone; iOS 26.3; Scale/3.00)" + +// API is an HTTP client for the ChargePoint API. +type API struct { + log *util.Logger + identity *Identity +} + +// NewAPI creates a ChargePoint API client. +func NewAPI(log *util.Logger, identity *Identity) *API { + return &API{ + log: log, + identity: identity, + } +} + +// cpHeaders returns the standard CP headers required by all API endpoints. +// Cookies are set explicitly because the cookie jar is empty after a settings +// restore and the app always sends them as static header values. +func (a *API) cpHeaders() map[string]string { + return map[string]string{ + "User-Agent": userAgent, + "CP-Region": a.identity.Region, + "CP-Session-Token": a.identity.SessionID, + "CP-Session-Type": "CP_SESSION_TOKEN", + "Cache-Control": "no-store", + "Accept-Language": "en;q=1", + "Cookie": "coulomb_sess=" + a.identity.SessionID + "; auth-session=" + a.identity.SSOSessionID, + } +} + +// cpWSHeaders returns CP headers for webservices.chargepoint.com calls, +// which require a different User-Agent from the native app endpoints. +func (a *API) cpWSHeaders() map[string]string { + h := a.cpHeaders() + h["User-Agent"] = wsUserAgent + return h +} + +// cpInternalHeaders returns CP headers for internal-api calls, which +// additionally require an Authorization bearer token. +func (a *API) cpInternalHeaders() map[string]string { + h := a.cpHeaders() + h["Authorization"] = "Bearer " + a.identity.SSOSessionID + return h +} + +// doJSON executes the request produced by makeReq. If the server returns 401, +// it re-authenticates and retries once with a freshly-built request. +func (a *API) doJSON(makeReq func() (*http.Request, error), res any) error { + req, _ := makeReq() + err := a.identity.DoJSON(req, res) + if err == nil { + return nil + } + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnauthorized) { + return err + } + // Session expired — re-authenticate and retry once. + a.log.DEBUG.Println("chargepoint session expired, re-authenticating") + if loginErr := a.identity.Login(); loginErr != nil { + return fmt.Errorf("re-authentication failed: %w (original: %v)", loginErr, err) + } + req, _ = makeReq() + return a.identity.DoJSON(req, res) +} + +// Account fetches the account and returns the user ID. +func (a *API) Account() (int32, error) { + var res struct { + User struct { + UserID int32 `json:"userId"` + } `json:"user"` + } + err := a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodGet, a.identity.cfg.EndPoints.Accounts.Value+"v1/driver/profile/user", nil, + request.JSONEncoding, a.cpHeaders()) + }, &res) + if err != nil { + return 0, err + } + return res.User.UserID, nil +} + +// HomeChargerIDs returns the device IDs of all registered home chargers. +func (a *API) HomeChargerIDs() ([]int, error) { + data := struct { + UserID int32 `json:"user_id"` + GetPandas struct { + MFHS struct{} `json:"mfhs"` + } `json:"get_pandas"` + }{UserID: a.identity.UserID} + + var res struct { + GetPandas struct { + DeviceIDs []int `json:"device_ids"` + } `json:"get_pandas"` + } + err := a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodPost, a.identity.cfg.EndPoints.WebServices.Value+"mobileapi/v5", + request.MarshalJSON(data), request.JSONEncoding, a.cpWSHeaders()) + }, &res) + if err != nil { + return nil, err + } + + return res.GetPandas.DeviceIDs, nil +} + +// HomeChargerStatus returns the current status of a home charger via the +// internal REST API, which returns richer data than the legacy mobileapi. +func (a *API) HomeChargerStatus(deviceID int) (HomeChargerStatus, error) { + uri := fmt.Sprintf("%sapi/v1/configuration/users/%d/chargers/%d/status?", a.identity.cfg.EndPoints.Chargers.Value, a.identity.UserID, deviceID) + + var res HomeChargerStatus + err := a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodGet, uri, nil, + request.JSONEncoding, a.cpInternalHeaders()) + }, &res) + return res, err +} + +// StartCharging starts a charging session on the given device. +func (a *API) StartCharging(deviceID int) error { + data := struct { + DeviceData DeviceData `json:"deviceData"` + DeviceID int `json:"deviceId"` + }{ + DeviceData: a.identity.deviceData, + DeviceID: deviceID, + } + + var res struct { + AckID int `json:"ackId"` + } + if err := a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodPost, a.identity.cfg.EndPoints.Accounts.Value+"v1/driver/station/startsession", + request.MarshalJSON(data), request.JSONEncoding, a.cpHeaders()) + }, &res); err != nil { + // 422 means the charger received the command but responds with an ack ID + // in the body — decodeJSON still populates res on error, so fall through + // to poll. Any other error is fatal. + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnprocessableEntity) { + return err + } + } + + return a.pollAck(res.AckID, "start_session") +} + +// StopCharging stops the active charging session on the given device. +func (a *API) StopCharging(deviceID int) error { + data := struct { + DeviceData DeviceData `json:"deviceData"` + DeviceID int `json:"deviceId"` + }{ + DeviceData: a.identity.deviceData, + DeviceID: deviceID, + } + + var res struct { + AckID int `json:"ackId"` + } + if err := a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodPost, a.identity.cfg.EndPoints.Accounts.Value+"v1/driver/station/stopsession", + request.MarshalJSON(data), request.JSONEncoding, a.cpHeaders()) + }, &res); err != nil { + // 422 means the charger received the command but responds with an ack ID + // in the body — decodeJSON still populates res on error, so fall through + // to poll. Any other error is fatal. + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnprocessableEntity) { + return err + } + } + + return a.pollAck(res.AckID, "stop_session") +} + +func (a *API) pollAck(ackID int, action string) error { + ackData := struct { + DeviceData DeviceData `json:"deviceData"` + AckID int `json:"ackId"` + Action string `json:"action"` + }{ + DeviceData: a.identity.deviceData, + AckID: ackID, + Action: action, + } + + for i := range 5 { + if i > 0 { + time.Sleep(time.Second) + } + + req, err := request.New(http.MethodPost, a.identity.cfg.EndPoints.Accounts.Value+"v1/driver/station/session/ack", + request.MarshalJSON(ackData), request.JSONEncoding, a.cpHeaders()) + if err != nil { + return err + } + + err = a.identity.DoJSON(req, nil) + if err == nil { + return nil + } + // 422 is expected and indicates we should keep waiting. + if se, ok := errors.AsType[*request.StatusError](err); ok && se.HasStatus(http.StatusUnprocessableEntity) { + continue + } + a.log.DEBUG.Printf("pollAck %s attempt %d/5 (ackId=%d): %v", action, i+1, ackID, err) + } + + a.log.WARN.Printf("charger did not acknowledge %s within 5s, assuming it succeeded", action) + + return nil +} + +// SetAmperageLimit sets the charge amperage limit on the given device via the +// internal REST API using PUT, as required by that endpoint. +func (a *API) SetAmperageLimit(deviceID int, limit int64) error { + uri := fmt.Sprintf("%sapi/v1/configuration/chargers/%d/charge-amperage-limit", a.identity.cfg.EndPoints.Chargers.Value, deviceID) + + data := struct { + ChargeAmperageLimit int64 `json:"chargeAmperageLimit"` + }{limit} + + return a.doJSON(func() (*http.Request, error) { + return request.New(http.MethodPut, uri, + request.MarshalJSON(data), request.JSONEncoding, a.cpInternalHeaders()) + }, nil) +} diff --git a/charger/chargepoint/api_test.go b/charger/chargepoint/api_test.go new file mode 100644 index 0000000000..c9e28215e8 --- /dev/null +++ b/charger/chargepoint/api_test.go @@ -0,0 +1,86 @@ +package chargepoint + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +// TestDoJSON_ReauthOn401 verifies that doJSON transparently re-authenticates +// and retries when the server returns HTTP 401. +func TestDoJSON_ReauthOn401(t *testing.T) { + var accountCalls atomic.Int32 + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && r.URL.Path == "/v2/driver/profile/account/login": + // Login endpoint — return fresh tokens. + json.NewEncoder(w).Encode(map[string]any{ + "sessionId": "new-session", + "ssoSessionId": "new-sso-session", + "user": map[string]any{"userId": 42}, + }) + + case r.Method == http.MethodGet && r.URL.Path == "/v1/driver/profile/user": + // Account endpoint — 401 on first call, 200 on retry. + if accountCalls.Add(1) == 1 { + w.WriteHeader(http.StatusUnauthorized) + json.NewEncoder(w).Encode(map[string]any{"error": "unauthorized"}) + return + } + json.NewEncoder(w).Encode(map[string]any{ + "user": map[string]any{"userId": 42}, + }) + + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + base := srv.URL + "/" + + identity := &Identity{ + Helper: request.NewHelper(util.NewLogger("test")), + settingsKey: "chargepoint.test", + deviceData: newDeviceData("test@example.com"), + cfg: &globalConfig{ + Region: "US", + EndPoints: configEndpoints{ + Accounts: endpointValue{Value: base}, + Chargers: endpointValue{Value: base}, + InternalAPI: endpointValue{Value: base}, + WebServices: endpointValue{Value: base}, + }, + }, + identityState: identityState{ + Username: "test@example.com", + Password: "password", + SessionID: "old-session", + SSOSessionID: "old-sso-session", + Region: "US", + }, + } + + api := NewAPI(util.NewLogger("test"), identity) + + userID, err := api.Account() + if err != nil { + t.Fatalf("Account() error: %v", err) + } + if userID != 42 { + t.Errorf("userID: got %d, want 42", userID) + } + if n := accountCalls.Load(); n != 2 { + t.Errorf("account endpoint calls: got %d, want 2", n) + } + if identity.SessionID != "new-session" { + t.Errorf("SessionID after reauth: got %q, want %q", identity.SessionID, "new-session") + } +} diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go new file mode 100644 index 0000000000..83a6c9ea81 --- /dev/null +++ b/charger/chargepoint/identity.go @@ -0,0 +1,221 @@ +// Package chargepoint implements authentication for the ChargePoint Home Flex +// charger. +// +// # Authentication overview +// +// Behavior here is modeled after version 6.20.1 of the iOS app and +// sso.chargepoint.com. +// +// ChargePoint uses username/password credentials and expects two tokens. +// Interacting with the charger involves different subdomains which have +// slightly different expectations around these tokens. +// +// The accounts API endpoint (/v2/driver/profile/account/login) is +// used with a stable iOS device fingerprint (derived from username). On +// success, the endpoint returns a "sessionId" that encodes the region directly +// in its structure: "#D#R". It also returns a +// "ssoSessionId" JWT token, valid for 6 months. This seems to match the +// behavior on sso.chargepoint.com's login endpoint. +// +// # CAPTCHA protection +// +// All ChargePoint endpoints are protected by DataDome bot detection. Repeated +// logins from the same IP (~4 per half hour) will trigger a CAPTCHA challenge +// and return HTTP 403 even for valid credentials. Only after a cooldown of +// several hours will you be able to login again, however previously generated +// credentials will continue to work. It's for this reason that we always +// prefer persisted DB credentials over new logins. + +package chargepoint + +import ( + "errors" + "fmt" + "net/http" + "net/http/cookiejar" + "time" + + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" + "golang.org/x/net/publicsuffix" +) + +const ( + discoveryAPI = "https://discovery.chargepoint.com/discovery/v3/globalconfig" + appVersion = "6.20.1" + userAgent = "com.coulomb.ChargePoint/" + appVersion + " CFNetwork/3860.400.51 Darwin/25.3.0" +) + +// Identity manages ChargePoint session state using a shared cookie jar, +// mirroring how python-chargepoint uses requests.Session. +type Identity struct { + *request.Helper + identityState + + settingsKey string + deviceData DeviceData + cfg *globalConfig +} + +// identityState is persisted in settings. +type identityState struct { + Username string `json:"username"` + Password string `json:"password"` + UserID int32 `json:"user_id"` + Region string `json:"region"` + SessionID string `json:"sessionId"` + SSOSessionID string `json:"ssoSessionId"` // JWT returned by login; sso.chargepoint.com also returns this token. +} + +// NewIdentity creates a ChargePoint Identity backed by a cookie jar. It loads +// a stored session from settings if available, then refreshes it; otherwise +// falls back to a fresh login. +func NewIdentity(log *util.Logger, username, password string) (*Identity, error) { + v := &Identity{ + Helper: request.NewHelper(log), + settingsKey: "chargepoint." + username, + deviceData: newDeviceData(username), + + identityState: identityState{ + Username: username, + Password: password, + }, + } + + v.Helper.Jar, _ = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + cfg, err := discover(v.Helper, v.deviceData, v.Username) + if err != nil { + return nil, fmt.Errorf("discovering endpoints: %w", err) + } + v.cfg = cfg + + if err := v.Login(); err != nil { + return nil, err + } + + return v, nil +} + +// Login performs the ChargePoint mobile app login flow. +func (v *Identity) Login() error { + var state identityState + if err := settings.Json(v.settingsKey, &state); err == nil && + state.SSOSessionID != "" && !jwtExpired(state.SSOSessionID) { + v.UserID = state.UserID + v.Region = state.Region + v.SessionID = state.SessionID + v.SSOSessionID = state.SSOSessionID + if err := v.validate(); err == nil { + return nil + } + } + + data := struct { + DeviceData DeviceData `json:"deviceData"` + Username string `json:"username"` + Password string `json:"password"` + }{v.deviceData, v.Username, v.Password} + + uri := v.cfg.EndPoints.Accounts.Value + "v2/driver/profile/account/login" + req, _ := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + req.Header.Set("User-Agent", userAgent) + + var res accountLoginResponse + if err := v.Helper.DoJSON(req, &res); err != nil { + return fmt.Errorf("login: %w", err) + } + + if res.SessionID == "" { + return errors.New("no session ID in login response") + } + + v.UserID = res.User.UserID + v.Region = v.cfg.Region + v.SessionID = res.SessionID + v.SSOSessionID = res.SSOSessionID + + if err := settings.SetJson(v.settingsKey, v.identityState); err != nil { + return err + } + + return nil +} + +// validate checks whether the current credentials are still valid by fetching +// the user profile. Returns nil on success. +func (v *Identity) validate() error { + headers := map[string]string{ + "User-Agent": userAgent, + "CP-Region": v.Region, + "CP-Session-Token": v.SessionID, + "CP-Session-Type": "CP_SESSION_TOKEN", + "Cache-Control": "no-store", + "Accept-Language": "en;q=1", + "Cookie": "coulomb_sess=" + v.SessionID + "; auth-session=" + v.SSOSessionID, + } + req, err := request.New(http.MethodGet, v.cfg.EndPoints.Accounts.Value+"v1/driver/profile/user", nil, + request.JSONEncoding, headers) + if err != nil { + return err + } + return v.Helper.DoJSON(req, nil) +} + +// jwtExpired returns true if the JWT's exp claim is in the past or the token +// cannot be parsed. The signature is not verified — we only need the expiry. +func jwtExpired(tokenStr string) bool { + p := jwt.NewParser() + token, _, err := p.ParseUnverified(tokenStr, &jwt.RegisteredClaims{}) + if err != nil { + return true + } + exp, err := token.Claims.GetExpirationTime() + if err != nil || exp == nil { + return true + } + return time.Now().After(exp.Time) +} + +func discover(c *request.Helper, dev DeviceData, username string) (*globalConfig, error) { + data := struct { + DeviceData DeviceData `json:"deviceData"` + Username string `json:"username"` + }{dev, username} + + req, err := request.New(http.MethodPost, discoveryAPI, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return nil, err + } + + var cfg globalConfig + if err := c.DoJSON(req, &cfg); err != nil { + return nil, err + } + + return &cfg, nil +} + +// deviceUDID returns a stable UUID v5 derived from the username, +// mimicking a real iOS device that always presents the same UDID. +func deviceUDID(username string) string { + return uuid.NewSHA1(uuid.NameSpaceX500, []byte(username)).String() +} + +// newDeviceData returns a stable iOS device fingerprint derived from the username. +func newDeviceData(username string) DeviceData { + return DeviceData{ + AppID: "com.coulomb.ChargePoint", + Manufacturer: "Apple", + Model: "iPhone", + NotificationID: "", + NotificationIDType: "", + Type: "IOS", + UDID: deviceUDID(username), + Version: appVersion, + } +} diff --git a/charger/chargepoint/types.go b/charger/chargepoint/types.go new file mode 100644 index 0000000000..de0d7cfbdf --- /dev/null +++ b/charger/chargepoint/types.go @@ -0,0 +1,50 @@ +package chargepoint + +// HomeChargerStatus holds the current status of a home charger. +type HomeChargerStatus struct { + ChargingStatus string `json:"chargingStatus"` // AVAILABLE, CHARGING + IsPluggedIn bool `json:"isPluggedIn"` // Vehicle's connection status. + IsConnected bool `json:"isConnected"` // ??? + ChargeAmperageSettings struct { + ChargeLimit int64 `json:"chargeLimit"` // What the limit is now. + PossibleChargeLimit []int64 `json:"possibleChargeLimit"` // Possible limits. + } `json:"chargeAmperageSettings"` +} + +// DeviceData is the iOS device fingerprint included in ChargePoint API requests. +type DeviceData struct { + AppID string `json:"appId"` + Manufacturer string `json:"manufacturer"` + Model string `json:"model"` + NotificationID string `json:"notificationId"` + NotificationIDType string `json:"notificationIdType"` + Type string `json:"type"` + UDID string `json:"udid"` + Version string `json:"version"` +} + +type endpointValue struct { + Value string `json:"value"` +} + +type configEndpoints struct { + Accounts endpointValue `json:"accounts_endpoint"` + Chargers endpointValue `json:"hcpo_hcm_endpoint"` + InternalAPI endpointValue `json:"internal_api_gateway_endpoint"` + MapCache endpointValue `json:"mapcache_endpoint"` + SSO endpointValue `json:"sso_endpoint"` + WebServices endpointValue `json:"webservices_endpoint"` +} + +type globalConfig struct { + Region string `json:"region"` + EndPoints configEndpoints `json:"endPoints"` +} + +type accountLoginResponse struct { + SessionID string `json:"sessionId"` + SSOSessionID string `json:"ssoSessionId"` + User struct { + UserID int32 `json:"userId"` + } `json:"user"` +} diff --git a/templates/definition/charger/chargepoint-home-flex.yaml b/templates/definition/charger/chargepoint-home-flex.yaml new file mode 100644 index 0000000000..84fce22110 --- /dev/null +++ b/templates/definition/charger/chargepoint-home-flex.yaml @@ -0,0 +1,28 @@ +template: chargepoint-home-flex +products: + - brand: ChargePoint + description: + generic: Home Flex +params: + - name: user + required: true + description: + generic: User + help: + en: ChargePoint account email address. + de: E-Mail-Adresse des ChargePoint-Kontos. + - name: password + required: true + - name: deviceid + description: + generic: Device ID + help: + en: ChargePoint Home Flex device ID. Leave empty for automatic discovery if only one charger is registered to the account. + de: Geräte-ID des ChargePoint Home Flex. Leer lassen für automatische Erkennung, wenn nur ein Ladegerät am Konto registriert ist. + type: int + default: 0 +render: | + type: chargepoint + user: {{ .user }} + password: {{ .password }} + deviceid: {{ .deviceid }}