Fix Polestar api (#11766)

This commit is contained in:
andig 2024-02-11 14:15:18 +01:00 • committed by GitHub
parent d25af7d2be
commit f651a06ae3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 107 additions and 94 deletions

View file

@ -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
}

View file

@ -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
}

View file

@ -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)

View file

@ -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"`
}