From 10ac59034f6751150fa6205491309dc4a34ea0f0 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 24 Nov 2025 17:24:00 +0100 Subject: [PATCH] Add FordConnect Query (#25462) --- server/providerauth/state.go | 12 ++- .../vehicle/ford-connect-query.yaml | 43 +++++++++++ .../definition/vehicle/ford-connect.yaml | 2 +- vehicle/ford-connect-query.go | 66 ++++++++++++++++ vehicle/ford/query/api.go | 49 ++++++++++++ vehicle/ford/query/oauth2.go | 50 +++++++++++++ vehicle/ford/query/provider.go | 75 +++++++++++++++++++ vehicle/ford/query/types.go | 55 ++++++++++++++ 8 files changed, 347 insertions(+), 5 deletions(-) create mode 100644 templates/definition/vehicle/ford-connect-query.yaml create mode 100644 vehicle/ford-connect-query.go create mode 100644 vehicle/ford/query/api.go create mode 100644 vehicle/ford/query/oauth2.go create mode 100644 vehicle/ford/query/provider.go create mode 100644 vehicle/ford/query/types.go diff --git a/server/providerauth/state.go b/server/providerauth/state.go index f7bac49c0..d4404c110 100644 --- a/server/providerauth/state.go +++ b/server/providerauth/state.go @@ -4,7 +4,7 @@ import ( "crypto/aes" "crypto/cipher" "crypto/rand" - "encoding/base64" + "encoding/base32" "encoding/json" "errors" "fmt" @@ -15,7 +15,7 @@ import ( const stateValidity = 2 * time.Minute type State struct { - Created time.Time + Created time.Time `json:"time"` } func NewState() State { @@ -24,8 +24,12 @@ func NewState() State { } } +// Use base32 to avoid special characters. Changed from base64 with padding for +// compatibility with FordConnect Query in https://github.com/evcc-io/evcc/pull/25462 +var encoding = base32.StdEncoding.WithPadding(base32.NoPadding) + func DecryptState(enc string, key []byte) (*State, error) { - ciphertext, err := base64.URLEncoding.DecodeString(enc) + ciphertext, err := encoding.DecodeString(enc) if err != nil { return nil, fmt.Errorf("failed to base64 decode encrypted state: %w", err) } @@ -79,7 +83,7 @@ func (c *State) Encrypt(key []byte) string { stream.XORKeyStream(ciphertext[aes.BlockSize:], plain) // convert to base64 - return base64.URLEncoding.EncodeToString(ciphertext) + return encoding.EncodeToString(ciphertext) } func (c *State) Valid() bool { diff --git a/templates/definition/vehicle/ford-connect-query.yaml b/templates/definition/vehicle/ford-connect-query.yaml new file mode 100644 index 000000000..993269fed --- /dev/null +++ b/templates/definition/vehicle/ford-connect-query.yaml @@ -0,0 +1,43 @@ +template: ford-connect-query +products: + - brand: Ford (FordConnect Query) +params: + - preset: vehicle-common + - name: clientid + description: + generic: FordConnect Query Client ID + help: + de: Einrichtung unter https://developer.ford.com/apis/fordconnect-query + en: Setup at https://developer.ford.com/apis/fordconnect-query + required: true + - name: clientsecret + description: + generic: FordConnect Query Client Secret + help: + de: Einrichtung unter https://developer.ford.com/apis/fordconnect-query + en: Setup at https://developer.ford.com/apis/fordconnect-query + required: true + - name: redirecturi + description: + generic: FordConnect Query Redirect URL + help: + de: Einrichtung unter https://developer.ford.com/apis/fordconnect-query + en: Setup at https://developer.ford.com/apis/fordconnect-query + service: auth/redirecturi + required: true + - name: vin + example: WF0FXX... + - name: cache + default: 15m +auth: + type: ford-connect + params: [clientid, clientsecret, redirecturi] +render: | + type: ford-connect-query + vin: {{ .vin }} + credentials: + id: {{ .clientid }} + secret: {{ .clientsecret }} + redirecturi: {{ .redirecturi }} + {{ include "vehicle-common" . }} + cache: {{ .cache }} diff --git a/templates/definition/vehicle/ford-connect.yaml b/templates/definition/vehicle/ford-connect.yaml index 3e6380574..318770afa 100644 --- a/templates/definition/vehicle/ford-connect.yaml +++ b/templates/definition/vehicle/ford-connect.yaml @@ -1,6 +1,6 @@ template: ford-connect products: - - brand: Ford + - brand: Ford (Legacy FordConnect) params: - preset: vehicle-common - name: clientid diff --git a/vehicle/ford-connect-query.go b/vehicle/ford-connect-query.go new file mode 100644 index 000000000..363f7c80d --- /dev/null +++ b/vehicle/ford-connect-query.go @@ -0,0 +1,66 @@ +package vehicle + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/vehicle/ford/query" +) + +// https://developer.ford.com/apis/fordconnect-query + +// FordConnectQuery is an api.Vehicle implementation for Ford cars +type FordConnectQuery struct { + *embed + *query.Provider +} + +func init() { + registry.Add("ford-connect-query", NewFordConnectQueryFromConfig) +} + +// NewFordConnectQueryFromConfig creates a new vehicle +func NewFordConnectQueryFromConfig(other map[string]any) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + Credentials ClientCredentials + RedirectURI string + VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + v := &FordConnectQuery{ + embed: &cc.embed, + } + + if err := cc.Credentials.Error(); err != nil { + return nil, err + } + + oc := query.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret, cc.RedirectURI) + ts, err := query.NewOAuth(oc, cc.embed.GetTitle()) + if err != nil { + return nil, err + } + + log := util.NewLogger("ford").Redact(cc.VIN) + api := query.NewAPI(log, ts) + + vehicle, err := ensureVehicleEx(cc.VIN, api.Vehicles, func(v query.Vehicle) (string, error) { + return v.VIN, nil + }) + + if err == nil { + v.fromVehicle(vehicle.NickName, 0) + v.Provider = query.NewProvider(api, vehicle.VehicleID, cc.Cache) + } + + return v, err +} diff --git a/vehicle/ford/query/api.go b/vehicle/ford/query/api.go new file mode 100644 index 000000000..20b039e13 --- /dev/null +++ b/vehicle/ford/query/api.go @@ -0,0 +1,49 @@ +package query + +import ( + "fmt" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "golang.org/x/oauth2" +) + +const ApiURI = "https://api.vehicle.ford.com/fcon-query" + +// API is the Ford api client +type API struct { + *request.Helper +} + +// NewAPI creates a new api client +func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API { + v := &API{ + Helper: request.NewHelper(log), + } + + v.Client.Transport = &oauth2.Transport{ + Source: ts, + Base: v.Client.Transport, + } + + return v +} + +// Vehicles returns the list of user vehicles +func (v *API) Vehicles() ([]Vehicle, error) { + var res Vehicle + + uri := fmt.Sprintf("%s/v1/garage", ApiURI) + err := v.GetJSON(uri, &res) + + return []Vehicle{res}, err +} + +func (v *API) Telemetry(_ string) (Telemetry, error) { + var res Telemetry + + uri := fmt.Sprintf("%s/v1/telemetry", ApiURI) + err := v.GetJSON(uri, &res) + + return res, err +} diff --git a/vehicle/ford/query/oauth2.go b/vehicle/ford/query/oauth2.go new file mode 100644 index 000000000..3ca192970 --- /dev/null +++ b/vehicle/ford/query/oauth2.go @@ -0,0 +1,50 @@ +package query + +import ( + "context" + + "github.com/coreos/go-oidc/v3/oidc" + "github.com/evcc-io/evcc/plugin/auth" + "github.com/evcc-io/evcc/util" + "golang.org/x/oauth2" +) + +func init() { + auth.Register("ford-connect", func(other map[string]any) (oauth2.TokenSource, error) { + var cc struct { + ClientID string + ClientSecret string + RedirectURI string + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + oc := OAuth2Config(cc.ClientID, cc.ClientSecret, cc.RedirectURI) + + return NewOAuth(oc, "") + }) +} + +func OAuth2Config(id, secret, redirectUri string) *oauth2.Config { + return &oauth2.Config{ + ClientID: id, + ClientSecret: secret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://api.vehicle.ford.com/fcon-public/v1/auth/init", + TokenURL: "https://api.vehicle.ford.com/dah2vb2cprod.onmicrosoft.com/oauth2/v2.0/token?p=B2C_1A_FCON_AUTHORIZE", + AuthStyle: oauth2.AuthStyleInParams, + }, + RedirectURL: redirectUri, + Scopes: []string{ + oidc.ScopeOpenID, + oidc.ScopeOfflineAccess, + }, + } +} + +// NewOAuth creates FordConnect token source +func NewOAuth(oc *oauth2.Config, title string) (oauth2.TokenSource, error) { + return auth.NewOAuth(context.Background(), "Ford Connect", title, oc) +} diff --git a/vehicle/ford/query/provider.go b/vehicle/ford/query/provider.go new file mode 100644 index 000000000..9f6f0d5fd --- /dev/null +++ b/vehicle/ford/query/provider.go @@ -0,0 +1,75 @@ +package query + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" +) + +type Provider struct { + telemetryG func() (Telemetry, error) +} + +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + impl := &Provider{ + telemetryG: util.Cached(func() (Telemetry, error) { + return api.Telemetry(vin) + }, cache), + } + + return impl +} + +var _ api.Battery = (*Provider)(nil) + +// Soc implements the api.Battery interface +func (v *Provider) Soc() (float64, error) { + res, err := v.telemetryG() + return res.Metrics.XevBatteryStateOfCharge.Value, err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.telemetryG() + return int64(res.Metrics.XevBatteryRange.Value), err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status := api.StatusA + + res, err := v.telemetryG() + if err != nil { + return status, err + } + + switch res.Metrics.XevPlugChargerStatus.Value { + case "CONNECTED": + status = api.StatusB + case "CHARGING": + status = api.StatusC + } + + return status, nil +} + +var _ api.VehicleOdometer = (*Provider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *Provider) Odometer() (float64, error) { + res, err := v.telemetryG() + return res.Metrics.Odometer.Value, err +} + +var _ api.VehiclePosition = (*Provider)(nil) + +// Position implements the api.VehiclePosition interface +func (v *Provider) Position() (float64, float64, error) { + res, err := v.telemetryG() + return res.Metrics.Position.Value.Location.Lat, res.Metrics.Position.Value.Location.Lon, err +} diff --git a/vehicle/ford/query/types.go b/vehicle/ford/query/types.go new file mode 100644 index 000000000..b85261bb3 --- /dev/null +++ b/vehicle/ford/query/types.go @@ -0,0 +1,55 @@ +package query + +import ( + "time" +) + +type Vehicle struct { + VIN string + VehicleID string + Make string + ModelName string + ModelCode string + ModelYear string + Color string + NickName string + VehicleAuthorizationIndicator int + EngineType string +} + +type FloatValue struct { + UpdateTime time.Time + Value float64 +} + +type StringValue struct { + UpdateTime time.Time + Value string +} + +type Telemetry struct { + UpdateTime time.Time + VehicleId string + VIN string + Metrics struct { + DoorLockStatus any + DoorStatus any + IgnitionStatus StringValue + Position struct { + UpdatedTime time.Time + Value struct { + Location struct { + Lat, Lon, Alt float64 + } + } + } + Odometer FloatValue + XevBatteryRange FloatValue + XevBatteryStateOfCharge FloatValue + XevPlugChargerStatus StringValue + XevBatteryChargeDisplayStatus StringValue + XevChargeStationPowerType StringValue + XevBatteryChargerEnergyOutput FloatValue + XevBatteryTimeToFullCharge FloatValue + } +}