Porsche: add apis and fix model selection (#1798)

- Make Odomoter, Climater, ChargeState, FinishTime APIs actually usable, they weren't exposed before
- Combine Provider and ProviderEmobility into one, as they complement each other instead of being different APIs for the same purpose
- Fix SoC not working since 27th October. The Emobility API only works if the Status API is called before. Otherwise the Emobility API returns HTTP 502
- Call status api only once before calling the capabilities Emobility API, as the 502 error only happens for that one call which we only need once per vehicle anyway
- Return proper `api.ErrNotAvailable` errors for vehicles not supporting a specific API
- Only do one call at best for every API. Status API is used if Emobility API is called but fails
This commit is contained in:
Andreas Linde 2021-10-28 16:32:59 +02:00 • committed by GitHub
parent b55b2643a3
commit e1f8a50062
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 322 additions and 350 deletions

View file

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

View file

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

View file

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

View file

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

95
vehicle/porsche/types.go Normal file
View file

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