From 147dd7bb8adc757af969c45f7ea322b7f7366dcc Mon Sep 17 00:00:00 2001 From: Brett Henderson Date: Thu, 16 Apr 2026 18:14:29 +1000 Subject: [PATCH] Add support for Connected Cars API (used by Volkswagen Australia) (#28899) --- .../definition/vehicle/connected-cars.yaml | 43 ++++++ vehicle/connectedcars.go | 120 +++++++++++++++ vehicle/connectedcars/api.go | 142 ++++++++++++++++++ vehicle/connectedcars/types.go | 46 ++++++ 4 files changed, 351 insertions(+) create mode 100644 templates/definition/vehicle/connected-cars.yaml create mode 100644 vehicle/connectedcars.go create mode 100644 vehicle/connectedcars/api.go create mode 100644 vehicle/connectedcars/types.go diff --git a/templates/definition/vehicle/connected-cars.yaml b/templates/definition/vehicle/connected-cars.yaml new file mode 100644 index 000000000..90e64f5cf --- /dev/null +++ b/templates/definition/vehicle/connected-cars.yaml @@ -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 }} diff --git a/vehicle/connectedcars.go b/vehicle/connectedcars.go new file mode 100644 index 000000000..244210db0 --- /dev/null +++ b/vehicle/connectedcars.go @@ -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 +} diff --git a/vehicle/connectedcars/api.go b/vehicle/connectedcars/api.go new file mode 100644 index 000000000..f22d5d71f --- /dev/null +++ b/vehicle/connectedcars/api.go @@ -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 +} diff --git a/vehicle/connectedcars/types.go b/vehicle/connectedcars/types.go new file mode 100644 index 000000000..1254455cb --- /dev/null +++ b/vehicle/connectedcars/types.go @@ -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"` +}