diff --git a/vehicle/porsche.go b/vehicle/porsche.go index 13fc1bf0d..3f6e98f2a 100644 --- a/vehicle/porsche.go +++ b/vehicle/porsche.go @@ -11,7 +11,11 @@ import ( type porscheProvider interface { api.Battery + api.ChargeState api.VehicleRange + api.VehicleClimater + api.VehicleFinishTimer + api.VehicleOdometer } // Porsche is an api.Vehicle implementation for Porsche cars @@ -47,17 +51,12 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, fmt.Errorf("login failed: %w", err) } - vehicle, err := identity.FindVehicle(accessTokens, cc.VIN) + vin, err := identity.FindVehicle(accessTokens, cc.VIN) if err != nil { return nil, err } - var provider porscheProvider - if vehicle.EmobilityVehicle { - provider = porsche.NewEMobilityProvider(log, identity, accessTokens.EmobilityToken, vehicle.VIN, cc.Cache) - } else { - provider = porsche.NewProvider(log, identity, accessTokens.Token, vehicle.VIN, cc.Cache) - } + provider := porsche.NewProvider(log, identity, accessTokens, vin, cc.Cache) v := &Porsche{ embed: &cc.embed, diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go index 9da3135c8..0eecd6a8c 100644 --- a/vehicle/porsche/identity.go +++ b/vehicle/porsche/identity.go @@ -7,9 +7,9 @@ import ( "net/http/cookiejar" "net/url" "strings" - "time" "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" @@ -21,13 +21,6 @@ const ( EmobilityClientID = "NJOxLv4QQNrpZnYQbb7mCvdiMxQWkHDq" ) -type tokenResponse struct { - AccessToken string `json:"access_token"` - IDToken string `json:"id_token"` - TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` -} - type AccessTokens struct { Token, EmobilityToken oauth2.Token } @@ -103,27 +96,24 @@ func (v *Identity) Login() (AccessTokens, error) { resp.Body.Close() // get the token for the generic API - var pr tokenResponse - if pr, err = v.fetchToken(false); err != nil { + var token oauth.Token + token, err = v.fetchToken(false) + if err != nil { return accessTokens, err } + accessTokens.Token = (oauth2.Token)(token) - accessTokens.Token.AccessToken = pr.AccessToken - accessTokens.Token.Expiry = time.Now().Add(time.Duration(pr.ExpiresIn) * time.Second) - - if pr, err = v.fetchToken(true); err != nil { - // we don't need to return this error, because we simply won't use the emobility API in this case - return accessTokens, nil + token, err = v.fetchToken(true) + if err != nil { + return accessTokens, err } + accessTokens.EmobilityToken = (oauth2.Token)(token) - accessTokens.EmobilityToken.AccessToken = pr.AccessToken - accessTokens.EmobilityToken.Expiry = time.Now().Add(time.Duration(pr.ExpiresIn) * time.Second) - - return accessTokens, nil + return accessTokens, err } -func (v *Identity) fetchToken(emobility bool) (tokenResponse, error) { - var pr tokenResponse +func (v *Identity) fetchToken(emobility bool) (oauth.Token, error) { + var pr oauth.Token actualClientID := ClientID redirectURI := "https://my.porsche.com/core/de/de_DE/" @@ -187,72 +177,81 @@ func (v *Identity) fetchToken(emobility bool) (tokenResponse, error) { err = v.DoJSON(req, &pr) } - if pr.AccessToken == "" || pr.ExpiresIn == 0 { + if pr.AccessToken == "" { return pr, errors.New("could not obtain token") } return pr, err } -type Vehicle struct { - VIN string - EmobilityVehicle bool -} - -type VehicleResponse struct { - VIN string - ModelDescription string - Pictures []struct { - URL string - View string - Size int - Width int - Height int - Transparent bool - } -} - -func (v *Identity) FindVehicle(accessTokens AccessTokens, vin string) (Vehicle, error) { - uri := "https://api.porsche.com/core/api/v3/de/de_DE/vehicles" - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ +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": EmobilityClientID, + "apikey": ClientID, }) + if err != nil { + return "", err + } + var vehicles []VehicleResponse - if err == nil { - err = v.DoJSON(req, &vehicles) + if err = v.DoJSON(req, &vehicles); err != nil { + return "", err } var foundVehicle VehicleResponse - var foundEmobilityVehicle bool - if err == nil { - if vin == "" && len(vehicles) == 1 { - foundVehicle = vehicles[0] - } else { - for _, vehicleItem := range vehicles { - if vehicleItem.VIN == strings.ToUpper(vin) { - foundVehicle = vehicleItem - } + if vin == "" && len(vehicles) == 1 { + foundVehicle = vehicles[0] + } else { + for _, vehicleItem := range vehicles { + if vehicleItem.VIN == strings.ToUpper(vin) { + foundVehicle = vehicleItem } } - - if foundVehicle.VIN != "" { - v.log.DEBUG.Printf("found vehicle: %v", foundVehicle.VIN) - - if accessTokens.EmobilityToken.AccessToken != "" { - foundEmobilityVehicle = true - } - } else { - err = errors.New("vin not found") - } + } + if foundVehicle.VIN == "" { + return "", errors.New("vin not found") } - vehicle := Vehicle{ - VIN: foundVehicle.VIN, - EmobilityVehicle: foundEmobilityVehicle, + 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 } - return vehicle, 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 } diff --git a/vehicle/porsche/provider.go b/vehicle/porsche/provider.go index c273dc5b6..3447bc943 100644 --- a/vehicle/porsche/provider.go +++ b/vehicle/porsche/provider.go @@ -1,6 +1,7 @@ package porsche import ( + "errors" "fmt" "net/http" "time" @@ -9,92 +10,135 @@ import ( "github.com/evcc-io/evcc/provider" "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/request" - "golang.org/x/oauth2" ) -type StatusResponse struct { - CarControlData struct { - BatteryLevel struct { - Unit string - Value float64 - } - Mileage struct { - Unit string - Value float64 - } - RemainingRanges struct { - ElectricalRange struct { - Distance struct { - Unit string - Value float64 - } - } - } - } -} - // Provider is an api.Vehicle implementation for Porsche PHEV cars type Provider struct { log *util.Logger *request.Helper - token oauth2.Token - identity *Identity - statusG func() (interface{}, error) + accessTokens AccessTokens + identity *Identity + carModel string + statusG func() (interface{}, error) + statusEmobilityG func() (interface{}, error) } // NewProvider creates a new vehicle -func NewProvider(log *util.Logger, identity *Identity, token oauth2.Token, vin string, cache time.Duration) *Provider { +func NewProvider(log *util.Logger, identity *Identity, accessTokens AccessTokens, vin string, cache time.Duration) *Provider { impl := &Provider{ - log: log, - Helper: request.NewHelper(log), - token: token, - identity: identity, + log: log, + Helper: request.NewHelper(log), + accessTokens: accessTokens, + identity: identity, } + 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(uri string) (*http.Request, error) { - if v.token.AccessToken == "" || time.Since(v.token.Expiry) > 0 { +func (v *Provider) request(emobility bool, uri string) (*http.Request, error) { + token := v.accessTokens.Token + apiKey := ClientID + if emobility { + token = v.accessTokens.EmobilityToken + apiKey = EmobilityClientID + } + + if token.AccessToken == "" || time.Since(token.Expiry) > 0 { accessTokens, err := v.identity.Login() if err != nil { return nil, err } - v.token = accessTokens.Token + v.accessTokens = accessTokens } req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", v.token.AccessToken), + "Authorization": fmt.Sprintf("Bearer %s", token.AccessToken), + "apikey": apiKey, }) return req, err } // Status implements the vehicle status response -func (v *Provider) status(vin string) (interface{}, error) { - uri := fmt.Sprintf("https://connect-portal.porsche.com/core/api/v3/de/de_DE/vehicles/%s", vin) - req, err := v.request(uri) +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 0, err + return res, err } - var pr StatusResponse - err = v.DoJSON(req, &pr) + err = v.DoJSON(req, &res) - return pr, err + 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.statusG() + res, err := v.statusEmobilityG() + if res, ok := res.(EmobilityResponse); err == nil && ok { + return float64(res.BatteryChargeStatus.StateOfChargeInPercentage), nil + } + + res, err = v.statusG() if res, ok := res.(StatusResponse); err == nil && ok { - return res.CarControlData.BatteryLevel.Value, nil + return res.BatteryLevel.Value, nil } return 0, err @@ -104,21 +148,83 @@ var _ api.VehicleRange = (*Provider)(nil) // Range implements the api.VehicleRange interface func (v *Provider) Range() (int64, error) { - res, err := v.statusG() + res, err := v.statusEmobilityG() + if res, ok := res.(EmobilityResponse); err == nil && ok { + return int64(res.BatteryChargeStatus.RemainingERange.ValueInKilometers), nil + } + + res, err = v.statusG() if res, ok := res.(StatusResponse); err == nil && ok { - return int64(res.CarControlData.RemainingRanges.ElectricalRange.Distance.Value), nil + return int64(res.RemainingRanges.ElectricalRange.Distance.Value), nil } return 0, err } +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.statusEmobilityG() + if res, ok := res.(EmobilityResponse); err == nil && ok { + return time.Now().Add(time.Duration(res.BatteryChargeStatus.RemainingChargeTimeUntil100PercentInMinutes) * time.Minute), err + } + + return time.Time{}, err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + res, err := v.statusEmobilityG() + if res, ok := res.(EmobilityResponse); err == nil && ok { + switch res.BatteryChargeStatus.PlugState { + case "DISCONNECTED": + return api.StatusA, nil + case "CONNECTED": + // ignore if the car is connected to a DC charging station + if res.BatteryChargeStatus.ChargingInDCMode { + return api.StatusA, nil + } + switch res.BatteryChargeStatus.ChargingState { + case "ERROR": + return api.StatusF, nil + case "OFF", "COMPLETED": + return api.StatusB, nil + case "ON": + return api.StatusC, nil + } + } + } + + return api.StatusNone, err +} + +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() + if res, ok := res.(EmobilityResponse); err == nil && ok { + switch res.DirectClimatisation.ClimatisationState { + case "OFF": + return false, 20, 20, nil + case "ON": + return true, 20, 20, nil + } + } + + return active, outsideTemp, targetTemp, err +} + var _ api.VehicleOdometer = (*Provider)(nil) // Odometer implements the api.VehicleOdometer interface func (v *Provider) Odometer() (float64, error) { res, err := v.statusG() if res, ok := res.(StatusResponse); err == nil && ok { - return res.CarControlData.Mileage.Value, nil + return res.Mileage.Value, nil } return 0, err diff --git a/vehicle/porsche/provider_emobility.go b/vehicle/porsche/provider_emobility.go deleted file mode 100644 index e487586c9..000000000 --- a/vehicle/porsche/provider_emobility.go +++ /dev/null @@ -1,227 +0,0 @@ -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" - "golang.org/x/oauth2" -) - -type CapabilitiesResponse struct { - DisplayParkingBrake bool - NeedsSPIN bool - HasRDK bool - EngineType string - CarModel string - OnlineRemoteUpdateStatus struct { - EditableByUser bool - Active bool - } - HeatingCapabilities struct { - FrontSeatHeatingAvailable bool - RearSeatHeatingAvailable bool - } - SteeringWheelPosition string - HasHonkAndFlash bool -} - -type EmobilityResponse struct { - BatteryChargeStatus struct { - ChargeRate struct { - Unit string - Value float64 - ValueInKmPerHour int64 - } - ChargingInDCMode bool - ChargingMode string - ChargingPower float64 - ChargingReason string - ChargingState string - ChargingTargetDateTime string - ExternalPowerSupplyState string - PlugState string - RemainingChargeTimeUntil100PercentInMinutes int64 - StateOfChargeInPercentage int64 - RemainingERange struct { - OriginalUnit string - OriginalValue int64 - Unit string - Value int64 - ValueInKilometers int64 - } - } - ChargingStatus string - DirectCharge struct { - Disabled bool - IsActive bool - } - DirectClimatisation struct { - ClimatisationState string - RemainingClimatisationTime int64 - } - PcckErrorMessage string -} - -// EMobilityProvider is an api.Vehicle implementation for Porsche Taycan cars -type EMobilityProvider struct { - log *util.Logger - *request.Helper - token oauth2.Token - identity *Identity - carModel string - statusG func() (interface{}, error) -} - -// NewEMobilityProvider creates a new vehicle -func NewEMobilityProvider(log *util.Logger, identity *Identity, token oauth2.Token, vin string, cache time.Duration) *EMobilityProvider { - impl := &EMobilityProvider{ - log: log, - token: token, - Helper: request.NewHelper(log), - identity: identity, - } - - impl.statusG = provider.NewCached(func() (interface{}, error) { - return impl.status(vin) - }, cache).InterfaceGetter() - - return impl -} - -func (v *EMobilityProvider) request(uri string) (*http.Request, error) { - if v.token.AccessToken == "" || time.Since(v.token.Expiry) > 0 { - accessTokens, err := v.identity.Login() - if err != nil { - return nil, err - } - v.token = accessTokens.EmobilityToken - } - - req, err := request.New(http.MethodGet, uri, nil, map[string]string{ - "Authorization": fmt.Sprintf("Bearer %s", v.token.AccessToken), - "apikey": EmobilityClientID, - }) - - return req, err -} - -// Status implements the vehicle status response -func (v *EMobilityProvider) status(vin string) (interface{}, error) { - if v.carModel == "" { - uri := fmt.Sprintf("https://api.porsche.com/e-mobility/vcs/capabilities/%s", vin) - req, err := v.request(uri) - if err != nil { - return 0, err - } - - req.Header.Set("x-vrs-url-country", "de") - req.Header.Set("x-vrs-url-language", "de_DE") - var cr CapabilitiesResponse - err = v.DoJSON(req, &cr) - if err != nil { - return 0, 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(uri) - if err != nil { - return 0, err - } - - var pr EmobilityResponse - err = v.DoJSON(req, &pr) - - if err != nil && pr.PcckErrorMessage != "" { - err = errors.New(pr.PcckErrorMessage) - } - - return pr, err -} - -var _ api.Battery = (*Provider)(nil) - -// SoC implements the api.Vehicle interface -func (v *EMobilityProvider) SoC() (float64, error) { - res, err := v.statusG() - if res, ok := res.(EmobilityResponse); err == nil && ok { - return float64(res.BatteryChargeStatus.StateOfChargeInPercentage), nil - } - - return 0, err -} - -var _ api.VehicleRange = (*Provider)(nil) - -// Range implements the api.VehicleRange interface -func (v *EMobilityProvider) Range() (int64, error) { - res, err := v.statusG() - if res, ok := res.(EmobilityResponse); err == nil && ok { - return int64(res.BatteryChargeStatus.RemainingERange.ValueInKilometers), nil - } - - return 0, err -} - -var _ api.VehicleFinishTimer = (*EMobilityProvider)(nil) - -// FinishTime implements the api.VehicleFinishTimer interface -func (v *EMobilityProvider) FinishTime() (time.Time, error) { - res, err := v.statusG() - if res, ok := res.(*EmobilityResponse); err == nil && ok { - t := time.Now() - return t.Add(time.Duration(res.BatteryChargeStatus.RemainingChargeTimeUntil100PercentInMinutes) * time.Minute), err - } - - return time.Time{}, err -} - -var _ api.ChargeState = (*EMobilityProvider)(nil) - -// Status implements the api.ChargeState interface -func (v *EMobilityProvider) Status() (api.ChargeStatus, error) { - res, err := v.statusG() - if res, ok := res.(EmobilityResponse); err == nil && ok { - switch res.BatteryChargeStatus.PlugState { - case "DISCONNECTED": - return api.StatusA, nil - case "CONNECTED": - // ignore if the car is connected to a DC charging station - if res.BatteryChargeStatus.ChargingInDCMode { - return api.StatusA, nil - } - switch res.BatteryChargeStatus.ChargingState { - case "OFF", "COMPLETED": - return api.StatusB, nil - case "ON": - return api.StatusC, nil - } - } - } - - return api.StatusNone, err -} - -var _ api.VehicleClimater = (*EMobilityProvider)(nil) - -// Climater implements the api.VehicleClimater interface -func (v *EMobilityProvider) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { - res, err := v.statusG() - if res, ok := res.(EmobilityResponse); err == nil && ok { - switch res.DirectClimatisation.ClimatisationState { - case "OFF": - return false, 0, 0, nil - case "ON": - return true, 0, 0, nil - } - } - - return active, outsideTemp, targetTemp, err -} diff --git a/vehicle/porsche/types.go b/vehicle/porsche/types.go new file mode 100644 index 000000000..c48b7d3a4 --- /dev/null +++ b/vehicle/porsche/types.go @@ -0,0 +1,95 @@ +package porsche + +type VehicleResponse struct { + VIN string + ModelDescription string + Pictures []struct { + URL string + View string + Size int + Width int + Height int + Transparent bool + } +} + +type VehiclePairingResponse struct { + VIN string + PairingCode string + Status string + CanSendPairingCode bool +} + +type StatusResponse struct { + BatteryLevel struct { + Unit string + Value float64 + } + Mileage struct { + Unit string + Value float64 + } + RemainingRanges struct { + ElectricalRange struct { + Distance struct { + Unit string + Value float64 + } + } + } +} + +type CapabilitiesResponse struct { + DisplayParkingBrake bool + NeedsSPIN bool + HasRDK bool + EngineType string + CarModel string + OnlineRemoteUpdateStatus struct { + EditableByUser bool + Active bool + } + HeatingCapabilities struct { + FrontSeatHeatingAvailable bool + RearSeatHeatingAvailable bool + } + SteeringWheelPosition string + HasHonkAndFlash bool +} + +type EmobilityResponse struct { + BatteryChargeStatus struct { + ChargeRate struct { + Unit string + Value float64 + ValueInKmPerHour int64 + } + ChargingInDCMode bool + ChargingMode string + ChargingPower float64 + ChargingReason string + ChargingState string + ChargingTargetDateTime string + ExternalPowerSupplyState string + PlugState string + RemainingChargeTimeUntil100PercentInMinutes int64 + StateOfChargeInPercentage int64 + RemainingERange struct { + OriginalUnit string + OriginalValue int64 + Unit string + Value int64 + ValueInKilometers int64 + } + } + ChargingStatus string + DirectCharge struct { + Disabled bool + IsActive bool + } + DirectClimatisation struct { + ClimatisationState string + RemainingClimatisationTime int64 + } + PcckErrorMessage string +}