diff --git a/vehicle/id.go b/vehicle/id.go index 36b16cffc..70aa071c9 100644 --- a/vehicle/id.go +++ b/vehicle/id.go @@ -1,46 +1,22 @@ 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/id" "github.com/andig/evcc/vehicle/vw" - "golang.org/x/net/publicsuffix" ) -// ID is an api.Vehicle implementation for VW ID cars +// https://github.com/TA2k/ioBroker.vw-connect + +// ID is an api.Vehicle implementation for 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 + *id.Provider // provides the api implementations } func init() { @@ -62,263 +38,33 @@ func NewIDFromConfig(other map[string]interface{}) (api.Vehicle, error) { 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), + embed: &embed{cc.Title, cc.Capacity}, } - var err error - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, + log := util.NewLogger("id") + identity := vw.NewIdentity(log, "") + + query := url.Values(map[string][]string{ + "response_type": {"code id_token token"}, + "client_id": {"a24fba63-34b3-4d43-b181-942111e6bda8@apps_vw-dilab_com"}, + "redirect_uri": {"weconnect://authenticated"}, + "scope": {"openid profile badge cars dealers vin"}, }) - // track cookies and follow all redirects - v.Client.Jar = jar - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return nil - } - + err := identity.Login(query, cc.User, cc.Password) if err == nil { - err = v.authFlow() - } + api := id.NewAPI(log, identity) - if err == nil && cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) - if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + if cc.VIN == "" { + cc.VIN, err = findVehicle(api.Vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", cc.VIN) + } } - } - v.chargerG = provider.NewCached(v.chargeState, cc.Cache).InterfaceGetter() + v.Provider = id.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache) + } 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") - resp.Body.Close() - } - - // 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") - resp.Body.Close() - } - } - - // 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 resp, err = v.PostForm(uri, data); err == nil { - resp.Body.Close() - - 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/id/api.go b/vehicle/id/api.go new file mode 100644 index 000000000..0c291c4dd --- /dev/null +++ b/vehicle/id/api.go @@ -0,0 +1,136 @@ +package id + +import ( + "fmt" + "net/http" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/vw" +) + +// API is an api.Vehicle implementation for VW ID cars +type API struct { + *request.Helper + identity *vw.Identity +} + +// NewAPI creates a new vehicle +func NewAPI(log *util.Logger, identity *vw.Identity) *API { + v := &API{ + Helper: request.NewHelper(log), + identity: identity, + } + return v +} + +// Vehicles implements the /vehicles response +func (v *API) Vehicles() (res []string, err error) { + uri := "https://mobileapi.apps.emea.vwapps.io/vehicles" + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.identity.Token(), + }) + + var vehicles struct { + Data []struct { + VIN string + } + } + + if err == nil { + err = v.DoJSON(req, &vehicles) + + for _, v := range vehicles.Data { + res = append(res, v.VIN) + } + } + + return res, err +} + +// Status is the /status api +type Status struct { + Data struct { + BatteryStatus BatteryStatus + ChargingStatus ChargingStatus + PlugStatus PlugStatus + RangeStatus RangeStatus + ClimatisationSettings ClimatisationSettings + // ClimatisationStatus ClimatisationStatus // currently not available + } +} + +// BatteryStatus is the /status.batteryStatus api +type BatteryStatus struct { + CarCapturedTimestamp string + CurrentSOCPercent int `json:"currentSOC_pct"` + CruisingRangeElectricKm int `json:"cruisingRangeElectric_km"` +} + +// ChargingStatus is the /status.chargingStatus api +type ChargingStatus struct { + CarCapturedTimestamp string + ChargingState string + RemainingChargingTimeToCompleteMin int `json:"remainingChargingTimeToComplete_min"` + ChargePowerKW int `json:"chargePower_kW"` + ChargeRateKmph int `json:"chargeRate_kmph"` +} + +// ChargingSettings is the /status.chargingSettings api +type ChargingSettings struct { + CarCapturedTimestamp string + MaxChargeCurrentAC string + AutoUnlockPlugWhenCharged string + TargetSOCPercent int `json:"targetSOC_pct"` +} + +// PlugStatus is the /status.plugStatus api +type PlugStatus struct { + CarCapturedTimestamp string + PlugConnectionState string + PlugLockState string +} + +// ClimatisationSettings is the /status.climatisationSettings api +type ClimatisationSettings struct { + CarCapturedTimestamp string + TargetTemperatureK float64 `json:"targetTemperature_K"` + TargetTemperatureC float64 `json:"targetTemperature_C"` + ClimatisationWithoutExternalPower bool + ClimatizationAtUnlock bool + WindowHeatingEnabled bool + ZoneFrontLeftEnabled bool + ZoneFrontRightEnabled bool + ZoneRearLeftEnabled bool + ZoneRearRightEnabled bool +} + +// RangeStatus is the /status.rangeStatus api +type RangeStatus struct { + CarCapturedTimestamp string + CarType string + PrimaryEngine struct { + Type string + CurrentSOCPercent int `json:"currentSOC_pct"` + RemainingRangeKm int `json:"remainingRange_km"` + } + TotalRangeKm int `json:"totalRange_km"` +} + +// Status implements the /status response +func (v *API) Status(vin string) (res Status, err error) { + uri := fmt.Sprintf("https://mobileapi.apps.emea.vwapps.io/vehicles/%s/status", vin) + + req, err := request.New(http.MethodGet, uri, nil, map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.identity.Token(), + }) + + if err == nil { + err = v.DoJSON(req, &res) + } + + return res, err +} diff --git a/vehicle/id/provider.go b/vehicle/id/provider.go new file mode 100644 index 000000000..fcfb7dcdd --- /dev/null +++ b/vehicle/id/provider.go @@ -0,0 +1,54 @@ +package id + +import ( + "time" + + "github.com/andig/evcc/provider" +) + +// Provider is an api.Vehicle implementation for VW ID cars +type Provider struct { + statusG func() (interface{}, error) +} + +// NewProvider creates a new vehicle +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + impl := &Provider{ + statusG: provider.NewCached(func() (interface{}, error) { + return api.Status(vin) + }, cache).InterfaceGetter(), + } + return impl +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *Provider) ChargeState() (float64, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + return float64(res.Data.BatteryStatus.CurrentSOCPercent), nil + } + + return 0, err +} + +// FinishTime implements the Vehicle.ChargeFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + cst := res.Data.ChargingStatus + timestamp, err := time.Parse(time.RFC3339, cst.CarCapturedTimestamp) + return timestamp.Add(time.Duration(cst.RemainingChargingTimeToCompleteMin) * time.Minute), err + } + + return time.Time{}, err +} + +// Range implements the Vehicle.Range interface +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + if res, ok := res.(Status); err == nil && ok { + return int64(res.Data.BatteryStatus.CruisingRangeElectricKm), nil + } + + return 0, err +} diff --git a/vehicle/oidc/oidc.go b/vehicle/oidc/oidc.go index 66de293b3..1863f909a 100644 --- a/vehicle/oidc/oidc.go +++ b/vehicle/oidc/oidc.go @@ -5,7 +5,7 @@ import "time" // Tokens is an OAuth tokens response type Tokens struct { TokenType string `json:"token_type"` - ExpiresIn int `json:"expires_in"` + ExpiresIn int `json:"expires_in"` // expiration time in seconds IDToken string `json:"id_token"` AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index 4ceeb5970..12b52264a 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -6,6 +6,7 @@ import ( "net/http/cookiejar" "net/url" "strings" + "time" "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" @@ -26,6 +27,7 @@ const ( // Identity provides the identity.vwgroup.io login type Identity struct { + log *util.Logger *request.Helper clientID string tokens oidc.Tokens @@ -34,6 +36,7 @@ type Identity struct { // NewIdentity creates VW identity func NewIdentity(log *util.Logger, clientID string) *Identity { v := &Identity{ + log: log, Helper: request.NewHelper(log), clientID: clientID, } @@ -83,6 +86,19 @@ func (v *Identity) Login(query url.Values, user, password string) error { resp.Body.Close() } + // ID - get login url (previous request is ignored) + if v.clientID == "" { + uri := "https://login.apps.emea.vwapps.io/authorize?nonce=NZ2Q3T6jak0E5pDh&redirect_uri=weconnect://authenticated" + if resp, err = v.Get(uri); err == nil { + resp.Body.Close() + + uri = resp.Header.Get("Location") + if resp, err = v.Get(uri); err == nil { + resp.Body.Close() + } + } + } + // GET identity.vwgroup.io/signin-service/v1/signin/b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com?relayState=15404cb51c8b4cc5efeee1d2c2a73e5b41562faa if err == nil { uri = resp.Header.Get("Location") @@ -147,33 +163,58 @@ func (v *Identity) Login(query url.Values, user, password string) error { resp, err = v.redirect(resp, err) } + var location *url.URL + if err == nil { loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parseable url + location, err = url.Parse(loc) - var location *url.URL - if location, err = url.Parse(loc); err == nil { - v.tokens.IDToken = location.Query().Get("id_token") - - if v.tokens.IDToken == "" { - err = errors.New("missing id token") - } + if err == nil && location.Query().Get("id_token") == "" { + err = errors.New("missing id token") } } - if err == nil { + // VW or Audi + if err == nil && v.clientID != "" { data := url.Values(map[string][]string{ "grant_type": {"id_token"}, "scope": {"sc2:fal"}, - "token": {v.tokens.IDToken}, + "token": {location.Query().Get("id_token")}, }) req, err = request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ "Content-Type": "application/x-www-form-urlencoded", "X-Client-Id": v.clientID, }) + if err == nil { - if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" { - err = errors.New("missing access token") + var tokens oidc.Tokens + if err = v.DoJSON(req, &tokens); err == nil { + err = v.validateTokens(tokens) + } + } + } + + // ID + if err == nil && v.clientID == "" { + q := location.Query() + + data := map[string]string{ + "state": q.Get("state"), + "id_token": q.Get("id_token"), + "redirect_uri": "weconnect://authenticated", + "region": "emea", + "access_token": q.Get("access_token"), + "authorizationCode": q.Get("code"), + } + + uri := "https://login.apps.emea.vwapps.io/login/v1" + req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding) + + if err == nil { + var tokens idTokens + if err = v.DoJSON(req, &tokens); err == nil { + err = v.validateTokens(tokens.AsOIDC()) } } } @@ -181,8 +222,31 @@ func (v *Identity) Login(query url.Values, user, password string) error { return err } -// Token returns the access token +// validateTokens checks if token is present and sets valid time +func (v *Identity) validateTokens(tokens oidc.Tokens) error { + if tokens.AccessToken == "" { + return errors.New("missing access token") + } + + v.tokens.AccessToken = tokens.AccessToken + v.tokens.Valid = time.Now().Add(time.Second * time.Duration(tokens.ExpiresIn)) + + // re-use refresh token + if tokens.RefreshToken != "" { + v.tokens.RefreshToken = tokens.RefreshToken + } + + return nil +} + +// Token returns the access token, refreshed if necessary func (v *Identity) Token() string { + if time.Since(v.tokens.Valid) > 0 { + if err := v.RefreshToken(); err != nil { + v.log.ERROR.Printf("token refresh failed: %v", err) + } + } + return v.tokens.AccessToken } @@ -192,6 +256,10 @@ func (v *Identity) RefreshToken() error { return errors.New("missing refresh token") } + if v.clientID == "" { + return v.refreshIDToken() + } + data := url.Values(map[string][]string{ "grant_type": {"refresh_token"}, "refresh_token": {v.tokens.RefreshToken}, @@ -207,13 +275,28 @@ func (v *Identity) RefreshToken() error { if err == nil { var tokens oidc.Tokens - - err = v.DoJSON(req, &tokens) - if err == nil { - v.tokens.AccessToken = tokens.AccessToken - if tokens.RefreshToken != "" { - v.tokens.RefreshToken = tokens.RefreshToken - } + if err = v.DoJSON(req, &tokens); err == nil { + err = v.validateTokens(tokens) + } + } + + return err +} + +func (v *Identity) refreshIDToken() error { + uri := "https://login.apps.emea.vwapps.io/refresh/v1" + + headers := map[string]string{ + "Accept": "application/json", + "Authorization": "Bearer " + v.tokens.RefreshToken, + } + + req, err := request.New(http.MethodGet, uri, nil, headers) + + if err == nil { + var tokens idTokens + if err = v.DoJSON(req, &tokens); err == nil { + err = v.validateTokens(tokens.AsOIDC()) } } diff --git a/vehicle/vw/idtokens.go b/vehicle/vw/idtokens.go new file mode 100644 index 000000000..dc7121458 --- /dev/null +++ b/vehicle/vw/idtokens.go @@ -0,0 +1,18 @@ +package vw + +import "github.com/andig/evcc/vehicle/oidc" + +// idTokens is the non-OIDC compliant VW ID token structure +type idTokens struct { + AccessToken, RefreshToken, IDToken string +} + +// AsOIDC converts id tokens to OIDC tokens +func (tokens idTokens) AsOIDC() oidc.Tokens { + return oidc.Tokens{ + IDToken: tokens.IDToken, + AccessToken: tokens.AccessToken, + RefreshToken: tokens.RefreshToken, + ExpiresIn: 3600, + } +}