Add FordConnect Query (#25462)
This commit is contained in:
parent
e9223a69be
commit
10ac59034f
8 changed files with 347 additions and 5 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
43
templates/definition/vehicle/ford-connect-query.yaml
Normal file
43
templates/definition/vehicle/ford-connect-query.yaml
Normal 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 }}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
template: ford-connect
|
||||
products:
|
||||
- brand: Ford
|
||||
- brand: Ford (Legacy FordConnect)
|
||||
params:
|
||||
- preset: vehicle-common
|
||||
- name: clientid
|
||||
|
|
|
|||
66
vehicle/ford-connect-query.go
Normal file
66
vehicle/ford-connect-query.go
Normal 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
49
vehicle/ford/query/api.go
Normal 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
|
||||
}
|
||||
50
vehicle/ford/query/oauth2.go
Normal file
50
vehicle/ford/query/oauth2.go
Normal 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)
|
||||
}
|
||||
75
vehicle/ford/query/provider.go
Normal file
75
vehicle/ford/query/provider.go
Normal 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
|
||||
}
|
||||
55
vehicle/ford/query/types.go
Normal file
55
vehicle/ford/query/types.go
Normal 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
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue