diff --git a/vehicle/porsche.go b/vehicle/porsche.go index 20158c5b3..abc1dc57c 100644 --- a/vehicle/porsche.go +++ b/vehicle/porsche.go @@ -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 diff --git a/vehicle/porsche/api.go b/vehicle/porsche/api.go new file mode 100644 index 000000000..551eeec5f --- /dev/null +++ b/vehicle/porsche/api.go @@ -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 +} diff --git a/vehicle/porsche/api_emobility.go b/vehicle/porsche/api_emobility.go new file mode 100644 index 000000000..f5f8bf260 --- /dev/null +++ b/vehicle/porsche/api_emobility.go @@ -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 +} diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go index 0eecd6a8c..a9ff23bbd 100644 --- a/vehicle/porsche/identity.go +++ b/vehicle/porsche/identity.go @@ -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 } diff --git a/vehicle/porsche/provider.go b/vehicle/porsche/provider.go index ca16d60b4..588027687 100644 --- a/vehicle/porsche/provider.go +++ b/vehicle/porsche/provider.go @@ -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": diff --git a/vehicle/porsche/types.go b/vehicle/porsche/types.go index c48b7d3a4..81ae15a1a 100644 --- a/vehicle/porsche/types.go +++ b/vehicle/porsche/types.go @@ -1,6 +1,6 @@ package porsche -type VehicleResponse struct { +type Vehicle struct { VIN string ModelDescription string Pictures []struct {