diff --git a/templates/definition/vehicle/polestar.yaml b/templates/definition/vehicle/polestar.yaml new file mode 100644 index 000000000..1d1d2d518 --- /dev/null +++ b/templates/definition/vehicle/polestar.yaml @@ -0,0 +1,12 @@ +template: polestar +products: + - brand: Polestar +params: + - preset: vehicle-base + - preset: vehicle-identify + - name: vin + example: LPSVS... +render: | + type: polestar + {{ include "vehicle-base" . }} + {{ include "vehicle-identify" . }} diff --git a/templates/evcc.io/brands.json b/templates/evcc.io/brands.json index fe0d964bf..3b22463d7 100644 --- a/templates/evcc.io/brands.json +++ b/templates/evcc.io/brands.json @@ -223,6 +223,7 @@ "NIU", "Opel", "Peugeot", + "Polestar", "Porsche", "Renault", "Seat", diff --git a/vehicle/mb/identity.go b/vehicle/mb/identity.go index 2cf12014a..5627e5ce2 100644 --- a/vehicle/mb/identity.go +++ b/vehicle/mb/identity.go @@ -2,10 +2,7 @@ package mb import ( "context" - "crypto/rand" - "encoding/base64" "fmt" - "io" "net/http" "net/http/cookiejar" "net/url" @@ -13,6 +10,7 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" + "github.com/samber/lo" "golang.org/x/net/publicsuffix" "golang.org/x/oauth2" ) @@ -36,15 +34,6 @@ func NewIdentity(log *util.Logger, oc *oauth2.Config) *Identity { } } -// github.com/uhthomas/tesla -func state() string { - var b [9]byte - if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { - panic(err) - } - return base64.RawURLEncoding.EncodeToString(b[:]) -} - func (v *Identity) Login(user, password string) error { if v.Client.Jar == nil { v.Client.Jar, _ = cookiejar.New(&cookiejar.Options{ @@ -57,7 +46,8 @@ func (v *Identity) Login(user, password string) error { cv := oauth2.GenerateVerifier() - uri := v.oc.AuthCodeURL(state(), oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cv)) + state := lo.RandomString(16, lo.AlphanumericCharset) + uri := v.oc.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cv)) if _, err := v.Get(uri); err != nil { return err } diff --git a/vehicle/polestar.go b/vehicle/polestar.go new file mode 100644 index 000000000..62aeeb0fb --- /dev/null +++ b/vehicle/polestar.go @@ -0,0 +1,67 @@ +package vehicle + +import ( + "context" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/polestar" +) + +// Polestar is an api.Vehicle implementation for Polestar cars +type Polestar struct { + *embed + *polestar.Provider +} + +func init() { + registry.Add("polestar", NewPolestarFromConfig) +} + +// NewPolestarFromConfig creates a new vehicle +func NewPolestarFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password string + VIN string + Cache time.Duration + Timeout time.Duration + }{ + Cache: interval, + Timeout: request.Timeout, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("polestar").Redact(cc.User, cc.Password, cc.VIN) + + v := &Polestar{ + embed: &cc.embed, + } + + identity := polestar.NewIdentity(log, polestar.OAuth2Config) + if err := identity.Login(cc.User, cc.Password); err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + api := polestar.NewAPI(log, identity) + + vehicle, err := ensureVehicleEx(cc.VIN, func() ([]polestar.ConsumerCar, error) { + ctx, cancel := context.WithTimeout(context.Background(), cc.Timeout) + defer cancel() + return api.Vehicles(ctx) + }, func(v polestar.ConsumerCar) string { + return v.VIN + }) + + if err == nil { + v.Provider = polestar.NewProvider(log, api, vehicle.VIN, cc.Timeout, cc.Cache) + } + + return v, err +} diff --git a/vehicle/polestar/api.go b/vehicle/polestar/api.go new file mode 100644 index 000000000..1ffc08a25 --- /dev/null +++ b/vehicle/polestar/api.go @@ -0,0 +1,69 @@ +package polestar + +import ( + "context" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/hasura/go-graphql-client" + "golang.org/x/oauth2" +) + +// https://github.com/TA2k/ioBroker.polestar + +const ApiURI = "https://pc-api.polestar.com/eu-north-1/my-star" + +type API struct { + *request.Helper + client *graphql.Client +} + +func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { + httpClient := request.NewClient(log) + + // replace client transport with authenticated transport + httpClient.Transport = &oauth2.Transport{ + Base: httpClient.Transport, + Source: identity, + } + + v := &API{ + Helper: request.NewHelper(log), + client: graphql.NewClient(ApiURI, httpClient), + } + + v.Client.Transport = httpClient.Transport + + return v +} + +func (v *API) Vehicles(ctx context.Context) ([]ConsumerCar, error) { + var res struct { + GetConsumerCarsV2 []ConsumerCar `graphql:"getConsumerCarsV2"` + } + + err := v.client.Query(ctx, &res, nil, graphql.OperationName("getCars")) + return res.GetConsumerCarsV2, err +} + +func (v *API) Status(ctx context.Context, vin string) (BatteryData, error) { + var res struct { + BatteryData `graphql:"getBatteryData(vin: $vin)"` + } + + err := v.client.Query(ctx, &res, map[string]interface{}{ + "vin": vin, + }, graphql.OperationName("GetBatteryData")) + return res.BatteryData, err +} + +func (v *API) Odometer(ctx context.Context, vin string) (OdometerData, error) { + var res struct { + OdometerData `graphql:"getOdometerData(vin: $vin)"` + } + + err := v.client.Query(ctx, &res, map[string]interface{}{ + "vin": vin, + }, graphql.OperationName("GetOdometerData")) + return res.OdometerData, err +} diff --git a/vehicle/polestar/identity.go b/vehicle/polestar/identity.go new file mode 100644 index 000000000..06566d67a --- /dev/null +++ b/vehicle/polestar/identity.go @@ -0,0 +1,142 @@ +package polestar + +import ( + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + "github.com/samber/lo" + "golang.org/x/net/publicsuffix" + "golang.org/x/oauth2" +) + +// https://github.com/TA2k/ioBroker.polestar + +const ( + OAuthURI = "https://polestarid.eu.polestar.com" + basicAuth = "cG9seHBsb3JlOlhhaUtvb0hlaXJlaXNvb3NhaDBFdjZxdW9oczhjb2hGZUtvaHdpZTFhZTdraWV3b2hkb295ZWk5QWVZZWlXb2g" +) + +// https://polestarid.eu.polestar.com/.well-known/openid-configuration +var OAuth2Config = &oauth2.Config{ + ClientID: "polxplore", + RedirectURL: "polestar-explore://explore.polestar.com", + Endpoint: oauth2.Endpoint{ + AuthURL: OAuthURI + "/as/authorization.oauth2", + TokenURL: OAuthURI + "/as/token.oauth2", + }, + Scopes: []string{"openid", "profile", "email", "customer:attributes"}, +} + +type Identity struct { + *request.Helper + oc *oauth2.Config + oauth2.TokenSource +} + +// NewIdentity creates Mercedes Benz identity +func NewIdentity(log *util.Logger, oc *oauth2.Config) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + oc: oc, + } +} + +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 := v.oc.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.S256ChallengeOption(cv)) + + var param request.InterceptResult + v.Client.CheckRedirect, param = request.InterceptRedirect("resumePath", false) + + _, err := v.Get(uri) + + var resume string + if err == nil { + resume, err = param() + } + + 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 + } + + 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 { + v.TokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), 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 + } + } + + return (*oauth2.Token)(&tok), err +} diff --git a/vehicle/polestar/provider.go b/vehicle/polestar/provider.go new file mode 100644 index 000000000..398fae77f --- /dev/null +++ b/vehicle/polestar/provider.go @@ -0,0 +1,84 @@ +package polestar + +import ( + "context" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/provider" + "github.com/evcc-io/evcc/util" +) + +// https://github.com/TA2k/ioBroker.polestar + +type Provider struct { + statusG func() (BatteryData, error) + odoG func() (OdometerData, error) +} + +func NewProvider(log *util.Logger, api *API, vin string, timeout, cache time.Duration) *Provider { + v := &Provider{ + statusG: provider.Cached(func() (BatteryData, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return api.Status(ctx, vin) + }, cache), + odoG: provider.Cached(func() (OdometerData, error) { + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + return api.Odometer(ctx, vin) + }, cache), + } + + return v +} + +// Soc implements the api.Vehicle interface +func (v *Provider) Soc() (float64, error) { + res, err := v.statusG() + return float64(res.BatteryChargeLevelPercentage), err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status, err := v.statusG() + + res := api.StatusA + if status.ChargerConnectionStatus == "CHARGER_CONNECTION_STATUS_CONNECTED" { + res = api.StatusB + } + if status.ChargingStatus == "CHARGING_STATUS_CHARGING" { + res = api.StatusB + } + + return res, err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + return int64(res.EstimatedDistanceToEmptyKm), err +} + +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 +} + +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.statusG() + if err != nil { + return time.Time{}, err + } + return time.Now().Add(time.Duration(res.EstimatedChargingTimeToFullMinutes) * time.Minute), nil +} diff --git a/vehicle/polestar/query.gql b/vehicle/polestar/query.gql new file mode 100644 index 000000000..b096297ca --- /dev/null +++ b/vehicle/polestar/query.gql @@ -0,0 +1,64 @@ +query getCars { + getConsumerCarsV2 { + vin + internalVehicleIdentifier + modelYear + content { + model { + code + name + __typename + } + images { + studio { + url + angles + __typename + } + __typename + } + __typename + } + hasPerformancePackage + registrationNo + deliveryDate + currentPlannedDeliveryDate + __typename + } +} + +query GetBatteryData($vin: String!) { + getBatteryData(vin: $vin) { + averageEnergyConsumptionKwhPer100Km + batteryChargeLevelPercentage + chargerConnectionStatus + chargingCurrentAmps + chargingPowerWatts + chargingStatus + estimatedChargingTimeMinutesToTargetDistance + estimatedChargingTimeToFullMinutes + estimatedDistanceToEmptyKm + estimatedDistanceToEmptyMiles + eventUpdatedTimestamp { + iso + unix + __typename + } + __typename + } +} + +query GetOdometerData($vin: String!) { + getOdometerData(vin: $vin) { + averageSpeedKmPerHour + eventUpdatedTimestamp { + iso + unix + __typename + } + odometerMeters + tripMeterAutomaticKm + tripMeterManualKm + __typename + } +} diff --git a/vehicle/polestar/types.go b/vehicle/polestar/types.go new file mode 100644 index 000000000..a01f4df1d --- /dev/null +++ b/vehicle/polestar/types.go @@ -0,0 +1,18 @@ +package polestar + +type ConsumerCar struct { + VIN string + InternalVehicleIdentifier string +} + +type BatteryData struct { + BatteryChargeLevelPercentage int + ChargerConnectionStatus string + ChargingStatus string + EstimatedChargingTimeToFullMinutes int + EstimatedDistanceToEmptyKm int +} + +type OdometerData struct { + OdometerMeters int +}