Fix Polestar api (#11766)
This commit is contained in:
parent
d25af7d2be
commit
f651a06ae3
4 changed files with 107 additions and 94 deletions
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue