Add Porsche Mobile API support (#2439)

This commit is contained in:
Andreas Linde 2022-01-31 14:05:16 +01:00 • committed by GitHub
parent 12f984bb06
commit 62680b6f20
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 364 additions and 28 deletions

View file

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

View file

@ -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",
}),
}

View file

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

View file

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

View file

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

View file

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