Porsche: refactor and use oauth2 library (#2115)

This commit is contained in:
andig 2021-12-25 14:14:09 +01:00 • committed by GitHub
parent ef68405fb8
commit 78138c91bf
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 259 additions and 303 deletions

View file

@ -1,27 +1,20 @@
package vehicle
import (
"errors"
"fmt"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/porsche"
"github.com/thoas/go-funk"
)
type porscheProvider interface {
api.Battery
api.ChargeState
api.VehicleRange
api.VehicleClimater
api.VehicleFinishTimer
api.VehicleOdometer
}
// Porsche is an api.Vehicle implementation for Porsche cars
type Porsche struct {
*embed
porscheProvider // provides the api implementations
*porsche.Provider
}
func init() {
@ -47,23 +40,59 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) {
}
log := util.NewLogger("porsche").Redact(cc.User, cc.Password, cc.VIN)
identity := porsche.NewIdentity(log, cc.User, cc.Password)
identity := porsche.NewIdentity(log)
accessTokens, err := identity.Login()
if err != nil {
if err := identity.Login(cc.User, cc.Password); err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
vin, err := identity.FindVehicle(accessTokens, cc.VIN)
api := porsche.NewAPI(log, identity.DefaultSource)
if cc.VIN == "" {
vehicles, err := api.Vehicles()
cc.VIN, err = findVehicle(funk.Map(vehicles, func(v porsche.Vehicle) string {
return v.VIN
}).([]string), err)
if err != nil {
return nil, err
}
log.DEBUG.Printf("found vehicle: %v", cc.VIN)
}
// check if vehicle is paired
if res, err := api.PairingStatus(cc.VIN); err == nil && res.Status != porsche.PairingComplete {
return nil, errors.New("vehicle is not paired with the My Porsche account")
}
// check if vehicle provides status:
// some PHEVs do not provide any data
if _, err := api.Status(cc.VIN); err != nil {
return nil, errors.New("vehicle is not capable of providing data")
}
// get eMobility capabilities
// Note: As of 27.10.21 the capabilities API needs to be called AFTER a
// call to status() as it otherwise returns an HTTP 502 error.
// The reason is unknown, even when tested with 100% identical Headers.
// It seems to be a new backend related issue.
if _, err := api.Status(cc.VIN); err != nil {
return nil, err
}
emobility := porsche.NewEmobilityAPI(log, identity.EmobilitySource)
capabilities, err := emobility.Capabilities(cc.VIN)
if err != nil {
return nil, err
}
provider := porsche.NewProvider(log, identity, accessTokens, vin, cc.Cache)
provider := porsche.NewProvider(log, api, emobility, cc.VIN, capabilities.CarModel, cc.Cache)
v := &Porsche{
embed: &cc.embed,
porscheProvider: provider,
embed: &cc.embed,
Provider: provider,
}
return v, err

64
vehicle/porsche/api.go Normal file
View file

@ -0,0 +1,64 @@
package porsche
import (
"fmt"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
const (
ApiURI = "https://api.porsche.com"
PairingComplete = "PAIRINGCOMPLETE"
)
// API is an api.Vehicle implementation for Porsche PHEV cars
type API struct {
*request.Helper
}
// NewAPI creates a new vehicle
func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API {
v := &API{
Helper: request.NewHelper(log),
}
v.Client.Transport = &transport.Decorator{
Base: &oauth2.Transport{
Source: identity,
Base: v.Client.Transport,
},
Decorator: transport.DecorateHeaders(map[string]string{
"apikey": OAuth2Config.ClientID,
}),
}
return v
}
// Vehicles implements the vehicle list response
func (v *API) Vehicles() ([]Vehicle, error) {
var res []Vehicle
uri := fmt.Sprintf("%s/core/api/v3/de/de_DE/vehicles", ApiURI)
err := v.GetJSON(uri, &res)
return res, err
}
// PairingStatus implements the vehicle pairing status response
func (v *API) PairingStatus(vin string) (VehiclePairingResponse, error) {
var res VehiclePairingResponse
uri := fmt.Sprintf("%s/core/api/v3/de/de_DE/vehicles/%s/pairing", ApiURI, vin)
err := v.GetJSON(uri, &res)
return res, err
}
// Status implements the vehicle status response
func (v *API) Status(vin string) (StatusResponse, error) {
var res StatusResponse
uri := fmt.Sprintf("%s/vehicle-data/de/de_DE/status/%s", ApiURI, vin)
err := v.GetJSON(uri, &res)
return res, err
}

View file

@ -0,0 +1,55 @@
package porsche
import (
"errors"
"fmt"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
// EmobilityAPI is the Porsche Emobility API
type EmobilityAPI struct {
*request.Helper
}
// NewEmobilityAPI creates a new vehicle
func NewEmobilityAPI(log *util.Logger, identity oauth2.TokenSource) *EmobilityAPI {
v := &EmobilityAPI{
Helper: request.NewHelper(log),
}
v.Client.Transport = &transport.Decorator{
Base: &oauth2.Transport{
Source: identity,
Base: v.Client.Transport,
},
Decorator: transport.DecorateHeaders(map[string]string{
"apikey": EmobilityOAuth2Config.ClientID,
}),
}
return v
}
func (v *EmobilityAPI) Capabilities(vin string) (CapabilitiesResponse, error) {
var res CapabilitiesResponse
uri := fmt.Sprintf("https://api.porsche.com/e-mobility/vcs/capabilities/%s", vin)
err := v.GetJSON(uri, &res)
return res, err
}
// Status implements the vehicle status response
func (v *EmobilityAPI) Status(vin, model string) (EmobilityResponse, error) {
var res EmobilityResponse
uri := fmt.Sprintf("https://api.porsche.com/e-mobility/de/de_DE/%s/%s?timezone=Europe/Berlin", model, vin)
err := v.GetJSON(uri, &res)
if err != nil && res.PcckErrorMessage != "" {
err = errors.New(res.PcckErrorMessage)
}
return res, err
}

View file

@ -1,6 +1,7 @@
package porsche
import (
"context"
"errors"
"fmt"
"net/http"
@ -9,7 +10,6 @@ import (
"strings"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
cv "github.com/nirasan/go-oauth-pkce-code-verifier"
"golang.org/x/net/publicsuffix"
@ -17,56 +17,75 @@ import (
)
const (
ClientID = "4mPO3OE5Srjb1iaUGWsbqKBvvesya8oA"
EmobilityClientID = "NJOxLv4QQNrpZnYQbb7mCvdiMxQWkHDq"
OAuthURI = "https://login.porsche.com"
)
type AccessTokens struct {
Token, EmobilityToken oauth2.Token
}
// https://login.porsche.com/.well-known/openid-configuration
var (
OAuth2Config = &oauth2.Config{
ClientID: "4mPO3OE5Srjb1iaUGWsbqKBvvesya8oA",
RedirectURL: "https://my.porsche.com/core/de/de_DE/",
Endpoint: oauth2.Endpoint{
AuthURL: OAuthURI + "/as/authorization.oauth2",
TokenURL: OAuthURI + "/as/token.oauth2",
},
Scopes: []string{"openid"},
}
EmobilityOAuth2Config = &oauth2.Config{
ClientID: "NJOxLv4QQNrpZnYQbb7mCvdiMxQWkHDq",
RedirectURL: "https://my.porsche.com/myservices/auth/auth.html",
Endpoint: OAuth2Config.Endpoint,
Scopes: OAuth2Config.Scopes,
}
)
// Identity is the Porsche Identity client
type Identity struct {
log *util.Logger
*request.Helper
user, password string
DefaultSource, EmobilitySource oauth2.TokenSource
}
// NewIdentity creates Porsche identity
func NewIdentity(log *util.Logger, user, password string) *Identity {
func NewIdentity(log *util.Logger) *Identity {
v := &Identity{
log: log,
Helper: request.NewHelper(log),
user: user,
password: password,
log: log,
Helper: request.NewHelper(log),
}
jar, _ := cookiejar.New(&cookiejar.Options{
return v
}
func (v *Identity) Login(user, password string) error {
jar, err := cookiejar.New(&cookiejar.Options{
PublicSuffixList: publicsuffix.List,
})
if err != nil {
return err
}
// track cookies and follow all (>10) redirects
v.Client.Jar = jar
v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
return nil
}
defer func() {
v.Client.Jar = nil
v.Client.CheckRedirect = nil
}()
return v
}
func (v *Identity) Login() (AccessTokens, error) {
var accessTokens AccessTokens
// get the login page to get the cookies for the subsequent requests
resp, err := v.Client.Get("https://login.porsche.com/auth/de/de_DE")
// get the login page
uri := fmt.Sprintf("%s/auth/api/v1/de/de_DE/public/login", OAuthURI)
resp, err := v.Get(uri)
if err != nil {
return accessTokens, err
return err
}
resp.Body.Close()
query, err := url.ParseQuery(resp.Request.URL.RawQuery)
if err != nil {
return accessTokens, err
return err
}
sec := query.Get("sec")
@ -79,179 +98,73 @@ func (v *Identity) Login() (AccessTokens, error) {
"resume": []string{resume},
"thirdPartyId": []string{thirdPartyID},
"state": []string{state},
"username": []string{v.user},
"password": []string{v.password},
"username": []string{user},
"password": []string{password},
"keeploggedin": []string{"false"},
}
req, err := request.New(http.MethodPost, "https://login.porsche.com/auth/api/v1/de/de_DE/public/login", strings.NewReader(dataLoginAuth.Encode()), request.URLEncoding)
req, err := request.New(http.MethodPost, uri, strings.NewReader(dataLoginAuth.Encode()), request.URLEncoding)
if err != nil {
return accessTokens, err
return err
}
// process the auth so the session is authenticated
if resp, err = v.Client.Do(req); err != nil {
return accessTokens, err
return err
}
resp.Body.Close()
// get the token for the generic API
var token oauth.Token
token, err = v.fetchToken(false)
if err != nil {
return accessTokens, err
}
accessTokens.Token = (oauth2.Token)(token)
token, err := v.fetchToken(OAuth2Config)
if err == nil {
v.DefaultSource = OAuth2Config.TokenSource(context.Background(), token)
token, err = v.fetchToken(true)
if err != nil {
return accessTokens, err
if token, err = v.fetchToken(EmobilityOAuth2Config); err == nil {
v.EmobilitySource = EmobilityOAuth2Config.TokenSource(context.Background(), token)
}
}
accessTokens.EmobilityToken = (oauth2.Token)(token)
return accessTokens, err
return err
}
func (v *Identity) fetchToken(emobility bool) (oauth.Token, error) {
var pr oauth.Token
actualClientID := ClientID
redirectURI := "https://my.porsche.com/core/de/de_DE/"
if emobility {
actualClientID = EmobilityClientID
redirectURI = "https://my.porsche.com/myservices/auth/auth.html"
}
var CodeVerifier, _ = cv.CreateCodeVerifier()
codeChallenge := CodeVerifier.CodeChallengeS256()
dataTokenAuth := url.Values{
"redirect_uri": []string{redirectURI},
"client_id": []string{actualClientID},
"response_type": []string{"code"},
"state": []string{"uvobn7XJs1"},
"scope": []string{"openid"},
"access_type": []string{"offline"},
"country": []string{"de"},
"locale": []string{"de_DE"},
"code_challenge": []string{codeChallenge},
"code_challenge_method": []string{"S256"},
}
req, err := http.NewRequest(http.MethodGet, "https://login.porsche.com/as/authorization.oauth2", nil)
func (v *Identity) fetchToken(oc *oauth2.Config) (*oauth2.Token, error) {
cv, err := cv.CreateCodeVerifier()
if err != nil {
return pr, err
return nil, err
}
req.URL.RawQuery = dataTokenAuth.Encode()
uri := oc.AuthCodeURL("uvobn7XJs1", oauth2.AccessTypeOffline,
oauth2.SetAuthURLParam("code_challenge", cv.CodeChallengeS256()),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
oauth2.SetAuthURLParam("country", "de"),
oauth2.SetAuthURLParam("locale", "de_DE"),
)
resp, err := v.Client.Do(req)
resp, err := v.Client.Get(uri)
if err != nil {
return pr, err
return nil, err
}
resp.Body.Close()
query, err := url.ParseQuery(resp.Request.URL.RawQuery)
if err != nil {
return pr, err
return nil, err
}
authCode := query.Get("code")
if authCode == "" {
return pr, errors.New("no auth code")
code := query.Get("code")
if code == "" {
return nil, errors.New("no auth code")
}
codeVerifier := CodeVerifier.CodeChallengePlain()
ctx, cancel := context.WithTimeout(
context.WithValue(context.Background(), oauth2.HTTPClient, v.Client),
request.Timeout,
)
defer cancel()
dataAPIToken := url.Values{
"grant_type": []string{"authorization_code"},
"client_id": []string{actualClientID},
"redirect_uri": []string{redirectURI},
"code": []string{authCode},
"code_verifier": []string{codeVerifier},
}
token, err := oc.Exchange(ctx, code,
oauth2.SetAuthURLParam("code_verifier", cv.CodeChallengePlain()),
)
req, err = request.New(http.MethodPost, "https://login.porsche.com/as/token.oauth2", strings.NewReader(dataAPIToken.Encode()), request.URLEncoding)
if err == nil {
err = v.DoJSON(req, &pr)
}
if pr.AccessToken == "" {
return pr, errors.New("could not obtain token")
}
return pr, err
}
func (v *Identity) FindVehicle(accessTokens AccessTokens, vin string) (string, error) {
vehiclesURL := "https://api.porsche.com/core/api/v3/de/de_DE/vehicles"
req, err := request.New(http.MethodGet, vehiclesURL, nil, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", accessTokens.Token.AccessToken),
"apikey": ClientID,
})
if err != nil {
return "", err
}
var vehicles []VehicleResponse
if err = v.DoJSON(req, &vehicles); err != nil {
return "", err
}
var foundVehicle VehicleResponse
if vin == "" && len(vehicles) == 1 {
foundVehicle = vehicles[0]
} else {
for _, vehicleItem := range vehicles {
if vehicleItem.VIN == strings.ToUpper(vin) {
foundVehicle = vehicleItem
}
}
}
if foundVehicle.VIN == "" {
return "", errors.New("vin not found")
}
v.log.DEBUG.Printf("found vehicle: %v", foundVehicle.VIN)
// check if vehicle is paired
uri := fmt.Sprintf("%s/%s/pairing", vehiclesURL, foundVehicle.VIN)
req, err = request.New(http.MethodGet, uri, nil, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", accessTokens.Token.AccessToken),
"apikey": ClientID,
})
if err != nil {
return "", err
}
var pairing VehiclePairingResponse
if err = v.DoJSON(req, &pairing); err != nil {
return "", err
}
if pairing.Status != "PAIRINGCOMPLETE" {
return "", errors.New("vehicle is not paired with the My Porsche account")
}
// now check if we get any response at all for a status request
// there are PHEV which do not provide any data, even thought they are PHEV
uri = fmt.Sprintf("https://api.porsche.com/vehicle-data/de/de_DE/status/%s", foundVehicle.VIN)
req, err = request.New(http.MethodGet, uri, nil, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", accessTokens.Token.AccessToken),
"apikey": ClientID,
})
if err != nil {
return "", err
}
if _, err = v.DoBody(req); err != nil {
return "", errors.New("vehicle is not capable of providing data")
}
return foundVehicle.VIN, err
return token, err
}

View file

@ -1,144 +1,39 @@
package porsche
import (
"errors"
"fmt"
"net/http"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/provider"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
)
// Provider is an api.Vehicle implementation for Porsche PHEV cars
type Provider struct {
log *util.Logger
*request.Helper
accessTokens AccessTokens
identity *Identity
carModel string
statusG func() (interface{}, error)
statusEmobilityG func() (interface{}, error)
statusG func() (interface{}, error)
emobilityG func() (interface{}, error)
}
// NewProvider creates a new vehicle
func NewProvider(log *util.Logger, identity *Identity, accessTokens AccessTokens, vin string, cache time.Duration) *Provider {
func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, vin, model string, cache time.Duration) *Provider {
impl := &Provider{
log: log,
Helper: request.NewHelper(log),
accessTokens: accessTokens,
identity: identity,
statusG: provider.NewCached(func() (interface{}, error) {
return api.Status(vin)
}, cache).InterfaceGetter(),
emobilityG: provider.NewCached(func() (interface{}, error) {
return emobility.Status(vin, model)
}, cache).InterfaceGetter(),
}
impl.Client.Jar = identity.Client.Jar
impl.statusG = provider.NewCached(func() (interface{}, error) {
return impl.status(vin)
}, cache).InterfaceGetter()
impl.statusEmobilityG = provider.NewCached(func() (interface{}, error) {
return impl.statusEmobility(vin)
}, cache).InterfaceGetter()
return impl
}
func (v *Provider) request(emobility bool, uri string) (*http.Request, error) {
defaultToken := v.accessTokens.Token
emobilityToken := v.accessTokens.EmobilityToken
// if any of the two Tokens are empty or expired, login again
if defaultToken.AccessToken == "" ||
time.Since(defaultToken.Expiry) > 0 ||
emobilityToken.AccessToken == "" ||
time.Since(emobilityToken.Expiry) > 0 {
newAccessTokens, err := v.identity.Login()
if err != nil {
return nil, err
}
v.accessTokens = newAccessTokens
}
apiKey := ClientID
apiAccessToken := v.accessTokens.Token.AccessToken
if emobility {
apiKey = EmobilityClientID
apiAccessToken = v.accessTokens.EmobilityToken.AccessToken
}
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", apiAccessToken),
"apikey": apiKey,
})
return req, err
}
// Status implements the vehicle status response
func (v *Provider) status(vin string) (StatusResponse, error) {
var res StatusResponse
uri := fmt.Sprintf("https://api.porsche.com/vehicle-data/de/de_DE/status/%s", vin)
req, err := v.request(false, uri)
if err != nil {
return res, err
}
err = v.DoJSON(req, &res)
return res, err
}
// Status implements the vehicle status response
func (v *Provider) statusEmobility(vin string) (EmobilityResponse, error) {
var res EmobilityResponse
if v.carModel == "" {
// Note: As of 27.10.21 the capabilities API needs to be called AFTER a
// call to status() as it otherwise returns an HTTP 502 error.
// The reason is unknown, even when tested with 100% identical Headers.
// It seems to be a new backend related issue.
if _, err := v.status(vin); err != nil {
return res, err
}
uri := fmt.Sprintf("https://api.porsche.com/e-mobility/vcs/capabilities/%s", vin)
req, err := v.request(true, uri)
if err != nil {
return res, err
}
var cr CapabilitiesResponse
err = v.DoJSON(req, &cr)
if err != nil {
return res, err
}
v.carModel = cr.CarModel
}
uri := fmt.Sprintf("https://api.porsche.com/e-mobility/de/de_DE/%s/%s?timezone=Europe/Berlin", v.carModel, vin)
req, err := v.request(true, uri)
if err != nil {
return res, err
}
err = v.DoJSON(req, &res)
if err != nil && res.PcckErrorMessage != "" {
err = errors.New(res.PcckErrorMessage)
}
return res, err
}
var _ api.Battery = (*Provider)(nil)
// SoC implements the api.Vehicle interface
func (v *Provider) SoC() (float64, error) {
res, err := v.statusEmobilityG()
res, err := v.emobilityG()
if res, ok := res.(EmobilityResponse); err == nil && ok {
return float64(res.BatteryChargeStatus.StateOfChargeInPercentage), nil
}
@ -155,7 +50,7 @@ var _ api.VehicleRange = (*Provider)(nil)
// Range implements the api.VehicleRange interface
func (v *Provider) Range() (int64, error) {
res, err := v.statusEmobilityG()
res, err := v.emobilityG()
if res, ok := res.(EmobilityResponse); err == nil && ok {
return res.BatteryChargeStatus.RemainingERange.ValueInKilometers, nil
}
@ -172,7 +67,7 @@ var _ api.VehicleFinishTimer = (*Provider)(nil)
// FinishTime implements the api.VehicleFinishTimer interface
func (v *Provider) FinishTime() (time.Time, error) {
res, err := v.statusEmobilityG()
res, err := v.emobilityG()
if res, ok := res.(EmobilityResponse); err == nil && ok {
return time.Now().Add(time.Duration(res.BatteryChargeStatus.RemainingChargeTimeUntil100PercentInMinutes) * time.Minute), err
}
@ -184,7 +79,7 @@ var _ api.ChargeState = (*Provider)(nil)
// Status implements the api.ChargeState interface
func (v *Provider) Status() (api.ChargeStatus, error) {
res, err := v.statusEmobilityG()
res, err := v.emobilityG()
if res, ok := res.(EmobilityResponse); err == nil && ok {
switch res.BatteryChargeStatus.PlugState {
case "DISCONNECTED":
@ -212,7 +107,7 @@ var _ api.VehicleClimater = (*Provider)(nil)
// Climater implements the api.VehicleClimater interface
func (v *Provider) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) {
res, err := v.statusEmobilityG()
res, err := v.emobilityG()
if res, ok := res.(EmobilityResponse); err == nil && ok {
switch res.DirectClimatisation.ClimatisationState {
case "OFF":

View file

@ -1,6 +1,6 @@
package porsche
type VehicleResponse struct {
type Vehicle struct {
VIN string
ModelDescription string
Pictures []struct {