From 62680b6f205a3929f8008e02e7701419ccec8879 Mon Sep 17 00:00:00 2001 From: Andreas Linde <42185+DerAndereAndi@users.noreply.github.com> Date: Mon, 31 Jan 2022 14:05:16 +0100 Subject: [PATCH] Add Porsche Mobile API support (#2439) --- vehicle/porsche.go | 40 ++++++---- vehicle/porsche/api.go | 3 +- vehicle/porsche/api_mobile.go | 55 ++++++++++++++ vehicle/porsche/identity.go | 45 ++++++++++-- vehicle/porsche/provider.go | 115 ++++++++++++++++++++++++++--- vehicle/porsche/types.go | 134 ++++++++++++++++++++++++++++++++++ 6 files changed, 364 insertions(+), 28 deletions(-) create mode 100644 vehicle/porsche/api_mobile.go diff --git a/vehicle/porsche.go b/vehicle/porsche.go index e8baecff0..d1f8e5280 100644 --- a/vehicle/porsche.go +++ b/vehicle/porsche.go @@ -48,8 +48,16 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { } api := porsche.NewAPI(log, identity.DefaultSource) + mobile := porsche.NewMobileAPI(log, identity.MobileSource) cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) { + mobileVehicles, err := mobile.Vehicles() + if err == nil { + return funk.Map(mobileVehicles, func(v porsche.StatusResponseMobile) string { + return v.VIN + }).([]string), err + } + vehicles, err := api.Vehicles() return funk.Map(vehicles, func(v porsche.Vehicle) string { return v.VIN @@ -61,13 +69,22 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { } // 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") - } + // 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 { + statusAvailable := false + if _, err := mobile.Status(cc.VIN); err == nil { + statusAvailable = true + } + + if _, err := api.Status(cc.VIN); err == nil { + statusAvailable = true + } + + if !statusAvailable { return nil, errors.New("vehicle is not capable of providing data") } @@ -77,17 +94,14 @@ func NewPorscheFromConfig(other map[string]interface{}) (api.Vehicle, error) { // 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 + var emobility *porsche.EmobilityAPI + var capabilities porsche.CapabilitiesResponse + if _, err := api.Status(cc.VIN); err == nil { + emobility = porsche.NewEmobilityAPI(log, identity.EmobilitySource) + capabilities, _ = emobility.Capabilities(cc.VIN) } - emobility := porsche.NewEmobilityAPI(log, identity.EmobilitySource) - capabilities, err := emobility.Capabilities(cc.VIN) - if err != nil { - return nil, err - } - - provider := porsche.NewProvider(log, api, emobility, cc.VIN, capabilities.CarModel, cc.Cache) + provider := porsche.NewProvider(log, api, emobility, mobile, cc.VIN, capabilities.CarModel, cc.Cache) v := &Porsche{ embed: &cc.embed, diff --git a/vehicle/porsche/api.go b/vehicle/porsche/api.go index 551eeec5f..772770b4b 100644 --- a/vehicle/porsche/api.go +++ b/vehicle/porsche/api.go @@ -32,7 +32,8 @@ func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { Base: v.Client.Transport, }, Decorator: transport.DecorateHeaders(map[string]string{ - "apikey": OAuth2Config.ClientID, + "apikey": OAuth2Config.ClientID, + "x-client-id": "52064df8-6daa-46f7-bc9e-e3232622ab26", }), } diff --git a/vehicle/porsche/api_mobile.go b/vehicle/porsche/api_mobile.go new file mode 100644 index 000000000..ba03b93f8 --- /dev/null +++ b/vehicle/porsche/api_mobile.go @@ -0,0 +1,55 @@ +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 ( + MobileApiURI = "https://api.ppa.porsche.com" +) + +// API is an api.Vehicle implementation for Porsche PHEV cars +type MobileAPI struct { + *request.Helper +} + +// NewAPI creates a new vehicle +func NewMobileAPI(log *util.Logger, identity oauth2.TokenSource) *MobileAPI { + v := &MobileAPI{ + 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, + "x-client-id": "52064df8-6daa-46f7-bc9e-e3232622ab26", + }), + } + + return v +} + +// Vehicles implements the vehicle list response +func (v *MobileAPI) Vehicles() ([]StatusResponseMobile, error) { + var res []StatusResponseMobile + uri := fmt.Sprintf("%s/app/connect/v1/vehicles", MobileApiURI) + err := v.GetJSON(uri, &res) + return res, err +} + +// Status implements the vehicle status response +func (v *MobileAPI) Status(vin string) (StatusResponseMobile, error) { + var res StatusResponseMobile + uri := fmt.Sprintf("%s/app/connect/v1/vehicles/%s?mf=*", MobileApiURI, vin) + err := v.GetJSON(uri, &res) + return res, err +} diff --git a/vehicle/porsche/identity.go b/vehicle/porsche/identity.go index 3a27b54d9..1c8522e44 100644 --- a/vehicle/porsche/identity.go +++ b/vehicle/porsche/identity.go @@ -37,16 +37,23 @@ var ( ClientID: "NJOxLv4QQNrpZnYQbb7mCvdiMxQWkHDq", RedirectURL: "https://my.porsche.com/myservices/auth/auth.html", Endpoint: OAuth2Config.Endpoint, - Scopes: OAuth2Config.Scopes, + Scopes: []string{"openid"}, + } + + MobileOAuth2Config = &oauth2.Config{ + ClientID: "L20OiZ0kBgWt958NWbuCB8gb970y6V6U", + RedirectURL: "One-Product-App://porsche-id/oauth2redirect", + Endpoint: OAuth2Config.Endpoint, + Scopes: []string{"openid", "magiclink", "mbb"}, } ) // Identity is the Porsche Identity client type Identity struct { *request.Helper - user, password string - defaultToken, emobilityToken *oauth2.Token - DefaultSource, EmobilitySource oauth2.TokenSource + user, password string + defaultToken, emobilityToken, mobileToken *oauth2.Token + DefaultSource, EmobilitySource, MobileSource oauth2.TokenSource } // NewIdentity creates Porsche identity @@ -66,6 +73,7 @@ func (v *Identity) Login() error { if err == nil { v.DefaultSource = oauth.RefreshTokenSource(v.defaultToken, v) v.EmobilitySource = oauth.RefreshTokenSource(v.emobilityToken, &emobilityAdapter{v}) + v.MobileSource = oauth.RefreshTokenSource(v.mobileToken, &mobileAdapter{v}) } return err @@ -137,6 +145,10 @@ func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { if token, err = v.fetchToken(EmobilityOAuth2Config); err == nil { v.emobilityToken = token } + + if token, err = v.fetchToken(MobileOAuth2Config); err == nil { + v.mobileToken = token + } } return v.defaultToken, err @@ -155,13 +167,24 @@ func (v *Identity) fetchToken(oc *oauth2.Config) (*oauth2.Token, error) { oauth2.SetAuthURLParam("locale", "de_DE"), ) + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + if req.URL.Scheme != "https" { + return http.ErrUseLastResponse + } + return nil + } + resp, err := v.Client.Get(uri) if err != nil { return nil, err } resp.Body.Close() - query, err := url.ParseQuery(resp.Request.URL.RawQuery) + rawQuery := resp.Request.URL.RawQuery + if strings.HasPrefix(resp.Header.Get("Location"), "One-Product-App") { + rawQuery = strings.Replace(resp.Header.Get("Location"), "One-Product-App://porsche-id/oauth2redirect?", "", 1) + } + query, err := url.ParseQuery(rawQuery) if err != nil { return nil, err } @@ -195,3 +218,15 @@ func (v *emobilityAdapter) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) } return token, err } + +type mobileAdapter struct { + tr *Identity +} + +func (v *mobileAdapter) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { + token, err := v.tr.RefreshToken(nil) + if err == nil { + token = v.tr.mobileToken + } + return token, err +} diff --git a/vehicle/porsche/provider.go b/vehicle/porsche/provider.go index 588027687..6c6dcdf4c 100644 --- a/vehicle/porsche/provider.go +++ b/vehicle/porsche/provider.go @@ -1,6 +1,7 @@ package porsche import ( + "errors" "time" "github.com/evcc-io/evcc/api" @@ -12,17 +13,25 @@ import ( type Provider struct { statusG func() (interface{}, error) emobilityG func() (interface{}, error) + mobileG func() (interface{}, error) } // NewProvider creates a new vehicle -func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, vin, model string, cache time.Duration) *Provider { +func NewProvider(log *util.Logger, api *API, emobility *EmobilityAPI, mobile *MobileAPI, vin string, carModel string, cache time.Duration) *Provider { impl := &Provider{ statusG: provider.NewCached(func() (interface{}, error) { return api.Status(vin) }, cache).InterfaceGetter(), emobilityG: provider.NewCached(func() (interface{}, error) { - return emobility.Status(vin, model) + if carModel != "" { + return emobility.Status(vin, carModel) + } + return EmobilityResponse{}, errors.New("no car model") + }, cache).InterfaceGetter(), + + mobileG: provider.NewCached(func() (interface{}, error) { + return mobile.Status(vin) }, cache).InterfaceGetter(), } @@ -33,7 +42,18 @@ var _ api.Battery = (*Provider)(nil) // SoC implements the api.Vehicle interface func (v *Provider) SoC() (float64, error) { - res, err := v.emobilityG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("BATTERY_LEVEL") + if err != nil && err != api.ErrNotAvailable { + return 0, err + } + if err != api.ErrNotAvailable { + return float64(m.Value.Percent), nil + } + } + + res, err = v.emobilityG() if res, ok := res.(EmobilityResponse); err == nil && ok { return float64(res.BatteryChargeStatus.StateOfChargeInPercentage), nil } @@ -50,7 +70,18 @@ var _ api.VehicleRange = (*Provider)(nil) // Range implements the api.VehicleRange interface func (v *Provider) Range() (int64, error) { - res, err := v.emobilityG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("E_RANGE") + if err != nil && err != api.ErrNotAvailable { + return 0, err + } + if err != api.ErrNotAvailable { + return int64(m.Value.Kilometers), nil + } + } + + res, err = v.emobilityG() if res, ok := res.(EmobilityResponse); err == nil && ok { return res.BatteryChargeStatus.RemainingERange.ValueInKilometers, nil } @@ -67,7 +98,24 @@ var _ api.VehicleFinishTimer = (*Provider)(nil) // FinishTime implements the api.VehicleFinishTimer interface func (v *Provider) FinishTime() (time.Time, error) { - res, err := v.emobilityG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("BATTERY_CHARGING_STATE") + if err != nil && err != api.ErrNotAvailable { + return time.Time{}, err + } + if err != api.ErrNotAvailable { + if m.Value.EndsAt == "" { + if m.Value.LastModified != "" { + return time.Parse(time.RFC3339, m.Value.LastModified) + } + return time.Time{}, nil + } + return time.Parse(time.RFC3339, m.Value.EndsAt) + } + } + + res, err = v.emobilityG() if res, ok := res.(EmobilityResponse); err == nil && ok { return time.Now().Add(time.Duration(res.BatteryChargeStatus.RemainingChargeTimeUntil100PercentInMinutes) * time.Minute), err } @@ -79,7 +127,30 @@ var _ api.ChargeState = (*Provider)(nil) // Status implements the api.ChargeState interface func (v *Provider) Status() (api.ChargeStatus, error) { - res, err := v.emobilityG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("BATTERY_CHARGING_STATE") + if err != nil && err != api.ErrNotAvailable { + return api.StatusNone, err + } + if err != api.ErrNotAvailable { + switch m.Value.Status { + case "FAST_CHARGING", "NOT_PLUGGED", "UNKNOWN": + return api.StatusA, nil + case "CHARGING_COMPLETED", "CHARGING_PAUSED", "READY_TO_CHARGE", "SOC_REACHED", + "INITIALISING", "STANDBY", "SUSPENDED", "PLUGGED_LOCKED", "PLUGGED_NOT_LOCKED": + return api.StatusB, nil + case "CHARGING": + return api.StatusC, nil + case "CHARGING_ERROR": + return api.StatusF, nil + default: + return api.StatusNone, errors.New("mobile - unknown charging status: " + m.Value.Status) + } + } + } + + res, err = v.emobilityG() if res, ok := res.(EmobilityResponse); err == nil && ok { switch res.BatteryChargeStatus.PlugState { case "DISCONNECTED": @@ -94,8 +165,10 @@ func (v *Provider) Status() (api.ChargeStatus, error) { return api.StatusF, nil case "OFF", "COMPLETED": return api.StatusB, nil - case "ON": + case "ON", "CHARGING": return api.StatusC, nil + default: + return api.StatusNone, errors.New("emobility - unknown charging state: " + res.BatteryChargeStatus.ChargingState) } } } @@ -107,13 +180,26 @@ 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.emobilityG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("CLIMATIZER_STATE") + if err != nil && err != api.ErrNotAvailable { + return active, 20, 20, err + } + if err != api.ErrNotAvailable { + return m.Value.IsOn, 20, 20, err + } + } + + res, err = v.emobilityG() 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 + default: + return active, outsideTemp, targetTemp, errors.New("emobility - unknown climate state: " + res.DirectClimatisation.ClimatisationState) } } @@ -124,7 +210,18 @@ var _ api.VehicleOdometer = (*Provider)(nil) // Odometer implements the api.VehicleOdometer interface func (v *Provider) Odometer() (float64, error) { - res, err := v.statusG() + res, err := v.mobileG() + if res, ok := res.(StatusResponseMobile); err == nil && ok { + m, err := res.MeasurementByKey("MILEAGE") + if err != nil && err != api.ErrNotAvailable { + return 0, err + } + if err != api.ErrNotAvailable { + return float64(m.Value.Kilometers), nil + } + } + + res, err = v.statusG() if res, ok := res.(StatusResponse); err == nil && ok { return res.Mileage.Value, nil } diff --git a/vehicle/porsche/types.go b/vehicle/porsche/types.go index 81ae15a1a..10b3a446d 100644 --- a/vehicle/porsche/types.go +++ b/vehicle/porsche/types.go @@ -1,5 +1,11 @@ package porsche +import ( + "errors" + + "github.com/evcc-io/evcc/api" +) + type Vehicle struct { VIN string ModelDescription string @@ -20,6 +26,134 @@ type VehiclePairingResponse struct { CanSendPairingCode bool } +type MeasurementMobile struct { + Key string + /* + RANGE, + E_RANGE, + MILEAGE, + FUEL_LEVEL, + FUEL_RESERVE, + BATTERY_LEVEL // status, value + BATTERY_CHARGING_STATE // status, value + OIL_SERVICE_RANGE, + OIL_SERVICE_TIME, + MAIN_SERVICE_RANGE, + MAIN_SERVICE_TIME, + INTERMEDIATE_SERVICE_RANGE, + INTERMEDIATE_SERVICE_TIME, + OIL_LEVEL_MAX, + OIL_LEVEL_CURRENT, + OIL_LEVEL_MIN_WARNING, + OPEN_STATE_DOOR_FRONT_LEFT, + OPEN_STATE_DOOR_REAR_LEFT, + OPEN_STATE_DOOR_FRONT_RIGHT, + OPEN_STATE_DOOR_REAR_RIGHT, + OPEN_STATE_LID_FRONT, + OPEN_STATE_LID_REAR, + OPEN_STATE_WINDOW_FRONT_LEFT, + OPEN_STATE_WINDOW_REAR_LEFT, + OPEN_STATE_WINDOW_FRONT_RIGHT, + OPEN_STATE_WINDOW_REAR_RIGHT, + OPEN_STATE_SUNROOF, + OPEN_STATE_SUNROOF_REAR, + OPEN_STATE_TOP, + OPEN_STATE_SERVICE_FLAP, + OPEN_STATE_SPOILER, + OPEN_STATE_CHARGE_FLAP_LEFT, + OPEN_STATE_CHARGE_FLAP_RIGHT, + LOCK_STATE_VEHICLE, + GPS_LOCATION, + GLOBAL_PRIVACY_MODE, + REMOTE_ACCESS_AUTHORIZATION, + THEFT_MODE, + TRIP_STATISTICS_CYCLIC, + TRIP_STATISTICS_LONG_TERM, + TRIP_STATISTICS_SHORT_TERM, + PARKING_LIGHT, + HEATING_STATE, + ACV_STATE, + CLIMATIZER_STATE // isOn, minutesLeft, targetTemperature + TIMERS, + BLEID_DDADATA, + VTS_MODES, + SPEED_ALARMS, + LOCATION_ALARMS, + CHARGING_PROFILES, + BATTERY_TYPE // lastModified, plugTypes, capacityAh capacityKWh + VALET_ALARM; + */ + Status struct { + IsEnabled bool + Cause string // AVAILABLE, PRIVACY_ACTIVATED, LICENSE_DEACTIVATED, NOT_SUPPORTED, UNKNOWN + } + Value struct { + LastModified string + + IsEnabled bool + SoCPhoneNumber string + Percent int64 + DistanceKM float64 + ZeroEmissionDistanceKm float64 + AvgLiterPerHundredKm float64 + AvgKwhPerHundredKm float64 + AvgSpeedKmh float64 + DrivingTimeMinutes float64 + TripEndTime string + + IsOn bool + TargetTemperature float64 + // MinutesLeft + + Kilometers int64 + Days int64 + IsLocked bool + IsOpen bool + Direction int64 + + PlugTypes string // COMBINED_CS, TYP2, UNKNOWN + // CapacityAh + // CapacityKWh + + Status string // CHARGING, FAST_CHARGING, CHARGING_COMPLETED, CHARGING_PAUSED, READY_TO_CHARGE, SOC_REACHED, INITIALISING, STANDBY, SUSPENDED, PLUGGED_LOCKED, PLUGGED_NOT_LOCKED, CHARGING_ERROR, NOT_PLUGGED, UNKNOWN + Mode string // DIRECT, TIMER_PROFILE, LONGTERM, TCP, UNKNOWN + DirectChargingState string // ENABLED_ON, ENABLED_OFF, DISABLED_ON, DISABLED_OFF, HIDDEN, UNKNOWN + EndsAt string + ChargingPower float64 + ChargingRate float64 + } +} + +type StatusResponseMobile struct { + VIN string + ModelName string + CustomName string + ModelType struct { + Code string + Year string + Body string + Generation string + Model string + Engine string + } + Connect bool + GreyConnectStoreURL string + PairingCodeV2 string + Measurements []MeasurementMobile +} + +func (s *StatusResponseMobile) MeasurementByKey(key string) (*MeasurementMobile, error) { + for _, m := range s.Measurements { + if m.Key == key { + if !m.Status.IsEnabled { + return nil, errors.New(m.Status.Cause) + } + return &m, nil + } + } + return nil, api.ErrNotAvailable +} + type StatusResponse struct { BatteryLevel struct { Unit string