diff --git a/cmd/token.go b/cmd/token.go index 3d8f41982..f757de7db 100644 --- a/cmd/token.go +++ b/cmd/token.go @@ -71,6 +71,8 @@ func runToken(cmd *cobra.Command, args []string) { token, err = tronityToken(conf, vehicleConf) case "citroen", "ds", "opel", "peugeot": token, err = psaToken(typ) + case "volvo-connected": + token, err = volvoToken(vehicleConf) default: log.FATAL.Fatalf("vehicle type '%s' does not support token authentication", vehicleConf.Type) diff --git a/cmd/token_volvo.go b/cmd/token_volvo.go new file mode 100644 index 000000000..7d5b600d9 --- /dev/null +++ b/cmd/token_volvo.go @@ -0,0 +1,63 @@ +package cmd + +import ( + "context" + "fmt" + + "github.com/AlecAivazis/survey/v2" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle" + "github.com/evcc-io/evcc/vehicle/volvo/connected" + "github.com/samber/lo" + "golang.org/x/oauth2" +) + +func volvoToken(conf config.Named) (*oauth2.Token, error) { + var cc struct { + Credentials vehicle.ClientCredentials + } + + if err := util.DecodeOther(conf.Other, &cc); err != nil { + return nil, err + } + + if cc.Credentials.ID == "" { + if err := survey.AskOne(&survey.Input{ + Message: "Please enter your client id:", + }, &cc.Credentials.ID, survey.WithValidator(survey.Required)); err != nil { + return nil, err + } + } + + if cc.Credentials.Secret == "" { + if err := survey.AskOne(&survey.Input{ + Message: "Please enter your client secret:", + }, &cc.Credentials.Secret, survey.WithValidator(survey.Required)); err != nil { + return nil, err + } + } + + oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret) + cv := oauth2.GenerateVerifier() + + state := lo.RandomString(16, lo.AlphanumericCharset) + authorize_url := oc.AuthCodeURL(state, oauth2.S256ChallengeOption(cv)) + + fmt.Println("Please visit: ", authorize_url) + fmt.Println("And grab the authorization code like described here: https://github.com/flobz/psa_car_controller/discussions/779") + + var code string + prompt_code := &survey.Input{ + Message: "Please enter your authorization code:", + } + if err := survey.AskOne(prompt_code, &code, survey.WithValidator(survey.Required)); err != nil { + return nil, err + } + + client := request.NewClient(util.NewLogger("volvo-connected")) + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + + return oc.Exchange(ctx, code, oauth2.VerifierOption(cv)) +} diff --git a/templates/definition/vehicle/volvo-connected.yaml b/templates/definition/vehicle/volvo-connected.yaml index 955cdbc27..f27405e80 100644 --- a/templates/definition/vehicle/volvo-connected.yaml +++ b/templates/definition/vehicle/volvo-connected.yaml @@ -1,17 +1,55 @@ template: volvo-connected products: - brand: Volvo - description: - de: aktuell - en: latest +requirements: + description: + de: | + Für die Nutzung mit EVCC benötigst du einen Volvo Account und einen Volvo Connected Car API Key. + Erstelle dazu auf der [Account Seite](https://developer.volvocars.com/account/) eine neue Applikation und speichere den primären VCC API Key ab. + Veröffentliche nun deine Applikation und wähle unter "Scopes" die Berechtigungen "Connected Vehicle API -> conve:vehicle_relation" und "Energy API -> (alles)" aus. + Als Redirect URL kannst du erstmal "http://localhost:9999" (oder etwas anderes nicht erreichbares) verwenden. Der Authorisierungscode wird später händisch aus dem Browser gelesen und eingegeben. + Sobald die Applikation erstellt ist, wird sie als "Publication under Review" angezeigt. Das ist nicht weiter schlimm, es funktioniert trotzdem. + Erstelle danach einen Token mit `evcc token ` und speichere die Tokens in der Konfiguration. Falls Evcc wegen einem invaliden Refresh Token abbricht, lösche die Tokens mit `evcc settings` und generiere sie neu. + en: | + To use with EVCC, you need a Volvo account and a Volvo Connected Car API Key. + To do this, create a new application on the [Account page](https://developer.volvocars.com/account/) and save the primary VCC API key. + Now publish your application and select the permissions "Connected Vehicle API -> conve:vehicle_relation" and "Energy API -> (everything)" under "Scopes". + You can use "http://localhost:9999" (or something else unreachable) as the redirect URL for now. The authorization code will be read and entered manually from the browser later. + Once the application is created, it will be displayed as "Publication under Review". This is not a problem, it still works. + Then create a token with `evcc token ` and save the tokens in the configuration. If Evcc crashes due to an invalid refresh token, delete the tokens with `evcc settings` and generate them again. params: - - preset: vehicle-base + - preset: vehicle-common - name: vccapikey required: true help: - en: "Volvo developer portal VCC API Key, see https://github.com/evcc-io/evcc/discussions/3677#discussioncomment-4106300" - de: "Volvo developer portal VCC API Key, siehe https://github.com/evcc-io/evcc/discussions/3677#discussioncomment-4106300" + en: "Volvo developer portal VCC API Key" + de: "Volvo developer portal VCC API Key" + - name: clientId + required: true + help: + en: "Client ID of your [Volvo Developer App](https://developer.volvocars.com/)." + de: "Client ID deiner [Volvo Developer App](https://developer.volvocars.com/)." + - name: clientSecret + required: true + help: + en: "Client Secret of your [Volvo Developer App](https://developer.volvocars.com/)." + de: "Client Secret deiner [Volvo Developer App](https://developer.volvocars.com/)." + - name: accessToken + required: true + mask: true + - name: refreshToken + required: true + mask: true + - name: vin + example: WF0FXX... render: | type: volvo-connected vccapikey: {{ .vccapikey }} - {{ include "vehicle-base" . }} + credentials: + id: {{ .clientId }} + secret: {{ .clientSecret }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + vin: {{ .vin }} + {{ include "vehicle-common" . }} diff --git a/templates/definition/vehicle/volvo.yaml b/templates/definition/vehicle/volvo.yaml index fc9ed8844..86b24dffc 100644 --- a/templates/definition/vehicle/volvo.yaml +++ b/templates/definition/vehicle/volvo.yaml @@ -1,4 +1,5 @@ template: volvo +deprecated: true products: - brand: Volvo description: diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 741e84735..96ef7bb9b 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -21,13 +21,12 @@ func init() { // NewVolvoConnectedFromConfig creates a new VolvoConnected vehicle func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { - embed `mapstructure:",squash"` - User, Password string - VIN string - // ClientID, ClientSecret string - // Sandbox bool - VccApiKey string - Cache time.Duration + embed `mapstructure:",squash"` + VIN string + VccApiKey string + Credentials ClientCredentials + Tokens Tokens + Cache time.Duration }{ Cache: interval, } @@ -36,40 +35,20 @@ func NewVolvoConnectedFromConfig(other map[string]interface{}) (api.Vehicle, err return nil, err } - if cc.User == "" || cc.Password == "" { - return nil, api.ErrMissingCredentials - } + log := util.NewLogger("volvo-connected").Redact(cc.VIN, cc.VccApiKey, cc.Tokens.Access, cc.Tokens.Refresh) - // if cc.ClientID == "" && cc.ClientSecret == "" { - // return nil, errors.New("missing credentials") - // } + oc := connected.Oauth2Config(cc.Credentials.ID, cc.Credentials.Secret) - // var options []VolvoConnected.IdentityOptions - - // TODO Load tokens from a persistence storage and use those during startup - // e.g. persistence.Load("key") - // if tokens != nil { - // options = append(options, VolvoConnected.WithToken(&oauth2.Token{ - // AccessToken: tokens.Access, - // RefreshToken: tokens.Refresh, - // Expiry: tokens.Expiry, - // })) - // } - - log := util.NewLogger("volvo-cc").Redact(cc.User, cc.Password, cc.VIN, cc.VccApiKey) - - // identity, err := connected.NewIdentity(log, cc.ClientID, cc.ClientSecret) - identity, err := connected.NewIdentity(log) + token, err := cc.Tokens.Token() if err != nil { return nil, err } - ts, err := identity.Login(cc.User, cc.Password) + ts, err := connected.NewIdentity(log, oc, token) if err != nil { return nil, err } - // api := connected.NewAPI(log, identity, cc.Sandbox) api := connected.NewAPI(log, ts, cc.VccApiKey) cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles) diff --git a/vehicle/volvo/connected/api.go b/vehicle/volvo/connected/api.go index 866e58bcb..697710d71 100644 --- a/vehicle/volvo/connected/api.go +++ b/vehicle/volvo/connected/api.go @@ -42,18 +42,15 @@ func NewAPI(log *util.Logger, identity oauth2.TokenSource, vccapikey string) *AP } func (v *API) Vehicles() ([]string, error) { - type Vehicle struct { - ID string - } var res struct { - Vehicles []Vehicle + Vehicles []Vehicle `json:"data"` } - uri := fmt.Sprintf("%s/extended-vehicle/v1/vehicles", ApiURL) + uri := fmt.Sprintf("%s/connected-vehicle/v2/vehicles", ApiURL) err := v.GetJSON(uri, &res) return lo.Map(res.Vehicles, func(v Vehicle, _ int) string { - return v.ID + return v.VIN }), err } diff --git a/vehicle/volvo/connected/helper.go b/vehicle/volvo/connected/helper.go new file mode 100644 index 000000000..b74639ab4 --- /dev/null +++ b/vehicle/volvo/connected/helper.go @@ -0,0 +1,20 @@ +package connected + +import ( + "sync" + + "golang.org/x/oauth2" +) + +var ( + mu sync.Mutex + identities = make(map[string]oauth2.TokenSource) +) + +func getInstance(subject string) oauth2.TokenSource { + return identities[subject] +} + +func addInstance(subject string, identity oauth2.TokenSource) { + identities[subject] = identity +} diff --git a/vehicle/volvo/connected/identity.go b/vehicle/volvo/connected/identity.go index 60b96f844..faeb97aa4 100644 --- a/vehicle/volvo/connected/identity.go +++ b/vehicle/volvo/connected/identity.go @@ -1,97 +1,91 @@ package connected import ( - "net/http" - "net/url" + "context" + "errors" "strings" - "time" + "sync" "github.com/coreos/go-oidc/v3/oidc" + "github.com/evcc-io/evcc/server/db/settings" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/util/request" "golang.org/x/oauth2" ) -var Oauth2Config = oauth2.Config{ - Endpoint: oauth2.Endpoint{ - AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2", - TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2", - }, - Scopes: []string{ - oidc.ScopeOpenID, "vehicle:attributes", - "energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status", - "conve:fuel_status", "conve:odometer_status", "conve:environment", - }, +func Oauth2Config(id, secret string) *oauth2.Config { + return &oauth2.Config{ + ClientID: id, + ClientSecret: secret, + RedirectURL: "http://localhost:7070/callback", + Endpoint: oauth2.Endpoint{ + AuthURL: "https://volvoid.eu.volvocars.com/as/authorization.oauth2", + TokenURL: "https://volvoid.eu.volvocars.com/as/token.oauth2", + AuthStyle: oauth2.AuthStyleInHeader, + }, + Scopes: []string{ + oidc.ScopeOpenID, + "conve:vehicle_relation", + "energy:recharge_status", "energy:battery_charge_level", "energy:electric_range", "energy:estimated_charging_time", "energy:charging_connection_status", "energy:charging_system_status", + }, + } } -const ( - managerId = "JWTh4Yf0b" - basicAuth = "Basic aDRZZjBiOlU4WWtTYlZsNnh3c2c1WVFxWmZyZ1ZtSWFEcGhPc3kxUENhVXNpY1F0bzNUUjVrd2FKc2U0QVpkZ2ZJZmNMeXc=" -) - type Identity struct { - log *util.Logger - *request.Helper + ts oauth2.TokenSource + mu sync.Mutex + subject string } -func NewIdentity(log *util.Logger) (*Identity, error) { - v := &Identity{ - log: log, - Helper: request.NewHelper(log), +func NewIdentity(log *util.Logger, config *oauth2.Config, token *oauth2.Token) (oauth2.TokenSource, error) { + // serialise instance handling + mu.Lock() + defer mu.Unlock() + // reuse instance + subject := "volvo-connected." + strings.ToLower(config.ClientID) + if instance := getInstance(subject); instance != nil { + return instance, nil } + v := &Identity{ + subject: subject, + } + + var tok oauth2.Token + if err := settings.Json(v.subject, &tok); err == nil { + token = &tok + } + + client := request.NewClient(log) + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, client) + + v.ts = config.TokenSource(ctx, token) + + if tok, err := v.Token(); err == nil { + token = tok + } else { + return nil, err + } + + if !token.Valid() { + return nil, errors.New("token expired and could not be refreshed") + } + + // add instance + addInstance(v.subject, v) + return v, nil } -func (v *Identity) Login(user, password string) (oauth2.TokenSource, error) { - data := url.Values{ - "username": {user}, - "password": {password}, - "access_token_manager_id": {managerId}, - "grant_type": {"password"}, - "scope": {strings.Join(Oauth2Config.Scopes, " ")}, - } +func (v *Identity) Token() (*oauth2.Token, error) { + v.mu.Lock() + defer v.mu.Unlock() - req, err := request.New(http.MethodPost, Oauth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Authorization": basicAuth, - }) + tok, err := v.ts.Token() if err != nil { return nil, err } + err = settings.SetJson(v.subject, tok) - var tok oauth2.Token - if err := v.DoJSON(req, &tok); err != nil { - return nil, err - } - - token := util.TokenWithExpiry(&tok) - ts := oauth2.ReuseTokenSourceWithExpiry(token, oauth.RefreshTokenSource(token, v), 15*time.Minute) - go oauth.Refresh(v.log, token, ts) - - return ts, nil -} - -func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { - data := url.Values{ - "access_token_manager_id": {managerId}, - "grant_type": {"refresh_token"}, - "refresh_token": {token.RefreshToken}, - } - - req, err := request.New(http.MethodPost, Oauth2Config.Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{ - "Content-Type": request.FormContent, - "Authorization": basicAuth, - }) - if err != nil { - return nil, err - } - - var res oauth2.Token - if err := v.DoJSON(req, &res); err != nil { - return nil, err - } - - return util.TokenWithExpiry(&res), err + return tok, err } diff --git a/vehicle/volvo/connected/types.go b/vehicle/volvo/connected/types.go index 5fe7b867b..36b38cca0 100644 --- a/vehicle/volvo/connected/types.go +++ b/vehicle/volvo/connected/types.go @@ -31,3 +31,7 @@ type RechargeStatus struct { } } } + +type Vehicle struct { + VIN string +} diff --git a/vehicle/volvo.go b/vehicle/volvo_deprecated.go similarity index 98% rename from vehicle/volvo.go rename to vehicle/volvo_deprecated.go index b62b7a3c9..7ed3141df 100644 --- a/vehicle/volvo.go +++ b/vehicle/volvo_deprecated.go @@ -11,7 +11,7 @@ import ( "github.com/evcc-io/evcc/vehicle/volvo" ) -// Volvo is an api.Vehicle implementation for Volvo. cars +// Volvo is an api.Vehicle implementation for Volvo cars type Volvo struct { *embed *request.Helper