Skip to content

Commit

Permalink
Add Porsche api support (#118)
Browse files Browse the repository at this point in the history
  • Loading branch information
Andreas Linde authored May 10, 2020
1 parent 0ba100b commit 08cee90
Show file tree
Hide file tree
Showing 6 changed files with 263 additions and 1 deletion.
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,7 @@ Available vehicle implementations are:
- `nissan`: Nissan (Leaf)
- `tesla`: Tesla (any model)
- `renault`: Renault (Zoe, Kangoo ZE)
- `porsche`: Porsche (Taycan)
- `default`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle

## Plugins
Expand Down
8 changes: 8 additions & 0 deletions evcc.dist.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,14 @@ vehicles:
# region: de_DE # gigya region
# vin: WREN...
# cache: 5m
# - name: porsche
# type: porsche
# title: Taycan
# capacity: 83 # kWh
# user: # user
# password: # password
# vin: WP...
# cache: 5m

loadpoints:
- name: main # name for logging
Expand Down
3 changes: 2 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ require (
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mitchellh/mapstructure v1.3.0
github.com/mjibson/esc v0.2.0
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da
github.com/pkg/errors v0.9.1
github.com/spf13/cobra v1.0.0
github.com/spf13/jwalterweatherman v1.1.0
Expand All @@ -31,7 +32,7 @@ require (
github.com/technoweenie/multipartstreamer v1.0.1 // indirect
github.com/volkszaehler/mbmd v0.0.0-20200505114042-9732bf1ad846
golang.org/x/crypto v0.0.0-20200429183012-4b2356b1ed79 // indirect
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c // indirect
golang.org/x/net v0.0.0-20200505041828-1ed23360d12c
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect
golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8
)
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,8 @@ github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJ
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da h1:qiPWuGGr+1GQE6s9NPSK8iggR/6x/V+0snIoOPYsBgc=
github.com/nirasan/go-oauth-pkce-code-verifier v0.0.0-20170819232839-0fbfe93532da/go.mod h1:DvuJJ/w1Y59rG8UTDxsMk5U+UJXJwuvUgbiJSm9yhX8=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1 h1:/I3lTljEEDNYLho3/FUB7iD/oc2cEFgVmbHzV+O0PtU=
github.com/pascaldekloe/name v0.0.0-20180628100202-0fd16699aae1/go.mod h1:eD5JxqMiuNYyFNmyY9rkJ/slN8y59oEu4Ei7F8OoKWQ=
Expand Down
2 changes: 2 additions & 0 deletions vehicle/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ func NewFromConfig(log *util.Logger, typ string, other map[string]interface{}) a
c = NewNissanFromConfig(log, other)
case "renault", "zoe":
c = NewRenaultFromConfig(log, other)
case "porsche", "taycan":
c = NewPorscheFromConfig(log, other)
default:
log.FATAL.Fatalf("invalid vehicle type '%s'", typ)
}
Expand Down
248 changes: 248 additions & 0 deletions vehicle/porsche.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
package vehicle

import (
"encoding/json"
"errors"
"fmt"
"io/ioutil"
"log"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
cv "github.com/nirasan/go-oauth-pkce-code-verifier"
"golang.org/x/net/publicsuffix"
)

const (
porscheLogin = "https://login.porsche.com/auth/de/de_DE"
porscheLoginAuth = "https://login.porsche.com/auth/api/v1/de/de_DE/public/login"
porscheAPIClientID = "TZ4Vf5wnKeipJxvatJ60lPHYEzqZ4WNp"
porscheAPIRedirectUri = "https://my-static02.porsche.com/static/cms/auth.html"
porscheAPIAuth = "https://login.porsche.com/as/authorization.oauth2"
porscheAPIToken = "https://login.porsche.com/as/token.oauth2"
porscheAPI = "https://connect-portal.porsche.com/core/api/v3/de/de_DE"
)

type porscheTokenResponse struct {
AccessToken string `json:"access_token"`
IdToken string `json:"id_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
}

type porscheVehicleResponse struct {
CarControlData struct {
BatteryLevel struct {
Unit string
Value float64
}
Mileage struct {
Unit string
Value float64
}
}
}

// Porsche is an api.Vehicle implementation for Porsche cars
type Porsche struct {
*embed
*util.HTTPHelper
user, password, vin string
token string
tokenValid time.Time
chargeStateG provider.FloatGetter
}

// NewPorscheFromConfig creates a new vehicle
func NewPorscheFromConfig(log *util.Logger, other map[string]interface{}) api.Vehicle {
cc := struct {
Title string
Capacity int64
User, Password, VIN string
Cache time.Duration
}{}
util.DecodeOther(log, other, &cc)

v := &Porsche{
embed: &embed{cc.Title, cc.Capacity},
HTTPHelper: util.NewHTTPHelper(util.NewLogger("porsche")),
user: cc.User,
password: cc.Password,
vin: cc.VIN,
}

v.chargeStateG = provider.NewCached(log, v.chargeState, cc.Cache).FloatGetter()

return v
}

// login with a my Porsche account
// looks like the backend is using a PingFederate Server with OAuth2
func (v *Porsche) login(user, password string) error {
jar, err := cookiejar.New(&cookiejar.Options{PublicSuffixList: publicsuffix.List})
if err != nil {
log.Fatal(err)
}

// the flow is using Oauth2 and >10 redirects
client := &http.Client{
Jar: jar,
CheckRedirect: func(req *http.Request, via []*http.Request) error {
return nil
},
}

// get the login page to get the cookies for the subsequent requests
reqLogin, err := http.NewRequest(http.MethodGet, porscheLogin, nil)
if err != nil {
return err
}

respLogin, err := client.Do(reqLogin)
if err != nil {
return err
}

queryLogin, err := url.ParseQuery(respLogin.Request.URL.RawQuery)
if err != nil {
return err
}

sec := queryLogin.Get("sec")
resume := queryLogin.Get("resume")
state := queryLogin.Get("state")
thirdPartyId := queryLogin.Get("thirdPartyId")

dataLoginAuth := url.Values{
"sec": []string{sec},
"resume": []string{resume},
"thirdPartyId": []string{thirdPartyId},
"state": []string{state},
"username": []string{user},
"password": []string{password},
"keeploggedin": []string{"false"},
}

reqLoginAuth, err := http.NewRequest(http.MethodPost, porscheLoginAuth, strings.NewReader(dataLoginAuth.Encode()))
if err != nil {
return err
}
reqLoginAuth.Header.Set("Content-Type", "application/x-www-form-urlencoded")

// process the auth so the session is authenticated
_, err = client.Do(reqLoginAuth)
if err != nil {
return err
}

var CodeVerifier, _ = cv.CreateCodeVerifier()
codeChallenge := CodeVerifier.CodeChallengeS256()

dataAuth := url.Values{
"scope": []string{"openid"},
"response_type": []string{"code"},
"access_type": []string{"offline"},
"prompt": []string{"none"},
"client_id": []string{porscheAPIClientID},
"redirect_uri": []string{porscheAPIRedirectUri},
"code_challenge": []string{codeChallenge},
"code_challenge_method": []string{"S256"},
}

reqAPIAuth, err := http.NewRequest(http.MethodGet, porscheAPIAuth, nil)
if err != nil {
return err
}
reqAPIAuth.URL.RawQuery = dataAuth.Encode()

respAPIAuth, err := client.Do(reqAPIAuth)
if err != nil {
return err
}

queryAPIAuth, err := url.ParseQuery(respAPIAuth.Request.URL.RawQuery)
if err != nil {
return err
}

authCode := queryAPIAuth.Get("code")

codeVerifier := CodeVerifier.CodeChallengePlain()

dataAPIToken := url.Values{
"grant_type": []string{"authorization_code"},
"client_id": []string{porscheAPIClientID},
"redirect_uri": []string{porscheAPIRedirectUri},
"code": []string{authCode},
"prompt": []string{"none"},
"code_verifier": []string{codeVerifier},
}

reqAPIToken, err := http.NewRequest(http.MethodPost, porscheAPIToken, strings.NewReader(dataAPIToken.Encode()))
if err != nil {
return err
}
reqAPIToken.Header.Set("Content-Type", "application/x-www-form-urlencoded")

respAPIToken, err := client.Do(reqAPIToken)
if err != nil {
return err
}

b, _ := ioutil.ReadAll(respAPIToken.Body)

var pr porscheTokenResponse
err = json.Unmarshal(b, &pr)
if err != nil {
return err
}

if pr.AccessToken == "" || pr.ExpiresIn == 0 {
return errors.New("could not obtain token")
}

v.token = pr.AccessToken
v.tokenValid = time.Now().Add(time.Duration(pr.ExpiresIn) * time.Second)

return nil
}

func (v *Porsche) request(uri string) (*http.Request, error) {
if v.token == "" || time.Since(v.tokenValid) > 0 {
if err := v.login(v.user, v.password); err != nil {
return nil, err
}
}

req, err := http.NewRequest(http.MethodGet, uri, nil)
if err == nil {
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", v.token))
}

return req, nil
}

// chargeState implements the Vehicle.ChargeState interface
func (v *Porsche) chargeState() (float64, error) {
uri := fmt.Sprintf("%s/vehicles/%s", porscheAPI, v.vin)
req, err := v.request(uri)
if err != nil {
return 0, err
}

var pr porscheVehicleResponse
_, err = v.RequestJSON(req, &pr)

return pr.CarControlData.BatteryLevel.Value, err
}

// ChargeState implements the Vehicle.ChargeState interface
func (v *Porsche) ChargeState() (float64, error) {
return v.chargeStateG()
}

0 comments on commit 08cee90

Please sign in to comment.