Add Polestar (#11125)
This commit is contained in:
parent
19df92369e
commit
e5cf89e165
9 changed files with 460 additions and 13 deletions
12
templates/definition/vehicle/polestar.yaml
Normal file
12
templates/definition/vehicle/polestar.yaml
Normal 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" . }}
|
||||
|
|
@ -223,6 +223,7 @@
|
|||
"NIU",
|
||||
"Opel",
|
||||
"Peugeot",
|
||||
"Polestar",
|
||||
"Porsche",
|
||||
"Renault",
|
||||
"Seat",
|
||||
|
|
|
|||
|
|
@ -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
67
vehicle/polestar.go
Normal 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
69
vehicle/polestar/api.go
Normal 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
|
||||
}
|
||||
142
vehicle/polestar/identity.go
Normal file
142
vehicle/polestar/identity.go
Normal 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
|
||||
}
|
||||
84
vehicle/polestar/provider.go
Normal file
84
vehicle/polestar/provider.go
Normal 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
|
||||
}
|
||||
64
vehicle/polestar/query.gql
Normal file
64
vehicle/polestar/query.gql
Normal 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
18
vehicle/polestar/types.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue