Add FordConnect Query (#25462)

This commit is contained in:
andig 2025-11-24 17:24:00 +01:00 • committed by GitHub
parent e9223a69be
commit 10ac59034f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 347 additions and 5 deletions

View file

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

View file

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

View file

@ -1,6 +1,6 @@
template: ford-connect
products:
- brand: Ford
- brand: Ford (Legacy FordConnect)
params:
- preset: vehicle-common
- name: clientid

View file

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

49
vehicle/ford/query/api.go Normal file
View file

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

View file

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

View file

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

View file

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