-
-
Notifications
You must be signed in to change notification settings - Fork 1.2k
Add Chargepoint Home Flex charger #28512
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Draft
blampe
wants to merge
14
commits into
evcc-io:master
Choose a base branch
from
blampe:blampe/chargepoint-pr
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+766
−0
Draft
Changes from all commits
Commits
Show all changes
14 commits
Select commit
Hold shift + click to select a range
580a5a7
Add Chargepoint Home Flex charger
blampe 3057986
feedback
blampe 2c59684
remove brotli
blampe 757fbee
simplify jwt
blampe d6f6117
s/session/charging
blampe 6c7b277
Update charger/chargepoint/identity.go
blampe 03f302b
Update charger/chargepoint.go
blampe 0d81fa1
Update charger/chargepoint/identity.go
blampe 20d3b0b
Merge branch 'blampe/chargepoint-pr' of github.com:blampe/evcc into b…
blampe d06cdb1
feedback
blampe 5439c12
Merge branch 'master' of https://github.com/evcc-io/evcc into blampe/…
blampe a9c8ca0
fix loop range
blampe e25df4d
feedback
blampe 81021d2
Merge branch 'master' of https://github.com/evcc-io/evcc into blampe/…
blampe File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
return an error?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
So this charger can get into very weird states, like it thinks the vehicle is plugged in but it's not, or it just refuses to let you toggle charging. I've observed this under totally normal operation, completely independent of EVCC, zero automation. The only way I've found to restore it to the correct state is a hard reboot, but I would strongly prefer to not bake that into EVCC.
I originally had this return an error but during testing it got into one of these weird states, and that just caused an endless loop of EVCC trying to control it with it refusing. Making this best effort for now seems like the safe choice, and I'll probably spend more time trying to understand if there's a better way to nudge it out of these invalid states.