Add Volvo (Connected Car) (#20505)

This commit is contained in:
lehmanju 2025-04-12 11:58:37 +02:00 • committed by GitHub
parent 7e9bccf0b8
commit 960a71c97c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 213 additions and 115 deletions

View file

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

View file

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

View file

@ -1,4 +1,5 @@
template: volvo
deprecated: true
products:
- brand: Volvo
description:

View file

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

View file

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

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

View file

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

View file

@ -31,3 +31,7 @@ type RechargeStatus struct {
}
}
}
type Vehicle struct {
VIN string
}

View file

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