diff --git a/README.md b/README.md index 6a3ba2fd6..a975fdad1 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/evcc.dist.yaml b/evcc.dist.yaml index cf707459a..7344620d3 100644 --- a/evcc.dist.yaml +++ b/evcc.dist.yaml @@ -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 diff --git a/go.mod b/go.mod index 27b2bcf31..29c402cca 100644 --- a/go.mod +++ b/go.mod @@ -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 @@ -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 ) diff --git a/go.sum b/go.sum index 9a7708aab..2d01219cc 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/vehicle/config.go b/vehicle/config.go index 767e88473..050e4d33e 100644 --- a/vehicle/config.go +++ b/vehicle/config.go @@ -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) } diff --git a/vehicle/porsche.go b/vehicle/porsche.go new file mode 100644 index 000000000..8e35e66b7 --- /dev/null +++ b/vehicle/porsche.go @@ -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() +}