Add support for Connected Cars API (used by Volkswagen Australia) (#28899)
This commit is contained in:
parent
995e0044bc
commit
147dd7bb8a
4 changed files with 351 additions and 0 deletions
43
templates/definition/vehicle/connected-cars.yaml
Normal file
43
templates/definition/vehicle/connected-cars.yaml
Normal 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
120
vehicle/connectedcars.go
Normal 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
|
||||
}
|
||||
142
vehicle/connectedcars/api.go
Normal file
142
vehicle/connectedcars/api.go
Normal 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
|
||||
}
|
||||
46
vehicle/connectedcars/types.go
Normal file
46
vehicle/connectedcars/types.go
Normal 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"`
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue