Add Polestar (#11125)

This commit is contained in:
andig 2023-12-17 13:16:10 +01:00 • committed by GitHub
parent 19df92369e
commit e5cf89e165
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 460 additions and 13 deletions

View file

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

View file

@ -223,6 +223,7 @@
"NIU",
"Opel",
"Peugeot",
"Polestar",
"Porsche",
"Renault",
"Seat",

View file

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

67
vehicle/polestar.go Normal file
View file

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

69
vehicle/polestar/api.go Normal file
View file

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

View file

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

View file

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

View file

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

18
vehicle/polestar/types.go Normal file
View file

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