From 580a5a700d76a42d6d36c1272eb70b3dbd1aafb5 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Mon, 9 Mar 2026 11:52:06 -0700 Subject: [PATCH 01/11] Add Chargepoint Home Flex charger This adds support for the Chargepoint Home Flex charger. The API requires a username/password and hands out a long-lived (6 month) token from that. Too many login attempts will result in 401 response codes, so we persist it in settings. Once you have a valid token there don't seem to be any limits on it. The API also forces brotli compression, hence `util/transport/brotli.go`. --- charger/chargepoint.go | 157 +++++++++++ charger/chargepoint/api.go | 266 ++++++++++++++++++ charger/chargepoint/api_test.go | 86 ++++++ charger/chargepoint/identity.go | 232 +++++++++++++++ charger/chargepoint/types.go | 50 ++++ go.mod | 1 + go.sum | 4 + .../charger/chargepoint-home-flex.yaml | 54 ++++ util/transport/brotli.go | 50 ++++ 9 files changed, 900 insertions(+) create mode 100644 charger/chargepoint.go create mode 100644 charger/chargepoint/api.go create mode 100644 charger/chargepoint/api_test.go create mode 100644 charger/chargepoint/identity.go create mode 100644 charger/chargepoint/types.go create mode 100644 templates/definition/charger/chargepoint-home-flex.yaml create mode 100644 util/transport/brotli.go diff --git a/charger/chargepoint.go b/charger/chargepoint.go new file mode 100644 index 00000000000..8257d84bc05 --- /dev/null +++ b/charger/chargepoint.go @@ -0,0 +1,157 @@ +package charger + +import ( + "fmt" + "slices" + "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 + minCurrent int64 + maxCurrent int64 + 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) + + if deviceID == 0 { + ids, err := api.HomeChargerIDs() + if err != nil { + return nil, fmt.Errorf("discover chargers: %w", err) + } + switch len(ids) { + case 0: + return nil, fmt.Errorf("no home chargers found") + case 1: + deviceID = ids[0] + default: + return nil, fmt.Errorf("multiple home chargers found %v, specify deviceid", ids) + } + } + + cp := &ChargePoint{ + API: api, + deviceID: deviceID, + minCurrent: minCurrent, + maxCurrent: maxCurrent, + } + + cp.statusG = util.ResettableCached(func() (cpkg.HomeChargerStatus, error) { + return cp.API.HomeChargerStatus(cp.deviceID) + }, cache) + + // Clamp our min/max based on what the device supports. + if status, err := cp.statusG.Get(); err == nil { + if limits := status.ChargeAmperageSettings.PossibleChargeLimit; len(limits) > 0 { + cp.minCurrent = max(cp.minCurrent, slices.Min(limits)) + cp.maxCurrent = min(cp.maxCurrent, slices.Max(limits)) + } + } + + 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 { + var err error + if enable { + err = c.API.StartSession(c.deviceID) + } else { + err = c.API.StopSession(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 current < c.minCurrent { + current = c.minCurrent + } + if current > c.maxCurrent { + current = c.maxCurrent + } + + 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 00000000000..96d2dd1de2d --- /dev/null +++ b/charger/chargepoint/api.go @@ -0,0 +1,266 @@ +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 + wsURL string + accountsURL string + internalURL string + chargersURL string + region string +} + +// NewAPI creates a ChargePoint API client. +func NewAPI(log *util.Logger, identity *Identity) *API { + return &API{ + log: log, + identity: identity, + wsURL: identity.cfg.EndPoints.WebServices.Value, + accountsURL: identity.cfg.EndPoints.Accounts.Value, + internalURL: identity.cfg.EndPoints.InternalAPI.Value, + chargersURL: identity.cfg.EndPoints.Chargers.Value, + region: identity.Region, + } +} + +// 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 { + // Accept-Encoding is intentionally omitted here; the BrotliCompression + // transport (set in NewIdentity) sets it to "br" on every request. + return map[string]string{ + "User-Agent": userAgent, + "CP-Region": a.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, err := makeReq() + if err != nil { + return err + } + err = a.identity.DoJSON(req, res) + if err == nil { + return nil + } + var se *request.StatusError + if !errors.As(err, &se) || !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, err = makeReq() + if err != nil { + return err + } + 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.accountsURL+"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.wsURL+"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.chargersURL, 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 +} + +// StartSession starts a charging session on the given device. +func (a *API) StartSession(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.accountsURL+"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. + var se *request.StatusError + if !errors.As(err, &se) || !se.HasStatus(http.StatusUnprocessableEntity) { + return err + } + } + + return a.pollAck(res.AckID, "start_session") +} + +// StopSession stops the active charging session on the given device. +func (a *API) StopSession(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.accountsURL+"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. + var se *request.StatusError + if !errors.As(err, &se) || !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 := 0; i < 5; i++ { + if i > 0 { + time.Sleep(time.Second) + } + + req, err := request.New(http.MethodPost, a.accountsURL+"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. + var se *request.StatusError + if errors.As(err, &se) && 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.chargersURL, 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 00000000000..c9e28215e88 --- /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 00000000000..9feb64859fc --- /dev/null +++ b/charger/chargepoint/identity.go @@ -0,0 +1,232 @@ +// 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 ( + "encoding/base64" + "encoding/json" + "fmt" + "net/http" + "net/http/cookiejar" + "strings" + "time" + + "github.com/evcc-io/evcc/server/db/settings" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "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, + }, + } + + // Wrap transport for brotli decompression; must be done once here so that + // all requests through this Identity use brotli. NewAPI must not wrap it again. + v.Helper.Transport = transport.BrotliCompression(v.Helper.Transport) + + 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("logging in: %w", err) + } + + if res.SessionID == "" { + return fmt.Errorf("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 fmt.Errorf("persisting chargepoint identity: %w", 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 { + parts := strings.SplitN(tokenStr, ".", 3) + if len(parts) != 3 { + return true + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return true + } + var claims struct { + Exp int64 `json:"exp"` + } + if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { + return true + } + return time.Now().Unix() > claims.Exp +} + +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 machine hostname. +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 00000000000..de0d7cfbdfb --- /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/go.mod b/go.mod index 2d5e09d25a2..1b57aa018c8 100644 --- a/go.mod +++ b/go.mod @@ -11,6 +11,7 @@ require ( github.com/andig/go-powerwall v0.3.0 github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e + github.com/andybalholm/brotli v1.2.0 github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 diff --git a/go.sum b/go.sum index c6d2c2f4bba..cd568c1e2f1 100644 --- a/go.sum +++ b/go.sum @@ -39,6 +39,8 @@ github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b h1:81UMfM949I7StrR github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= +github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= +github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= @@ -777,6 +779,8 @@ github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjm github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= +github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/templates/definition/charger/chargepoint-home-flex.yaml b/templates/definition/charger/chargepoint-home-flex.yaml new file mode 100644 index 00000000000..8804f0b230f --- /dev/null +++ b/templates/definition/charger/chargepoint-home-flex.yaml @@ -0,0 +1,54 @@ +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 + mask: true + description: + generic: Password + help: + en: ChargePoint account password. Tokens are stored locally after the first login. + de: ChargePoint-Passwort. Nach dem ersten Login werden die Tokens lokal gespeichert. + - 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 + - name: mincurrent + description: + generic: Min. Current + help: + en: Minimum charging current in Ampere (8–48 A). + de: Minimaler Ladestrom in Ampere (8–48 A). + type: int + default: 8 + unit: A + - name: maxcurrent + description: + generic: Max. Current + help: + en: Maximum charging current in Ampere (8–48 A). + de: Maximaler Ladestrom in Ampere (8–48 A). + type: int + default: 48 + unit: A +render: | + type: chargepoint + user: {{ .user }} + password: {{ .password }} + deviceid: {{ .deviceid }} + mincurrent: {{ .mincurrent }} + maxcurrent: {{ .maxcurrent }} diff --git a/util/transport/brotli.go b/util/transport/brotli.go new file mode 100644 index 00000000000..9978f9436ba --- /dev/null +++ b/util/transport/brotli.go @@ -0,0 +1,50 @@ +package transport + +import ( + "io" + "net/http" + + "github.com/andybalholm/brotli" +) + +func BrotliCompression(base http.RoundTripper) http.RoundTripper { + return &brotliTransport{base: base} +} + +type brotliTransport struct { + base http.RoundTripper +} + +func (t *brotliTransport) RoundTrip(req *http.Request) (*http.Response, error) { + base := t.base + if base == nil { + base = http.DefaultTransport + } + + // Clone request so we don't mutate caller's headers + r := req.Clone(req.Context()) + r.Header.Set("Accept-Encoding", "br") + + resp, err := base.RoundTrip(r) + if err != nil { + return nil, err + } + + if resp.Header.Get("Content-Encoding") == "br" { + resp.Body = &brotliReadCloser{ + Reader: brotli.NewReader(resp.Body), + Closer: resp.Body, + } + + resp.Header.Del("Content-Encoding") + resp.Header.Del("Content-Length") + resp.ContentLength = -1 + } + + return resp, nil +} + +type brotliReadCloser struct { + io.Reader + io.Closer +} From 305798648c7855b0393ae17c4475967a8f30e481 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Mon, 23 Mar 2026 21:18:49 -0700 Subject: [PATCH 02/11] feedback --- charger/chargepoint/identity.go | 2 +- util/transport/brotli.go | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index 9feb64859fc..db16b84f043 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -217,7 +217,7 @@ func deviceUDID(username string) string { return uuid.NewSHA1(uuid.NameSpaceX500, []byte(username)).String() } -// newDeviceData returns a stable iOS device fingerprint derived from the machine hostname. +// newDeviceData returns a stable iOS device fingerprint derived from the username. func newDeviceData(username string) DeviceData { return DeviceData{ AppID: "com.coulomb.ChargePoint", diff --git a/util/transport/brotli.go b/util/transport/brotli.go index 9978f9436ba..a5e5669233f 100644 --- a/util/transport/brotli.go +++ b/util/transport/brotli.go @@ -23,7 +23,11 @@ func (t *brotliTransport) RoundTrip(req *http.Request) (*http.Response, error) { // Clone request so we don't mutate caller's headers r := req.Clone(req.Context()) - r.Header.Set("Accept-Encoding", "br") + if existing := r.Header.Get("Accept-Encoding"); existing == "" { + r.Header.Set("Accept-Encoding", "br") + } else { + r.Header.Set("Accept-Encoding", existing+", br") + } resp, err := base.RoundTrip(r) if err != nil { From 2c59684e5d93f183747303d646c5ea1f6e9083af Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 21:55:28 -0700 Subject: [PATCH 03/11] remove brotli --- charger/chargepoint/api.go | 3 +- charger/chargepoint/identity.go | 7 ++--- go.mod | 1 - go.sum | 4 --- util/transport/brotli.go | 54 --------------------------------- 5 files changed, 3 insertions(+), 66 deletions(-) delete mode 100644 util/transport/brotli.go diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go index 96d2dd1de2d..ba13d9c330e 100644 --- a/charger/chargepoint/api.go +++ b/charger/chargepoint/api.go @@ -42,9 +42,8 @@ func NewAPI(log *util.Logger, identity *Identity) *API { // 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 { - // Accept-Encoding is intentionally omitted here; the BrotliCompression - // transport (set in NewIdentity) sets it to "br" on every request. return map[string]string{ + "Accept-Encoding": "gzip, deflate", "User-Agent": userAgent, "CP-Region": a.region, "CP-Session-Token": a.identity.SessionID, diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index db16b84f043..e8cf6355c7e 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -40,7 +40,6 @@ import ( "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "github.com/evcc-io/evcc/util/transport" "github.com/google/uuid" "golang.org/x/net/publicsuffix" ) @@ -87,10 +86,6 @@ func NewIdentity(log *util.Logger, username, password string) (*Identity, error) }, } - // Wrap transport for brotli decompression; must be done once here so that - // all requests through this Identity use brotli. NewAPI must not wrap it again. - v.Helper.Transport = transport.BrotliCompression(v.Helper.Transport) - v.Helper.Jar, _ = cookiejar.New(&cookiejar.Options{ PublicSuffixList: publicsuffix.List, }) @@ -129,6 +124,7 @@ func (v *Identity) Login() error { 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("Accept-Encoding", "gzip, deflate") req.Header.Set("User-Agent", userAgent) var res accountLoginResponse @@ -156,6 +152,7 @@ func (v *Identity) Login() error { // the user profile. Returns nil on success. func (v *Identity) validate() error { headers := map[string]string{ + "Accept-Encoding": "gzip, deflate", "User-Agent": userAgent, "CP-Region": v.Region, "CP-Session-Token": v.SessionID, diff --git a/go.mod b/go.mod index 1b57aa018c8..2d5e09d25a2 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,6 @@ require ( github.com/andig/go-powerwall v0.3.0 github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e - github.com/andybalholm/brotli v1.2.0 github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/aws/aws-sdk-go-v2 v1.41.4 github.com/aws/aws-sdk-go-v2/config v1.32.12 diff --git a/go.sum b/go.sum index cd568c1e2f1..c6d2c2f4bba 100644 --- a/go.sum +++ b/go.sum @@ -39,8 +39,6 @@ github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b h1:81UMfM949I7StrR github.com/andig/gosunspec v0.0.0-20240918203654-860ce51d602b/go.mod h1:c6P6szcR+ROkqZruOR4f6qbDKFjZX6OitPpj+yJ/r8k= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e h1:m/NTP3JWpR7M0ljLxiQU4fzR25jjhe1LDtxLMNcoNJQ= github.com/andig/mbserver v0.0.0-20230310211055-1d29cbb5820e/go.mod h1:4VtYzTm//oUipwvO3yh0g/udTE7pYJM+U/kyAuFDsgM= -github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ= -github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= github.com/antihax/optional v1.0.0 h1:xK2lYat7ZLaVVcIuj82J8kIro4V6kDe0AUDFboUCwcg= @@ -779,8 +777,6 @@ github.com/warthog618/go-gpiosim v0.1.1/go.mod h1:YXsnB+I9jdCMY4YAlMSRrlts25ltjm github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= -github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= -github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= diff --git a/util/transport/brotli.go b/util/transport/brotli.go deleted file mode 100644 index a5e5669233f..00000000000 --- a/util/transport/brotli.go +++ /dev/null @@ -1,54 +0,0 @@ -package transport - -import ( - "io" - "net/http" - - "github.com/andybalholm/brotli" -) - -func BrotliCompression(base http.RoundTripper) http.RoundTripper { - return &brotliTransport{base: base} -} - -type brotliTransport struct { - base http.RoundTripper -} - -func (t *brotliTransport) RoundTrip(req *http.Request) (*http.Response, error) { - base := t.base - if base == nil { - base = http.DefaultTransport - } - - // Clone request so we don't mutate caller's headers - r := req.Clone(req.Context()) - if existing := r.Header.Get("Accept-Encoding"); existing == "" { - r.Header.Set("Accept-Encoding", "br") - } else { - r.Header.Set("Accept-Encoding", existing+", br") - } - - resp, err := base.RoundTrip(r) - if err != nil { - return nil, err - } - - if resp.Header.Get("Content-Encoding") == "br" { - resp.Body = &brotliReadCloser{ - Reader: brotli.NewReader(resp.Body), - Closer: resp.Body, - } - - resp.Header.Del("Content-Encoding") - resp.Header.Del("Content-Length") - resp.ContentLength = -1 - } - - return resp, nil -} - -type brotliReadCloser struct { - io.Reader - io.Closer -} From 757fbee9f0c0afac0b8b87eff50221560d2bb766 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 21:55:40 -0700 Subject: [PATCH 04/11] simplify jwt --- charger/chargepoint/identity.go | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index e8cf6355c7e..97ed1bf6f5b 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -29,17 +29,15 @@ package chargepoint import ( - "encoding/base64" - "encoding/json" "fmt" "net/http" "net/http/cookiejar" - "strings" "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" ) @@ -172,21 +170,16 @@ func (v *Identity) validate() error { // 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 { - parts := strings.SplitN(tokenStr, ".", 3) - if len(parts) != 3 { - return true - } - payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + p := jwt.NewParser() + token, _, err := p.ParseUnverified(tokenStr, &jwt.RegisteredClaims{}) if err != nil { return true } - var claims struct { - Exp int64 `json:"exp"` - } - if err := json.Unmarshal(payload, &claims); err != nil || claims.Exp == 0 { + exp, err := token.Claims.GetExpirationTime() + if err != nil || exp == nil { return true } - return time.Now().Unix() > claims.Exp + return time.Now().After(exp.Time) } func discover(c *request.Helper, dev DeviceData, username string) (*globalConfig, error) { From d6f61171567ac299b280c1bff42b5570387c79ec Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 21:58:32 -0700 Subject: [PATCH 05/11] s/session/charging --- charger/chargepoint.go | 4 ++-- charger/chargepoint/api.go | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/charger/chargepoint.go b/charger/chargepoint.go index 8257d84bc05..e52b2f44c03 100644 --- a/charger/chargepoint.go +++ b/charger/chargepoint.go @@ -126,9 +126,9 @@ func (c *ChargePoint) Enabled() (bool, error) { func (c *ChargePoint) Enable(enable bool) error { var err error if enable { - err = c.API.StartSession(c.deviceID) + err = c.API.StartCharging(c.deviceID) } else { - err = c.API.StopSession(c.deviceID) + err = c.API.StopCharging(c.deviceID) } if err != nil { return err diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go index ba13d9c330e..ed5708a61fc 100644 --- a/charger/chargepoint/api.go +++ b/charger/chargepoint/api.go @@ -152,8 +152,8 @@ func (a *API) HomeChargerStatus(deviceID int) (HomeChargerStatus, error) { return res, err } -// StartSession starts a charging session on the given device. -func (a *API) StartSession(deviceID int) error { +// 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"` @@ -181,8 +181,8 @@ func (a *API) StartSession(deviceID int) error { return a.pollAck(res.AckID, "start_session") } -// StopSession stops the active charging session on the given device. -func (a *API) StopSession(deviceID int) error { +// 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"` From 6c7b277b76c461a87d51af6fdee6f9433ff66e77 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 22:02:57 -0700 Subject: [PATCH 06/11] Update charger/chargepoint/identity.go Co-authored-by: andig --- charger/chargepoint/identity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index db16b84f043..09016c8fffc 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -137,7 +137,7 @@ func (v *Identity) Login() error { } if res.SessionID == "" { - return fmt.Errorf("no session ID in login response") + return errors.New("no session ID in login response") } v.UserID = res.User.UserID From 03f302b753c6f66151e3e31c418b9f384c465b44 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 22:03:11 -0700 Subject: [PATCH 07/11] Update charger/chargepoint.go Co-authored-by: andig --- charger/chargepoint.go | 7 ------- 1 file changed, 7 deletions(-) diff --git a/charger/chargepoint.go b/charger/chargepoint.go index 8257d84bc05..842b8f6657a 100644 --- a/charger/chargepoint.go +++ b/charger/chargepoint.go @@ -141,13 +141,6 @@ func (c *ChargePoint) Enable(enable bool) error { // MaxCurrent implements the api.Charger interface. func (c *ChargePoint) MaxCurrent(current int64) error { - if current < c.minCurrent { - current = c.minCurrent - } - if current > c.maxCurrent { - current = c.maxCurrent - } - if err := c.API.SetAmperageLimit(c.deviceID, current); err != nil { return err } From 0d81fa147a29e306155237a37c41188b67556a1d Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 22:03:53 -0700 Subject: [PATCH 08/11] Update charger/chargepoint/identity.go Co-authored-by: andig --- charger/chargepoint/identity.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index 09016c8fffc..79c238c99a6 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -133,7 +133,7 @@ func (v *Identity) Login() error { var res accountLoginResponse if err := v.Helper.DoJSON(req, &res); err != nil { - return fmt.Errorf("logging in: %w", err) + return fmt.Errorf("login: %w", err) } if res.SessionID == "" { From d06cdb1c1389b7354009ebc9decb57b1716ff952 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 22:38:34 -0700 Subject: [PATCH 09/11] feedback --- charger/chargepoint.go | 54 +++++++------------ charger/chargepoint/api.go | 1 - charger/chargepoint/identity.go | 5 +- .../charger/chargepoint-home-flex.yaml | 26 --------- 4 files changed, 21 insertions(+), 65 deletions(-) diff --git a/charger/chargepoint.go b/charger/chargepoint.go index aa12532da61..db6a05fa8f1 100644 --- a/charger/chargepoint.go +++ b/charger/chargepoint.go @@ -2,7 +2,7 @@ package charger import ( "fmt" - "slices" + "strconv" "time" "github.com/evcc-io/evcc/api" @@ -19,11 +19,9 @@ func init() { // ChargePoint implements the api.Charger interface for ChargePoint Home Flex chargers. type ChargePoint struct { *cpkg.API - deviceID int - minCurrent int64 - maxCurrent int64 - enabled bool - statusG util.Cacheable[cpkg.HomeChargerStatus] + deviceID int + enabled bool + statusG util.Cacheable[cpkg.HomeChargerStatus] } // NewChargePointFromConfig creates a ChargePoint charger from generic config. @@ -63,40 +61,27 @@ func NewChargePoint(deviceID int, user, password string, minCurrent, maxCurrent api := cpkg.NewAPI(log, identity) - if deviceID == 0 { - ids, err := api.HomeChargerIDs() - if err != nil { - return nil, fmt.Errorf("discover chargers: %w", err) - } - switch len(ids) { - case 0: - return nil, fmt.Errorf("no home chargers found") - case 1: - deviceID = ids[0] - default: - return nil, fmt.Errorf("multiple home chargers found %v, specify deviceid", ids) - } + 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, - minCurrent: minCurrent, - maxCurrent: maxCurrent, + API: api, + deviceID: deviceID, } cp.statusG = util.ResettableCached(func() (cpkg.HomeChargerStatus, error) { return cp.API.HomeChargerStatus(cp.deviceID) }, cache) - // Clamp our min/max based on what the device supports. - if status, err := cp.statusG.Get(); err == nil { - if limits := status.ChargeAmperageSettings.PossibleChargeLimit; len(limits) > 0 { - cp.minCurrent = max(cp.minCurrent, slices.Min(limits)) - cp.maxCurrent = min(cp.maxCurrent, slices.Max(limits)) - } - } - return cp, nil } @@ -124,12 +109,11 @@ func (c *ChargePoint) Enabled() (bool, error) { // Enable implements the api.Charger interface. func (c *ChargePoint) Enable(enable bool) error { - var err error + api := c.API.StopCharging if enable { - err = c.API.StartCharging(c.deviceID) - } else { - err = c.API.StopCharging(c.deviceID) + api = c.API.StartCharging } + err := api(c.deviceID) if err != nil { return err } diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go index ed5708a61fc..c930cec700a 100644 --- a/charger/chargepoint/api.go +++ b/charger/chargepoint/api.go @@ -43,7 +43,6 @@ func NewAPI(log *util.Logger, identity *Identity) *API { // restore and the app always sends them as static header values. func (a *API) cpHeaders() map[string]string { return map[string]string{ - "Accept-Encoding": "gzip, deflate", "User-Agent": userAgent, "CP-Region": a.region, "CP-Session-Token": a.identity.SessionID, diff --git a/charger/chargepoint/identity.go b/charger/chargepoint/identity.go index 7179290f237..83a6c9ea819 100644 --- a/charger/chargepoint/identity.go +++ b/charger/chargepoint/identity.go @@ -29,6 +29,7 @@ package chargepoint import ( + "errors" "fmt" "net/http" "net/http/cookiejar" @@ -122,7 +123,6 @@ func (v *Identity) Login() error { 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("Accept-Encoding", "gzip, deflate") req.Header.Set("User-Agent", userAgent) var res accountLoginResponse @@ -140,7 +140,7 @@ func (v *Identity) Login() error { v.SSOSessionID = res.SSOSessionID if err := settings.SetJson(v.settingsKey, v.identityState); err != nil { - return fmt.Errorf("persisting chargepoint identity: %w", err) + return err } return nil @@ -150,7 +150,6 @@ func (v *Identity) Login() error { // the user profile. Returns nil on success. func (v *Identity) validate() error { headers := map[string]string{ - "Accept-Encoding": "gzip, deflate", "User-Agent": userAgent, "CP-Region": v.Region, "CP-Session-Token": v.SessionID, diff --git a/templates/definition/charger/chargepoint-home-flex.yaml b/templates/definition/charger/chargepoint-home-flex.yaml index 8804f0b230f..84fce221105 100644 --- a/templates/definition/charger/chargepoint-home-flex.yaml +++ b/templates/definition/charger/chargepoint-home-flex.yaml @@ -13,12 +13,6 @@ params: de: E-Mail-Adresse des ChargePoint-Kontos. - name: password required: true - mask: true - description: - generic: Password - help: - en: ChargePoint account password. Tokens are stored locally after the first login. - de: ChargePoint-Passwort. Nach dem ersten Login werden die Tokens lokal gespeichert. - name: deviceid description: generic: Device ID @@ -27,28 +21,8 @@ params: 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 - - name: mincurrent - description: - generic: Min. Current - help: - en: Minimum charging current in Ampere (8–48 A). - de: Minimaler Ladestrom in Ampere (8–48 A). - type: int - default: 8 - unit: A - - name: maxcurrent - description: - generic: Max. Current - help: - en: Maximum charging current in Ampere (8–48 A). - de: Maximaler Ladestrom in Ampere (8–48 A). - type: int - default: 48 - unit: A render: | type: chargepoint user: {{ .user }} password: {{ .password }} deviceid: {{ .deviceid }} - mincurrent: {{ .mincurrent }} - maxcurrent: {{ .maxcurrent }} From a9c8ca062dde5b5322d5d25503d6aba2ccdf2491 Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Tue, 24 Mar 2026 22:57:52 -0700 Subject: [PATCH 10/11] fix loop range --- charger/chargepoint/api.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go index c930cec700a..057c5368c7d 100644 --- a/charger/chargepoint/api.go +++ b/charger/chargepoint/api.go @@ -220,7 +220,7 @@ func (a *API) pollAck(ackID int, action string) error { Action: action, } - for i := 0; i < 5; i++ { + for i := range 5 { if i > 0 { time.Sleep(time.Second) } From e25df4d57d3fe6f9f6546b1b68e770d790c7310f Mon Sep 17 00:00:00 2001 From: Bryce Lampe Date: Wed, 25 Mar 2026 08:29:51 -0700 Subject: [PATCH 11/11] feedback --- charger/chargepoint/api.go | 61 ++++++++++++++------------------------ 1 file changed, 22 insertions(+), 39 deletions(-) diff --git a/charger/chargepoint/api.go b/charger/chargepoint/api.go index 057c5368c7d..2f4cab12438 100644 --- a/charger/chargepoint/api.go +++ b/charger/chargepoint/api.go @@ -16,25 +16,15 @@ 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 - wsURL string - accountsURL string - internalURL string - chargersURL string - region string + 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, - wsURL: identity.cfg.EndPoints.WebServices.Value, - accountsURL: identity.cfg.EndPoints.Accounts.Value, - internalURL: identity.cfg.EndPoints.InternalAPI.Value, - chargersURL: identity.cfg.EndPoints.Chargers.Value, - region: identity.Region, + log: log, + identity: identity, } } @@ -44,7 +34,7 @@ func NewAPI(log *util.Logger, identity *Identity) *API { func (a *API) cpHeaders() map[string]string { return map[string]string{ "User-Agent": userAgent, - "CP-Region": a.region, + "CP-Region": a.identity.Region, "CP-Session-Token": a.identity.SessionID, "CP-Session-Type": "CP_SESSION_TOKEN", "Cache-Control": "no-store", @@ -72,16 +62,13 @@ func (a *API) cpInternalHeaders() map[string]string { // 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, err := makeReq() - if err != nil { - return err - } - err = a.identity.DoJSON(req, res) + req, _ := makeReq() + err := a.identity.DoJSON(req, res) if err == nil { return nil } - var se *request.StatusError - if !errors.As(err, &se) || !se.HasStatus(http.StatusUnauthorized) { + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnauthorized) { return err } // Session expired — re-authenticate and retry once. @@ -89,10 +76,7 @@ func (a *API) doJSON(makeReq func() (*http.Request, error), res any) error { if loginErr := a.identity.Login(); loginErr != nil { return fmt.Errorf("re-authentication failed: %w (original: %v)", loginErr, err) } - req, err = makeReq() - if err != nil { - return err - } + req, _ = makeReq() return a.identity.DoJSON(req, res) } @@ -104,7 +88,7 @@ func (a *API) Account() (int32, error) { } `json:"user"` } err := a.doJSON(func() (*http.Request, error) { - return request.New(http.MethodGet, a.accountsURL+"v1/driver/profile/user", nil, + return request.New(http.MethodGet, a.identity.cfg.EndPoints.Accounts.Value+"v1/driver/profile/user", nil, request.JSONEncoding, a.cpHeaders()) }, &res) if err != nil { @@ -128,7 +112,7 @@ func (a *API) HomeChargerIDs() ([]int, error) { } `json:"get_pandas"` } err := a.doJSON(func() (*http.Request, error) { - return request.New(http.MethodPost, a.wsURL+"mobileapi/v5", + return request.New(http.MethodPost, a.identity.cfg.EndPoints.WebServices.Value+"mobileapi/v5", request.MarshalJSON(data), request.JSONEncoding, a.cpWSHeaders()) }, &res) if err != nil { @@ -141,7 +125,7 @@ func (a *API) HomeChargerIDs() ([]int, error) { // 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.chargersURL, a.identity.UserID, deviceID) + 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) { @@ -165,14 +149,14 @@ func (a *API) StartCharging(deviceID int) error { AckID int `json:"ackId"` } if err := a.doJSON(func() (*http.Request, error) { - return request.New(http.MethodPost, a.accountsURL+"v1/driver/station/startsession", + 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. - var se *request.StatusError - if !errors.As(err, &se) || !se.HasStatus(http.StatusUnprocessableEntity) { + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnprocessableEntity) { return err } } @@ -194,14 +178,14 @@ func (a *API) StopCharging(deviceID int) error { AckID int `json:"ackId"` } if err := a.doJSON(func() (*http.Request, error) { - return request.New(http.MethodPost, a.accountsURL+"v1/driver/station/stopsession", + 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. - var se *request.StatusError - if !errors.As(err, &se) || !se.HasStatus(http.StatusUnprocessableEntity) { + se, ok := errors.AsType[*request.StatusError](err) + if !ok || !se.HasStatus(http.StatusUnprocessableEntity) { return err } } @@ -225,7 +209,7 @@ func (a *API) pollAck(ackID int, action string) error { time.Sleep(time.Second) } - req, err := request.New(http.MethodPost, a.accountsURL+"v1/driver/station/session/ack", + 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 @@ -236,8 +220,7 @@ func (a *API) pollAck(ackID int, action string) error { return nil } // 422 is expected and indicates we should keep waiting. - var se *request.StatusError - if errors.As(err, &se) && se.HasStatus(http.StatusUnprocessableEntity) { + 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) @@ -251,7 +234,7 @@ func (a *API) pollAck(ackID int, action string) error { // 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.chargersURL, deviceID) + uri := fmt.Sprintf("%sapi/v1/configuration/chargers/%d/charge-amperage-limit", a.identity.cfg.EndPoints.Chargers.Value, deviceID) data := struct { ChargeAmperageLimit int64 `json:"chargeAmperageLimit"`