diff --git a/vehicle/audi.go b/vehicle/audi.go index 524cc0f92..d8666c743 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -1,39 +1,28 @@ package vehicle import ( - "errors" - "net/http" - "net/http/cookiejar" "net/url" "strings" "time" "github.com/andig/evcc/api" "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" ) // https://github.com/davidgiga1993/AudiAPI +// https://github.com/TA2k/ioBroker.vw-connect // Audi is an api.Vehicle implementation for Audi cars type Audi struct { *embed - *request.Helper - user, password string - tokens oidc.Tokens - api *vw.API - *vw.Implementation // provides the api implementations + *vw.Provider // provides the api implementations } func init() { registry.Add("audi", NewAudiFromConfig) } -const audiClientID = "77869e21-e30a-4a92-b016-48ab7d3db1d8" - // NewAudiFromConfig creates a new vehicle func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { @@ -49,89 +38,35 @@ func NewAudiFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } - log := util.NewLogger("audi") - v := &Audi{ - embed: &embed{cc.Title, cc.Capacity}, - Helper: request.NewHelper(log), - user: cc.User, - password: cc.Password, + embed: &embed{cc.Title, cc.Capacity}, } - v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "Audi", "DE") - v.Implementation = vw.NewImplementation(v.api, cc.Cache) - - var err error - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, - }) - - // track cookies and don't follow redirects - v.Client.Jar = jar - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - - if err == nil { - err = v.authFlow() - } - - if err == nil && cc.VIN == "" { - v.api.VIN, err = findVehicle(v.api.Vehicles()) - if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.api.VIN) - } - } - - return v, err -} - -func (v *Audi) authFlow() error { - var req *http.Request - - const clientID = "09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com" - const redirectURI = "myaudi:///" + log := util.NewLogger("audi") + identity := vw.NewIdentity(log, "77869e21-e30a-4a92-b016-48ab7d3db1d8") query := url.Values(map[string][]string{ "response_type": {"id_token token"}, - "client_id": {clientID}, - "redirect_uri": {redirectURI}, + "client_id": {"09b6cbec-cd19-4589-82fd-363dfa8c24da@apps_vw-dilab_com"}, + "redirect_uri": {"myaudi:///"}, "scope": {"openid profile mbb vin badge birthdate nickname email address phone name picture"}, - "state": {vw.RandomString(43)}, - "nonce": {vw.RandomString(43)}, "prompt": {"login"}, "ui_locales": {"de-DE"}, }) - identity := &vw.Identity{Client: v.Client} - idToken, err := identity.Login(query, v.user, v.password) - + err := identity.Login(query, cc.User, cc.Password) if err == nil { - data := url.Values(map[string][]string{ - "grant_type": {"id_token"}, - "scope": {"sc2:fal"}, - "token": {idToken}, - }) + api := vw.NewAPI(log, identity, "Audi", "DE") - req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "X-Client-Id": audiClientID, - }) - if err == nil { - if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" { - err = errors.New("missing access token") + if cc.VIN == "" { + cc.VIN, err = findVehicle(api.Vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", cc.VIN) } } + + v.Provider = vw.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache) } - return err -} - -func (v *Audi) refreshHeaders() map[string]string { - return map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "X-App-Version": "3.14.0", - "X-App-Name": "myAudi", - "X-Client-Id": audiClientID, - } + return v, err } diff --git a/vehicle/vw.go b/vehicle/vw.go index ee0609306..4494d7754 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -1,40 +1,28 @@ package vehicle import ( - "errors" - "net/http" - "net/http/cookiejar" "net/url" "strings" "time" "github.com/andig/evcc/api" "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" ) // https://github.com/trocotronic/weconnect +// https://github.com/TA2k/ioBroker.vw-connect // VW is an api.Vehicle implementation for VW cars type VW struct { *embed - *request.Helper - user, password string - clientID string - tokens oidc.Tokens - api *vw.API - *vw.Implementation // provides the api implementations + *vw.Provider // provides the api implementations } func init() { registry.Add("vw", NewVWFromConfig) } -const vwClientID = "38761134-34d0-41f3-9a73-c4be88d7d337" - // NewVWFromConfig creates a new vehicle func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { @@ -42,92 +30,41 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) { 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("vw") - v := &VW{ - embed: &embed{cc.Title, cc.Capacity}, - Helper: request.NewHelper(log), - user: cc.User, - password: cc.Password, + embed: &embed{cc.Title, cc.Capacity}, } - v.api = vw.NewAPI(v.Helper, &v.tokens, v.authFlow, v.refreshHeaders, strings.ToUpper(cc.VIN), "VW", "DE") - v.Implementation = vw.NewImplementation(v.api, cc.Cache) + log := util.NewLogger("vw") + identity := vw.NewIdentity(log, "38761134-34d0-41f3-9a73-c4be88d7d337") - var err error - jar, err := cookiejar.New(&cookiejar.Options{ - PublicSuffixList: publicsuffix.List, + query := url.Values(map[string][]string{ + "response_type": {"id_token token"}, + "client_id": {"9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com"}, + "redirect_uri": {"carnet://identity-kit/login"}, + "scope": {"openid profile mbb cars birthdate nickname address phone"}, }) - // track cookies and don't follow redirects - v.Client.Jar = jar - v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { - return http.ErrUseLastResponse - } - + err := identity.Login(query, cc.User, cc.Password) if err == nil { - err = v.authFlow() - } + api := vw.NewAPI(log, identity, "VW", "DE") - if err == nil && cc.VIN == "" { - v.api.VIN, err = findVehicle(v.api.Vehicles()) - if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.api.VIN) + if cc.VIN == "" { + cc.VIN, err = findVehicle(api.Vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", cc.VIN) + } } + + v.Provider = vw.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache) } return v, err } - -func (v *VW) authFlow() error { - var req *http.Request - - const clientID = "9496332b-ea03-4091-a224-8c746b885068@apps_vw-dilab_com" - const redirectURI = "carnet://identity-kit/login" - - query := url.Values(map[string][]string{ - "response_type": {"id_token token"}, - "client_id": {clientID}, - "redirect_uri": {redirectURI}, - "scope": {"openid profile mbb cars birthdate nickname address phone"}, - "state": {vw.RandomString(43)}, - "nonce": {vw.RandomString(43)}, - "prompt": {"login"}, - }) - - identity := &vw.Identity{Client: v.Client} - idToken, err := identity.Login(query, v.user, v.password) - - if err == nil { - data := url.Values(map[string][]string{ - "grant_type": {"id_token"}, - "scope": {"sc2:fal"}, - "token": {idToken}, - }) - - req, err = request.New(http.MethodPost, vw.OauthTokenURI, strings.NewReader(data.Encode()), map[string]string{ - "Content-type": "application/x-www-form-urlencoded", - "X-Client-Id": vwClientID, - }) - - if err == nil { - if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" { - err = errors.New("missing access token") - } - } - } - - return err -} - -func (v *VW) refreshHeaders() map[string]string { - return map[string]string{ - "Content-Type": "application/x-www-form-urlencoded", - "X-Client-Id": vwClientID, - } -} diff --git a/vehicle/vw/api.go b/vehicle/vw/api.go index 3b232a537..43663a0bb 100644 --- a/vehicle/vw/api.go +++ b/vehicle/vw/api.go @@ -1,14 +1,12 @@ package vw import ( - "errors" "fmt" "net/http" - "net/url" "strings" + "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" - "github.com/andig/evcc/vehicle/oidc" ) // BaseURI is the VW api base URI @@ -93,63 +91,25 @@ func Temp2Float(val int) float64 { // API is the VW api client type API struct { *request.Helper - tokens *oidc.Tokens - authFlow func() error - refreshHeaders func() map[string]string + identity *Identity brand, country string - VIN string } // NewAPI creates a new api client -func NewAPI( - helper *request.Helper, tokens *oidc.Tokens, - authFlow func() error, refreshHeaders func() map[string]string, - vin, brand, country string, -) *API { +func NewAPI(log *util.Logger, identity *Identity, brand, country string) *API { v := &API{ - Helper: helper, - tokens: tokens, - authFlow: authFlow, - refreshHeaders: refreshHeaders, - VIN: vin, - brand: brand, - country: country, + Helper: request.NewHelper(log), + identity: identity, + brand: brand, + country: country, } return v } -func (v *API) refreshToken() error { - if v.tokens.RefreshToken == "" { - return errors.New("missing refresh token") - } - - data := url.Values(map[string][]string{ - "grant_type": {"refresh_token"}, - "refresh_token": {v.tokens.RefreshToken}, - "scope": {"sc2:fal"}, - }) - - req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), v.refreshHeaders()) - - 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 - } - } - } - - return err -} - func (v *API) getJSON(uri string, res interface{}) error { req, err := request.New(http.MethodGet, uri, nil, map[string]string{ "Accept": "application/json", - "Authorization": "Bearer " + v.tokens.AccessToken, + "Authorization": "Bearer " + v.identity.Token(), }) if err == nil { @@ -159,16 +119,13 @@ func (v *API) getJSON(uri string, res interface{}) error { if err != nil { // handle http 401 if se, ok := err.(request.StatusError); ok && se.StatusCode() == http.StatusUnauthorized { - // use refresh token - if err = v.refreshToken(); err != nil { - // re-run full auth flow - err = v.authFlow() - } + // refresh the token + err = v.identity.RefreshToken() } // retry original requests if err == nil { - req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken) + req.Header.Set("Authorization", "Bearer "+v.identity.Token()) err = v.DoJSON(req, &res) } } @@ -186,25 +143,25 @@ func (v *API) Vehicles() ([]string, error) { } // Charger implements the /charger response -func (v *API) Charger() (ChargerResponse, error) { +func (v *API) Charger(vin string) (ChargerResponse, error) { var res ChargerResponse - uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", BaseURI, v.brand, v.country, v.VIN) + uri := fmt.Sprintf("%s/bs/batterycharge/v1/%s/%s/vehicles/%s/charger", BaseURI, v.brand, v.country, vin) err := v.getJSON(uri, &res) return res, err } // Climater implements the /climater response -func (v *API) Climater() (ClimaterResponse, error) { +func (v *API) Climater(vin string) (ClimaterResponse, error) { var res ClimaterResponse - uri := fmt.Sprintf("%s/bs/climatisation/v1/%s/%s/vehicles/%s/climater", BaseURI, v.brand, v.country, v.VIN) + uri := fmt.Sprintf("%s/bs/climatisation/v1/%s/%s/vehicles/%s/climater", BaseURI, v.brand, v.country, vin) err := v.getJSON(uri, &res) return res, err } // Any implements any api response -func (v *API) Any(base string) (interface{}, error) { +func (v *API) Any(base, vin string) (interface{}, error) { var res interface{} - uri := fmt.Sprintf("%s/"+strings.TrimLeft(base, "/"), BaseURI, v.brand, v.country, v.VIN) + uri := fmt.Sprintf("%s/"+strings.TrimLeft(base, "/"), BaseURI, v.brand, v.country, vin) err := v.getJSON(uri, &res) return res, err } diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index 5e568150a..4ceeb5970 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -3,8 +3,14 @@ package vw import ( "errors" "net/http" + "net/http/cookiejar" "net/url" "strings" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/oidc" + "golang.org/x/net/publicsuffix" ) const ( @@ -20,7 +26,29 @@ const ( // Identity provides the identity.vwgroup.io login type Identity struct { - *http.Client + *request.Helper + clientID string + tokens oidc.Tokens +} + +// NewIdentity creates VW identity +func NewIdentity(log *util.Logger, clientID string) *Identity { + v := &Identity{ + Helper: request.NewHelper(log), + clientID: clientID, + } + + jar, _ := cookiejar.New(&cookiejar.Options{ + PublicSuffixList: publicsuffix.List, + }) + + // track cookies and don't follow redirects + v.Client.Jar = jar + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { + return http.ErrUseLastResponse + } + + return v } // redirect follows HTTP redirect header if error is nil. Request body is closed. @@ -40,10 +68,14 @@ func (v *Identity) redirect(resp *http.Response, err error) (*http.Response, err } // Login performs the identity.vwgroup.io login -func (v *Identity) Login(query url.Values, user, password string) (string, error) { +func (v *Identity) Login(query url.Values, user, password string) error { var vars FormVars var req *http.Request + // add nonce and state + query.Set("nonce", RandomString(43)) + query.Set("state", RandomString(43)) + // GET identity.vwgroup.io/oidc/v1/authorize?ui_locales=de&scope=openid%20profile%20birthdate%20nickname%20address%20phone%20cars%20mbb&response_type=code&state=gmiJOaB4&redirect_uri=https%3A%2F%2Fwww.portal.volkswagen-we.com%2Fportal%2Fweb%2Fguest%2Fcomplete-login&nonce=38042ee3-b7a7-43cf-a9c1-63d2f3f2d9f3&prompt=login&client_id=b7a5bb47-f875-47cf-ab83-2ba3bf6bb738@apps_vw-dilab_com uri := "https://identity.vwgroup.io/oidc/v1/authorize?" + query.Encode() resp, err := v.Get(uri) @@ -115,19 +147,75 @@ func (v *Identity) Login(query url.Values, user, password string) (string, error resp, err = v.redirect(resp, err) } - var idToken string if err == nil { loc := strings.ReplaceAll(resp.Header.Get("Location"), "#", "?") // convert to parseable url var location *url.URL if location, err = url.Parse(loc); err == nil { - idToken = location.Query().Get("id_token") + v.tokens.IDToken = location.Query().Get("id_token") - if idToken == "" { + if v.tokens.IDToken == "" { err = errors.New("missing id token") } } } - return idToken, err + if err == nil { + data := url.Values(map[string][]string{ + "grant_type": {"id_token"}, + "scope": {"sc2:fal"}, + "token": {v.tokens.IDToken}, + }) + + 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") + } + } + } + + return err +} + +// Token returns the access token +func (v *Identity) Token() string { + return v.tokens.AccessToken +} + +// RefreshToken uses the refresh token to obtain a new access token +func (v *Identity) RefreshToken() error { + if v.tokens.RefreshToken == "" { + return errors.New("missing refresh token") + } + + data := url.Values(map[string][]string{ + "grant_type": {"refresh_token"}, + "refresh_token": {v.tokens.RefreshToken}, + "scope": {"sc2:fal"}, + }) + + headers := map[string]string{ + "Content-Type": "application/x-www-form-urlencoded", + "X-Client-Id": v.clientID, + } + + req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), headers) + + 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 + } + } + } + + return err } diff --git a/vehicle/vw/impl.go b/vehicle/vw/provider.go similarity index 79% rename from vehicle/vw/impl.go rename to vehicle/vw/provider.go index e800fac00..cd78ffd39 100644 --- a/vehicle/vw/impl.go +++ b/vehicle/vw/provider.go @@ -8,27 +8,27 @@ import ( "github.com/andig/evcc/provider" ) -// Implementation implements the evcc vehicle api -type Implementation struct { +// Provider implements the evcc vehicle api +type Provider struct { chargerG func() (interface{}, error) climateG func() (interface{}, error) } -// NewImplementation provides the evcc vehicle api implementation -func NewImplementation(api *API, cache time.Duration) *Implementation { - impl := &Implementation{ +// NewProvider provides the evcc vehicle api provider +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + impl := &Provider{ chargerG: provider.NewCached(func() (interface{}, error) { - return api.Charger() + return api.Charger(vin) }, cache).InterfaceGetter(), climateG: provider.NewCached(func() (interface{}, error) { - return api.Climater() + return api.Climater(vin) }, cache).InterfaceGetter(), } return impl } // ChargeState implements the Vehicle.ChargeState interface -func (v *Implementation) ChargeState() (float64, error) { +func (v *Provider) ChargeState() (float64, error) { res, err := v.chargerG() if res, ok := res.(ChargerResponse); err == nil && ok { return float64(res.Charger.Status.BatteryStatusData.StateOfCharge.Content), nil @@ -38,7 +38,7 @@ func (v *Implementation) ChargeState() (float64, error) { } // FinishTime implements the Vehicle.ChargeFinishTimer interface -func (v *Implementation) FinishTime() (time.Time, error) { +func (v *Provider) FinishTime() (time.Time, error) { res, err := v.chargerG() if res, ok := res.(ChargerResponse); err == nil && ok { rct := res.Charger.Status.BatteryStatusData.RemainingChargingTime @@ -58,7 +58,7 @@ func (v *Implementation) FinishTime() (time.Time, error) { } // Status implements the Vehicle.Status interface -func (v *Implementation) Status() (api.ChargeStatus, error) { +func (v *Provider) Status() (api.ChargeStatus, error) { status := api.StatusA // disconnected res, err := v.chargerG() @@ -75,7 +75,7 @@ func (v *Implementation) Status() (api.ChargeStatus, error) { } // Range implements the Vehicle.Range interface -func (v *Implementation) Range() (rng int64, err error) { +func (v *Provider) Range() (rng int64, err error) { res, err := v.chargerG() if res, ok := res.(ChargerResponse); err == nil && ok { crsd := res.Charger.Status.CruisingRangeStatusData @@ -90,7 +90,7 @@ func (v *Implementation) Range() (rng int64, err error) { } // Climater implements the Vehicle.Climater interface -func (v *Implementation) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { +func (v *Provider) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { res, err := v.climateG() if res, ok := res.(ClimaterResponse); err == nil && ok { state := strings.ToLower(res.Climater.Status.ClimatisationStatusData.ClimatisationState.Content)