Add support for Connected Cars API (used by Volkswagen Australia) (#28899)

This commit is contained in:
Brett Henderson 2026-04-16 18:14:29 +10:00 • committed by GitHub
parent 995e0044bc
commit 147dd7bb8a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 351 additions and 0 deletions

View file

@ -0,0 +1,43 @@
template: connected-cars
products:
- description:
generic: Connected Cars (Volkswagen Australia)
group: generic
params:
- name: deviceToken
required: true
mask: true
description:
en: Device Token
de: Gerätetoken
help:
en: Obtained via Connected Cars device registration. See https://github.com/brettch/evcc-connected-cars for scripts to perform device registration.
de: Die Daten wurden über die Geräteregistrierung von Connected Cars bezogen. Skripte zur Geräteregistrierung finden Sie unter https://github.com/brettch/evcc-connected-cars.
- name: domain
default: au1.connectedcars.io
description:
en: API Domain
de: API-Domäne
help:
en: The API domain to use for Connected Cars. Each country typically has a unique domain. It can be found by logging into the Connected Cars GraphiQL tool (https://api.connectedcars.io/graphql/graphiql/), logging into the relevant country, and viewing the domain it uses (minus the leading "api.").
de: Die für Connected Cars zu verwendende API-Domäne. Jedes Land hat in der Regel eine eigene Domäne. Diese finden Sie, indem Sie sich im Connected Cars GraphiQL-Tool (https://api.connectedcars.io/graphql/graphiql/) anmelden, das entsprechende Land auswählen und die verwendete Domäne (ohne das vorangestellte „api.“) anzeigen.
- name: namespace
default: "vwaustralia:app"
description:
en: Organization Namespace
de: Namespace der Organisation
help:
en: The namespace is used to identify the organization within Connected Cars. It can be found by logging into the Connected Cars GraphiQL tool (https://api.connectedcars.io/graphql/graphiql/), logging into the relevant country, and viewing the "X-Organization-Namespace" header it sets for you.
de: Der Namespace dient zur Identifizierung der Organisation innerhalb von Connected Cars. Er kann ermittelt werden, indem man sich beim Connected Cars GraphiQL-Tool (https://api.connectedcars.io/graphql/graphiql/) anmeldet, das entsprechende Land auswählt und den automatisch festgelegten Header „X-Organization-Namespace“ anzeigt.
- name: vin
- preset: vehicle-common
- name: cache
default: 15m
render: |
type: connected-cars
deviceToken: {{ .deviceToken }}
domain: {{ .domain }}
namespace: {{ .namespace }}
vin: {{ .vin }}
{{ include "vehicle-common" . }}
cache: {{ .cache }}

120
vehicle/connectedcars.go Normal file
View file

@ -0,0 +1,120 @@
package vehicle
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/vehicle/connectedcars"
)
// ConnectedCars is an api.Vehicle implementation for the Connected Cars platform (connectedcars.io).
type ConnectedCars struct {
*embed
dataG func() (connectedcars.VehicleData, error)
}
func init() {
registry.Add("connected-cars", NewConnectedCarsFromConfig)
}
// NewConnectedCarsFromConfig creates a new vehicle
func NewConnectedCarsFromConfig(other map[string]any) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
DeviceToken string
Domain string
Namespace string
VIN string
Cache time.Duration
}{
Domain: "au1.connectedcars.io",
Namespace: "vwaustralia:app",
Cache: interval,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.DeviceToken == "" {
return nil, api.ErrMissingCredentials
}
log := util.NewLogger("connected-cars").Redact(cc.DeviceToken)
api := connectedcars.NewAPI(log, cc.Domain, cc.Namespace, cc.DeviceToken)
vehicle, err := ensureVehicleEx(
cc.VIN, api.Vehicles,
func(v connectedcars.Vehicle) (string, error) {
return v.VIN, nil
},
)
if err != nil {
return nil, err
}
v := &ConnectedCars{
embed: &cc.embed,
dataG: util.Cached(func() (connectedcars.VehicleData, error) {
return api.Data(vehicle.ID)
}, cc.Cache),
}
return v, nil
}
// Soc implements the api.Vehicle interface
func (v *ConnectedCars) Soc() (float64, error) {
res, err := v.dataG()
if err != nil {
return 0, err
}
if res.ChargePercentage == nil {
return 0, api.ErrNotAvailable
}
return res.ChargePercentage.Pct, nil
}
var _ api.ChargeState = (*ConnectedCars)(nil)
// Status implements the api.ChargeState interface
func (v *ConnectedCars) Status() (api.ChargeStatus, error) {
res, err := v.dataG()
if err != nil {
return api.StatusNone, err
}
if res.ChargingState != nil && res.ChargingState.Enabled {
return api.StatusC, nil
}
return api.StatusA, nil
}
var _ api.VehicleRange = (*ConnectedCars)(nil)
// Range implements the api.VehicleRange interface
func (v *ConnectedCars) Range() (int64, error) {
res, err := v.dataG()
if err != nil {
return 0, err
}
if res.RangeTotalKm == nil {
return 0, api.ErrNotAvailable
}
return int64(res.RangeTotalKm.Km), nil
}
var _ api.VehicleOdometer = (*ConnectedCars)(nil)
// Odometer implements the api.VehicleOdometer interface
func (v *ConnectedCars) Odometer() (float64, error) {
res, err := v.dataG()
if err != nil {
return 0, err
}
if res.Odometer == nil {
return 0, api.ErrNotAvailable
}
return res.Odometer.Odometer, nil
}

View file

@ -0,0 +1,142 @@
package connectedcars
import (
"fmt"
"net/http"
"time"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)
// API provides access to the Connected Cars GraphQL API.
type API struct {
*request.Helper
authHelper *request.Helper // plain client for token refresh; no oauth2 transport to avoid circular dependency
domain string
namespace string
}
// graphqlRequest is a generic GraphQL request body.
type graphqlRequest struct {
Query string `json:"query"`
Variables map[string]any `json:"variables,omitempty"`
}
// NewAPI creates a new Connected Cars API client with device-token authentication.
func NewAPI(log *util.Logger, domain, namespace, deviceToken string) *API {
api := &API{
Helper: request.NewHelper(log),
authHelper: request.NewHelper(log),
domain: domain,
namespace: namespace,
}
// Use RefreshTokenSource to handle JWT refresh via device token.
// The device token is stored as RefreshToken; it never changes.
token := &oauth2.Token{
RefreshToken: deviceToken,
}
ts := oauth.RefreshTokenSource(token, api.refreshToken)
// Install oauth2.Transport for Bearer token, plus a decorator for the
// namespace header required by all API endpoints.
api.Client.Transport = &transport.Decorator{
Decorator: transport.DecorateHeaders(map[string]string{
"X-Organization-Namespace": namespace,
}),
Base: &oauth2.Transport{
Source: ts,
Base: api.Client.Transport,
},
}
return api
}
// refreshToken exchanges the device token for a new JWT access token.
func (a *API) refreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := struct {
DeviceToken string `json:"deviceToken"`
}{
DeviceToken: token.RefreshToken,
}
uri := fmt.Sprintf("https://auth-api.%s/auth/login/deviceToken", a.domain)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
"Content-Type": request.JSONContent,
"X-Organization-Namespace": a.namespace,
})
if err != nil {
return nil, err
}
var res TokenResponse
// Use the plain authHelper (no oauth2 transport) to avoid circular dependency.
if err := a.authHelper.DoJSON(req, &res); err != nil {
return nil, fmt.Errorf("device token login: %w", err)
}
return &oauth2.Token{
AccessToken: res.Token,
RefreshToken: token.RefreshToken,
Expiry: time.Now().Add(time.Duration(res.Expires) * time.Second),
}, nil
}
// Vehicles returns the list of vehicles on the account.
func (a *API) Vehicles() ([]Vehicle, error) {
uri := fmt.Sprintf("https://api.%s/graphql", a.domain)
body := graphqlRequest{
Query: `{ vehicles(first:100) { items { id licensePlate vin } } }`,
}
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(body), request.JSONEncoding)
if err != nil {
return nil, err
}
var res VehiclesResponse
if err := a.DoJSON(req, &res); err != nil {
return nil, fmt.Errorf("list vehicles: %w", err)
}
if res.Data == nil {
return nil, fmt.Errorf("list vehicles: missing data in response")
}
return res.Data.Vehicles.Items, nil
}
// Data fetches the current vehicle telemetry data.
func (a *API) Data(vehicleID string) (VehicleData, error) {
uri := fmt.Sprintf("https://api.%s/graphql", a.domain)
body := graphqlRequest{
Query: `query($id: ID!) { vehicle(id: $id) { id chargePercentage { pct } odometer { odometer } rangeTotalKm { km } chargingState { enabled } }}`,
Variables: map[string]any{"id": vehicleID},
}
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(body), request.JSONEncoding)
if err != nil {
return VehicleData{}, err
}
var res DataResponse
if err := a.DoJSON(req, &res); err != nil {
return VehicleData{}, fmt.Errorf("vehicle data: %w", err)
}
if res.Data == nil {
return VehicleData{}, fmt.Errorf("vehicle data: missing data in response")
}
return res.Data.Vehicle, nil
}

View file

@ -0,0 +1,46 @@
package connectedcars
// TokenResponse is the response from the device token login endpoint.
type TokenResponse struct {
Token string `json:"token"`
Expires int `json:"expires"`
}
// VehiclesResponse is the GraphQL response for listing vehicles.
type VehiclesResponse struct {
Data *struct {
Vehicles struct {
Items []Vehicle `json:"items"`
} `json:"vehicles"`
} `json:"data"`
}
// Vehicle represents a vehicle from the Connected Cars API.
type Vehicle struct {
ID string `json:"id"`
LicensePlate string `json:"licensePlate"`
VIN string `json:"vin"`
}
// DataResponse is the GraphQL response for vehicle data.
type DataResponse struct {
Data *struct {
Vehicle VehicleData `json:"vehicle"`
} `json:"data"`
}
// VehicleData contains the vehicle telemetry fields.
type VehicleData struct {
ChargePercentage *struct {
Pct float64 `json:"pct"`
} `json:"chargePercentage"`
Odometer *struct {
Odometer float64 `json:"odometer"`
} `json:"odometer"`
RangeTotalKm *struct {
Km float64 `json:"km"`
} `json:"rangeTotalKm"`
ChargingState *struct {
Enabled bool `json:"enabled"`
} `json:"chargingState"`
}