From 6724f04e0421e753ec97d1e05a4e64ecdb123615 Mon Sep 17 00:00:00 2001 From: andig Date: Tue, 8 Dec 2020 08:11:45 +0100 Subject: [PATCH] Add VW ID api (#492) --- README.md | 5 +- util/request/helper.go | 8 +- vehicle/id.go | 320 +++++++++++++++++++++++++++++++++++++++++ vehicle/vw/identity.go | 14 +- 4 files changed, 332 insertions(+), 15 deletions(-) create mode 100644 vehicle/id.go diff --git a/README.md b/README.md index acb315560..8caf49d81 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,8 @@ Available vehicle remote interface implementations are: - `tesla`: Tesla (any model) - `renault`: Renault (Zoe, Kangoo ZE) - `porsche`: Porsche (Taycan) -- `vw`: Volkswagen (eGolf, eUp, ID.3, ID.4) +- `vw`: Volkswagen (eGolf, eUp) +- `id`: Volkswagen (ID.3, ID.4) - `default`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle Configuration examples are documented at [andig/evcc-config#vehicles](https://github.com/andig/evcc-config#vehicles) @@ -235,7 +236,7 @@ Configuration examples are documented at [andig/evcc-config#vehicles](https://gi EVCC can integrate itself with Home Energy Management Systems. At this time, the SMA Home Manager (SHM) is the only supported system. To enable add ```yaml -hems: +hems: type: sma ``` diff --git a/util/request/helper.go b/util/request/helper.go index 68f2d2ca6..fd32ef660 100644 --- a/util/request/helper.go +++ b/util/request/helper.go @@ -48,11 +48,17 @@ type helperTransport struct { } func (r *helperTransport) RoundTrip(req *http.Request) (*http.Response, error) { + msg := fmt.Sprintf("%s %s", req.Method, req.URL) + if r.detailed { + if body, err := httputil.DumpRequest(req, true); err == nil { + msg += "\n" + strings.TrimSpace(string(body)) + "\n" + } + } + resp, err := r.roundTripper.RoundTrip(req) r.lastResponse(resp) if r.log != nil { - msg := fmt.Sprintf("%s %s", req.Method, req.URL) if resp != nil { if r.detailed { if body, err := httputil.DumpResponse(resp, true); err == nil { diff --git a/vehicle/id.go b/vehicle/id.go new file mode 100644 index 000000000..adb7cf696 --- /dev/null +++ b/vehicle/id.go @@ -0,0 +1,320 @@ +package vehicle + +import ( + "errors" + "fmt" + "net/http" + "net/http/cookiejar" + "net/url" + "strconv" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/oidc" + "github.com/andig/evcc/vehicle/vw" + "golang.org/x/net/publicsuffix" +) + +// ID is an api.Vehicle implementation for VW ID cars +type ID struct { + *embed + *request.Helper + user, password string + vin string + userInfo UserInfo + csrf string + carTokens oidc.Tokens + weTokens oidc.Tokens + chargerG func() (interface{}, error) +} + +// UserInfo is the https://www.volkswagen.de/app/authproxy/vw-de/user response +type UserInfo struct { + Sub string + Name string + GivenName string + FamilyName string + Email string + EmailVerified bool + UpdatedAt int64 +} + +func init() { + registry.Add("id", NewIDFromConfig) +} + +// NewIDFromConfig creates a new vehicle +func NewIDFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + Title string + Capacity int64 + User, Password, VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + log := util.NewLogger("id") + + v := &ID{ + embed: &embed{cc.Title, cc.Capacity}, + Helper: request.NewHelper(log), + user: cc.User, + password: cc.Password, + vin: strings.ToUpper(cc.VIN), + } + + var err error + jar, err := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + // track cookies and follow all redirects + v.Client.Jar = jar + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return nil + } + + if err == nil { + err = v.authFlow() + } + + if err == nil && cc.VIN == "" { + v.vin, err = findVehicle(v.vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", v.vin) + } + } + + v.chargerG = provider.NewCached(v.chargeState, cc.Cache).InterfaceGetter() + + return v, err +} + +func (v *ID) authFlow() error { + var uri string + var req *http.Request + + uri = "https://www.volkswagen.de/app/authproxy/login?fag=vw-de,vwag-weconnect&scope-vw-de=profile,address,phone,carConfigurations,dealers,cars,vin,profession&scope-vwag-weconnect=openid&prompt-vw-de=login&prompt-vwag-weconnect=none&redirectUrl=https://www.volkswagen.de/de/besitzer-und-nutzer/myvolkswagen.html" + resp, err := v.Get(uri) + + var vars vw.FormVars + if err == nil { + vars, err = vw.FormValues(resp.Body, "form#emailPasswordForm") + } + + // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/identifier + if err == nil { + data := url.Values(map[string][]string{ + "_csrf": {vars.Csrf}, + "relayState": {vars.RelayState}, + "hmac": {vars.Hmac}, + "email": {v.user}, + }) + + uri = vw.IdentityURI + vars.Action + if resp, err = v.PostForm(uri, data); err == nil { + vars, err = vw.FormValues(resp.Body, "form#credentialsForm") + } + } + + // POST identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate + if err == nil { + data := url.Values(map[string][]string{ + "_csrf": {vars.Csrf}, + "relayState": {vars.RelayState}, + "hmac": {vars.Hmac}, + "email": {v.user}, + "password": {v.password}, + }) + + uri = vw.IdentityURI + vars.Action + if _, err = v.PostForm(uri, data); err == nil { + vwDomain, _ := url.Parse("https://www.volkswagen.de/") + cookies := v.Client.Jar.Cookies(vwDomain) + + for _, c := range cookies { + if c.Name == "csrf_token" { + v.csrf = c.Value + break + } + } + + if v.csrf == "" { + err = errors.New("missing csrf token") + } + } + } + + if err == nil { + req, err = request.New(http.MethodGet, "https://www.volkswagen.de/app/authproxy/vw-de/user", nil, map[string]string{ + "Accept": "application/json", + "X-csrf-token": v.csrf, + }) + + if err == nil { + err = v.DoJSON(req, &v.userInfo) + } + } + + if err == nil { + uri = "https://www.volkswagen.de/app/authproxy/vw-de/tokens" + req, err = request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "X-csrf-token": v.csrf, + }) + + if err == nil { + if err = v.DoJSON(req, &v.carTokens); err == nil { + if v.carTokens.AccessToken == "" { + err = errors.New("missing vw-de access token") + } + } + } + } + + if err == nil { + uri = "https://www.volkswagen.de/app/authproxy/vwag-weconnect/tokens" + req, err = request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "X-csrf-token": v.csrf, + }) + + if err == nil { + if err = v.DoJSON(req, &v.weTokens); err == nil { + if v.weTokens.AccessToken == "" { + err = errors.New("missing vwag-weconnect access token") + } + } + } + + if err == nil { + uri = "https://myvw-idk-token-exchanger.apps.emea.vwapps.io/token-exchange?isWcar=true" + req, err = request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "applicaton/json, text/plain", + "Authorization": "Bearer " + v.weTokens.AccessToken, + }) + + if err == nil { + if resp, err = v.Do(req); err == nil { + var body []byte + if body, err = request.ReadBody(resp); err == nil { + v.weTokens.AccessToken = string(body) + } + } + } + } + } + + return err +} + +func (v *ID) vehicles() (res []string, err error) { + uri := "https://w1hub-backend-production.apps.emea.vwapps.io/cars" + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.carTokens.AccessToken, + }) + + var vehicles []struct { + VIN string + } + + if err == nil { + err = v.DoJSON(req, &vehicles) + + for _, v := range vehicles { + res = append(res, v.VIN) + } + } + + return res, err +} + +type idData struct { + Error struct { + Code int + Message string + } + Data []struct { + ID string + CarCapturedTimestamp string + Properties []struct { + Name, Value string // engineType: electric, remainingRange_km, currentSOC_pct + } + } +} + +func (v *ID) chargeState() (interface{}, error) { + uri := fmt.Sprintf("https://cardata.apps.emea.vwapps.io/vehicles/%s/fuel/status", v.vin) + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.weTokens.AccessToken, + "User-Id": v.userInfo.Sub, + }) + + var state idData + if err == nil { + err = v.DoJSON(req, &state) + + if err != nil { + // handle http 401, 403 + if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusUnauthorized, http.StatusForbidden) { + if err = v.authFlow(); err == nil { + // re-do request with new token + req.Header.Set("Authorization", "Bearer "+v.weTokens.AccessToken) + err = v.DoJSON(req, &state) + } + } + } + } + + return state, err +} + +func (v *ID) extractProperty(data idData, property string) (int64, error) { + for _, d := range data.Data { + for _, p := range d.Properties { + if p.Name == property { + i, err := strconv.Atoi(p.Value) + return int64(i), err + } + } + } + + return 0, fmt.Errorf("missing %s", property) +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *ID) ChargeState() (float64, error) { + res, err := v.chargerG() + if res, ok := res.(idData); err == nil && ok { + var i int64 + if i, err = v.extractProperty(res, "currentSOC_pct"); err == nil { + return float64(i), nil + } + } + + return 0, err +} + +// Range implements the Vehicle.Range interface +func (v *ID) Range() (int64, error) { + res, err := v.chargerG() + if res, ok := res.(idData); err == nil && ok { + var i int64 + if i, err = v.extractProperty(res, "remainingRange_km"); err == nil { + return i, nil + } + } + + return 0, err +} diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index 8539148f9..e5d6152c8 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -5,8 +5,6 @@ import ( "net/http" "net/url" "strings" - - "github.com/andig/evcc/util/request" ) const ( @@ -67,11 +65,7 @@ func (v *Identity) Login(query url.Values, user, password string) (string, error }) uri = IdentityURI + vars.Action - req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) - - if err == nil { - resp, err = v.Do(req) - } + resp, err = v.PostForm(uri, data) } // GET identity.vwgroup.io/signin-service/v1/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com/login/authenticate?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&email=... @@ -99,11 +93,7 @@ func (v *Identity) Login(query url.Values, user, password string) (string, error }) uri = IdentityURI + vars.Action - req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) - - if err == nil { - resp, err = v.Do(req) - } + resp, err = v.PostForm(uri, data) } // GET identity.vwgroup.io/oidc/v1/oauth/sso?clientId=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com&relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa&userId=bca09cc0-8eba-4110-af71-7242868e1bf1&HMAC=2b01ce6a351fad4dd97dc8110d0967b46c95889ab5010c660a616462e66a83ca