Add Volvo (Connected Car) (#20505)
This commit is contained in:
parent
7e9bccf0b8
commit
960a71c97c
10 changed files with 213 additions and 115 deletions
|
|
@ -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)
|
||||
|
|
|
|||
63
cmd/token_volvo.go
Normal file
63
cmd/token_volvo.go
Normal file
|
|
@ -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))
|
||||
}
|
||||
|
|
@ -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 <auto-name>` 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 <car-name>` 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" . }}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
template: volvo
|
||||
deprecated: true
|
||||
products:
|
||||
- brand: Volvo
|
||||
description:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
20
vehicle/volvo/connected/helper.go
Normal file
20
vehicle/volvo/connected/helper.go
Normal file
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,3 +31,7 @@ type RechargeStatus struct {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Vehicle struct {
|
||||
VIN string
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue