From f651a06ae30f14476a838c07a1268b4012cee6f4 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 11 Feb 2024 14:15:18 +0100 Subject: [PATCH] Fix Polestar api (#11766) --- vehicle/polestar/api.go | 27 ++++--- vehicle/polestar/identity.go | 149 ++++++++++++++++------------------- vehicle/polestar/provider.go | 6 +- vehicle/polestar/types.go | 19 ++++- 4 files changed, 107 insertions(+), 94 deletions(-) diff --git a/vehicle/polestar/api.go b/vehicle/polestar/api.go index 1ffc08a25..0508af664 100644 --- a/vehicle/polestar/api.go +++ b/vehicle/polestar/api.go @@ -2,6 +2,9 @@ package polestar import ( "context" + "net/http" + "net/url" + "strings" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" @@ -9,12 +12,16 @@ import ( "golang.org/x/oauth2" ) +// https://github.com/leeyuentuen/polestar_api // https://github.com/TA2k/ioBroker.polestar -const ApiURI = "https://pc-api.polestar.com/eu-north-1/my-star" +const ( + ApiURI = "https://pc-api.polestar.com/eu-north-1" + ApiURIv1 = ApiURI + "/my-star" + ApiURIv2 = ApiURI + "/mystar-v2" +) type API struct { - *request.Helper client *graphql.Client } @@ -28,12 +35,9 @@ func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { } v := &API{ - Helper: request.NewHelper(log), - client: graphql.NewClient(ApiURI, httpClient), + client: graphql.NewClient(ApiURIv2, httpClient), } - v.Client.Transport = httpClient.Transport - return v } @@ -42,7 +46,10 @@ func (v *API) Vehicles(ctx context.Context) ([]ConsumerCar, error) { GetConsumerCarsV2 []ConsumerCar `graphql:"getConsumerCarsV2"` } - err := v.client.Query(ctx, &res, nil, graphql.OperationName("getCars")) + err := v.client.WithRequestModifier(func(req *http.Request) { + req.URL, _ = url.Parse(strings.ReplaceAll(req.URL.String(), "mystar-v2", "my-star")) + }).Query(ctx, &res, nil, graphql.OperationName("getCars")) + return res.GetConsumerCarsV2, err } @@ -51,9 +58,10 @@ func (v *API) Status(ctx context.Context, vin string) (BatteryData, error) { BatteryData `graphql:"getBatteryData(vin: $vin)"` } - err := v.client.Query(ctx, &res, map[string]interface{}{ + err := v.client.Query(ctx, &res, map[string]any{ "vin": vin, }, graphql.OperationName("GetBatteryData")) + return res.BatteryData, err } @@ -62,8 +70,9 @@ func (v *API) Odometer(ctx context.Context, vin string) (OdometerData, error) { OdometerData `graphql:"getOdometerData(vin: $vin)"` } - err := v.client.Query(ctx, &res, map[string]interface{}{ + err := v.client.Query(ctx, &res, map[string]any{ "vin": vin, }, graphql.OperationName("GetOdometerData")) + return res.OdometerData, err } diff --git a/vehicle/polestar/identity.go b/vehicle/polestar/identity.go index f35f3e8d3..09b890aa7 100644 --- a/vehicle/polestar/identity.go +++ b/vehicle/polestar/identity.go @@ -1,15 +1,18 @@ package polestar import ( + "context" "fmt" "net/http" "net/http/cookiejar" "net/url" "strings" + "time" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/util/request" + "github.com/hasura/go-graphql-client" "github.com/samber/lo" "golang.org/x/net/publicsuffix" "golang.org/x/oauth2" @@ -17,20 +20,23 @@ import ( // https://github.com/TA2k/ioBroker.polestar -const ( - OAuthURI = "https://polestarid.eu.polestar.com" - basicAuth = "cG9seHBsb3JlOlhhaUtvb0hlaXJlaXNvb3NhaDBFdjZxdW9oczhjb2hGZUtvaHdpZTFhZTdraWV3b2hkb295ZWk5QWVZZWlXb2g" -) +const OAuthURI = "https://polestarid.eu.polestar.com" // https://polestarid.eu.polestar.com/.well-known/openid-configuration var OAuth2Config = &oauth2.Config{ - ClientID: "polxplore", - RedirectURL: "polestar-explore://explore.polestar.com", + ClientID: "polmystar", + RedirectURL: "https://www.polestar.com/sign-in-callback", Endpoint: oauth2.Endpoint{ AuthURL: OAuthURI + "/as/authorization.oauth2", TokenURL: OAuthURI + "/as/token.oauth2", }, - Scopes: []string{"openid", "profile", "email", "customer:attributes"}, + Scopes: []string{ + "openid", "profile", "email", "customer:attributes", + // "conve:recharge_status", "conve:fuel_status", "conve:odometer_status", + // "energy:charging_connection_status", "energy:electric_range", "energy:estimated_charging_time", "energy:recharge_status", + // "energy:battery_charge_level", "energy:charging_system_status", "energy:charging_timer", "energy:electric_range", "energy:recharge_status", + // "energy:battery_charge_level", + }, } type Identity struct { @@ -40,101 +46,86 @@ type Identity struct { // NewIdentity creates Polestar identity func NewIdentity(log *util.Logger) *Identity { - return &Identity{ + v := &Identity{ Helper: request.NewHelper(log), } + + v.Client.Jar, _ = cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + return v } func (v *Identity) Login(user, password string) error { - if v.Client.Jar == nil { - var err error - v.Client.Jar, err = cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) - if err != nil { - return err - } - } - - cv := oauth2.GenerateVerifier() - state := lo.RandomString(16, lo.AlphanumericCharset) - uri := OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cv)) + uri := OAuth2Config.AuthCodeURL(state, oauth2.AccessTypeOffline) var param request.InterceptResult - v.Client.CheckRedirect, param = request.InterceptRedirect("resumePath", false) + v.Client.CheckRedirect, param = request.InterceptRedirect("resumePath", true) + defer func() { v.Client.CheckRedirect = nil }() - _, err := v.Get(uri) - - var resume string - if err == nil { - resume, err = param() + if _, err := v.Get(uri); err != nil { + return err } - v.Client.CheckRedirect = nil + resume, err := param() + if err != nil { + return err + } + + params := url.Values{ + "pf.username": []string{user}, + "pf.pass": []string{password}, + } + + uri = fmt.Sprintf("%s/as/%s/resume/as/authorization.ping?client_id=%s", OAuthURI, resume, OAuth2Config.ClientID) + v.Client.CheckRedirect, param = request.InterceptRedirect("code", true) + defer func() { v.Client.CheckRedirect = nil }() var code string - if err == nil { - params := url.Values{ - "pf.username": []string{user}, - "pf.pass": []string{password}, - } - - var param request.InterceptResult - v.Client.CheckRedirect, param = request.InterceptRedirect("code", true) - - uri = fmt.Sprintf("%s/as/%s/resume/as/authorization.ping?client_id=%s", OAuthURI, resume, OAuth2Config.ClientID) - - if _, err = v.Post(uri, request.FormContent, strings.NewReader(params.Encode())); err == nil { - code, err = param() - } - - v.Client.CheckRedirect = nil + if _, err = v.Post(uri, request.FormContent, strings.NewReader(params.Encode())); err == nil { + code, err = param() } - var token oauth.Token - if err == nil { - params := url.Values{ - "code": []string{code}, - "code_verifier": []string{cv}, - "redirect_uri": []string{OAuth2Config.RedirectURL}, - "grant_type": []string{"authorization_code"}, - } - - var req *http.Request - req, err = request.New(http.MethodPost, OAuth2Config.Endpoint.TokenURL, strings.NewReader(params.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Authorization": "Basic " + basicAuth, - }) - if err == nil { - err = v.DoJSON(req, &token) - } + if err != nil { + return err } - if err == nil { - v.TokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v) + var res struct { + Token `graphql:"getAuthToken(code: $code)"` } + if err := graphql.NewClient(ApiURI+"/auth", v.Client). + Query(context.Background(), &res, map[string]any{ + "code": code, + }, graphql.OperationName("getAuthToken")); err != nil { + return err + } + + v.TokenSource = oauth.RefreshTokenSource(&oauth2.Token{ + AccessToken: res.AccessToken, + RefreshToken: res.RefreshToken, + Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second), + }, v) + return err } func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { - data := url.Values{ - "redirect_uri": []string{OAuth2Config.RedirectURL}, - "refresh_token": []string{token.RefreshToken}, - "grant_type": []string{"refresh_token"}, - } - req, err := request.New(http.MethodPost, OAuth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Authorization": "Basic " + basicAuth, - }) - - var tok oauth.Token - if err == nil { - if err := v.DoJSON(req, &tok); err != nil { - return nil, err - } + var res struct { + Token `graphql:"refreshAuthToken(token: $token)"` } - return (*oauth2.Token)(&tok), err + err := graphql.NewClient(ApiURI+"/auth", v.Client).WithRequestModifier(func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+token.AccessToken) + }).Query(context.Background(), &res, map[string]any{ + "token": token.RefreshToken, + }, graphql.OperationName("refreshAuthToken")) + + return &oauth2.Token{ + AccessToken: res.AccessToken, + RefreshToken: res.RefreshToken, + Expiry: time.Now().Add(time.Duration(res.ExpiresIn) * time.Second), + }, err } diff --git a/vehicle/polestar/provider.go b/vehicle/polestar/provider.go index 398fae77f..5b71def7b 100644 --- a/vehicle/polestar/provider.go +++ b/vehicle/polestar/provider.go @@ -9,8 +9,6 @@ import ( "github.com/evcc-io/evcc/util" ) -// https://github.com/TA2k/ioBroker.polestar - type Provider struct { statusG func() (BatteryData, error) odoG func() (OdometerData, error) @@ -36,7 +34,7 @@ func NewProvider(log *util.Logger, api *API, vin string, timeout, cache time.Dur // Soc implements the api.Vehicle interface func (v *Provider) Soc() (float64, error) { res, err := v.statusG() - return float64(res.BatteryChargeLevelPercentage), err + return res.BatteryChargeLevelPercentage, err } var _ api.ChargeState = (*Provider)(nil) @@ -69,7 +67,7 @@ var _ api.VehicleOdometer = (*Provider)(nil) // Odometer implements the Provider.VehicleOdometer interface func (v *Provider) Odometer() (float64, error) { res, err := v.odoG() - return float64(res.OdometerMeters) / 1e3, err + return res.OdometerMeters / 1e3, err } var _ api.VehicleFinishTimer = (*Provider)(nil) diff --git a/vehicle/polestar/types.go b/vehicle/polestar/types.go index a01f4df1d..2d4457336 100644 --- a/vehicle/polestar/types.go +++ b/vehicle/polestar/types.go @@ -1,18 +1,33 @@ package polestar +import "time" + +type Token struct { + AccessToken string `graphql:"access_token"` + RefreshToken string `graphql:"refresh_token"` + ExpiresIn int `graphql:"expires_in"` +} + type ConsumerCar struct { VIN string InternalVehicleIdentifier string } type BatteryData struct { - BatteryChargeLevelPercentage int + BatteryChargeLevelPercentage float64 ChargerConnectionStatus string ChargingStatus string EstimatedChargingTimeToFullMinutes int EstimatedDistanceToEmptyKm int + EventUpdatedTimestamp EventUpdatedTimestamp } type OdometerData struct { - OdometerMeters int + OdometerMeters float64 + EventUpdatedTimestamp EventUpdatedTimestamp +} + +type EventUpdatedTimestamp struct { + ISO time.Time + // Unix int64 `json:",string"` }