Refactor and VW identity and api

This commit is contained in:
andig 2020-12-27 15:13:08 +01:00
parent c92e8669cc
commit def4c8a565
5 changed files with 162 additions and 245 deletions

View file

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

View file

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

View file

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

View file

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

View file

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