Skip to content
Draft
134 changes: 134 additions & 0 deletions charger/chargepoint.go
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
}
247 changes: 247 additions & 0 deletions charger/chargepoint/api.go
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)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

return an error?

Copy link
Contributor Author

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.


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)
}
Loading
Loading