Add VW ID api (#492)

This commit is contained in:
andig 2020-12-08 08:11:45 +01:00
parent 5b83ab13b4
commit 6724f04e04
4 changed files with 332 additions and 15 deletions

View file

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

View file

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

320
vehicle/id.go Normal file
View file

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

View file

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