Add Ford API support (#388)
This commit is contained in:
parent
89a717348d
commit
64a6b3a44f
7 changed files with 203 additions and 26 deletions
|
|
@ -18,7 +18,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
|
|||
- simple and clean user interface
|
||||
- multiple [chargers](#charger): Wallbe, Phoenix (includes ESL Walli), go-eCharger, NRGkick (direct Bluetooth or via Connect device), SimpleEVSE, EVSEWifi, KEBA/BMW, openWB, Mobile Charger Connect, and any other charger using scripting
|
||||
- multiple [meters](#meter): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Home Manager 2.0 and SMA Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC), Tesla PowerWall
|
||||
- different [vehicles](#vehicle) to show battery status: Audi (eTron), BMW (i3), Tesla, Nissan (Leaf), Renault ZE (ZOE, ...), and any other vehicle using scripting
|
||||
- different [vehicles](#vehicle) to show battery status: Audi (eTron), BMW (i3), Ford (Kuga, Mustang), Tesla, Nissan (Leaf), Renault ZE (ZOE...), VW (eUp, ID.3, ID.4) and any other vehicle using scripting
|
||||
- [plugins](#plugins) for integrating with hardware devices and home automation: Modbus (meters and grid inverters), MQTT and shell scripts
|
||||
- status notifications using [Telegram](https://telegram.org) and [PushOver](https://pushover.net)
|
||||
- logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/)
|
||||
|
|
@ -212,12 +212,14 @@ Available vehicle implementations are:
|
|||
|
||||
- `audi`: Audi (eTron, Q55)
|
||||
- `bmw`: BMW (i3)
|
||||
- `bmw`: Ford (Kuga, Mustang)
|
||||
- `kia`: Kia (Bluelink vehicles like Soul 2019)
|
||||
- `hyundai`: Hyundai (Bluelink vehicles like Kona or Ioniq)
|
||||
- `nissan`: Nissan (Leaf)
|
||||
- `tesla`: Tesla (any model)
|
||||
- `renault`: Renault (Zoe, Kangoo ZE)
|
||||
- `porsche`: Porsche (Taycan)
|
||||
- `vw`: VW (eUp, ID.3, ID.4)
|
||||
- `default`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle
|
||||
|
||||
Configuration examples are documented at [andig/evcc-config#vehicles](https://github.com/andig/evcc-config#vehicles)
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/andig/evcc/api"
|
||||
"github.com/andig/evcc/util"
|
||||
"github.com/andig/evcc/util/request"
|
||||
"github.com/andig/evcc/vehicle/oidc"
|
||||
"github.com/andig/evcc/vehicle/vw"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
|
@ -22,7 +23,7 @@ type Audi struct {
|
|||
*embed
|
||||
*request.Helper
|
||||
user, password string
|
||||
tokens vw.Tokens
|
||||
tokens oidc.Tokens
|
||||
api *vw.API
|
||||
*vw.Implementation // provides the api implementations
|
||||
}
|
||||
|
|
|
|||
168
vehicle/ford.go
Normal file
168
vehicle/ford.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package vehicle
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andig/evcc/api"
|
||||
"github.com/andig/evcc/provider"
|
||||
"github.com/andig/evcc/util"
|
||||
"github.com/andig/evcc/util/request"
|
||||
"github.com/andig/evcc/vehicle/oidc"
|
||||
)
|
||||
|
||||
const (
|
||||
fordAuth = "https://fcis.ice.ibmcloud.com"
|
||||
fordAPI = "https://usapi.cv.ford.com"
|
||||
)
|
||||
|
||||
// Ford is an api.Vehicle implementation for Ford cars
|
||||
type Ford struct {
|
||||
*embed
|
||||
*request.Helper
|
||||
user, password, vin string
|
||||
tokens oidc.Tokens
|
||||
chargeStateG func() (float64, error)
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Add("ford", NewFordFromConfig)
|
||||
}
|
||||
|
||||
// NewFordFromConfig creates a new vehicle
|
||||
func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
|
||||
cc := struct {
|
||||
Title string
|
||||
Capacity int64
|
||||
User, Password, VIN string
|
||||
Cache time.Duration
|
||||
}{}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("ford")
|
||||
|
||||
v := &Ford{
|
||||
embed: &embed{cc.Title, cc.Capacity},
|
||||
Helper: request.NewHelper(log),
|
||||
user: cc.User,
|
||||
password: cc.Password,
|
||||
vin: strings.ToUpper(cc.VIN),
|
||||
}
|
||||
|
||||
v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter()
|
||||
|
||||
var err error
|
||||
if cc.VIN == "" {
|
||||
v.vin, err = findVehicle(v.vehicles())
|
||||
if err == nil {
|
||||
log.DEBUG.Printf("found vehicle: %v", v.vin)
|
||||
}
|
||||
}
|
||||
|
||||
return v, nil
|
||||
}
|
||||
|
||||
func (v *Ford) login(user, password string) error {
|
||||
data := url.Values{
|
||||
"client_id": []string{"9fb503e0-715b-47e8-adfd-ad4b7770f73b"},
|
||||
"grant_type": []string{"password"},
|
||||
"username": []string{user},
|
||||
"password": []string{password},
|
||||
}
|
||||
|
||||
uri := fordAuth + "/v1.0/endpoint/default/token"
|
||||
req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tokens oidc.Tokens
|
||||
if err = v.DoJSON(req, &tokens); err == nil {
|
||||
tokens.Valid = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second)
|
||||
v.tokens = tokens
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func (v *Ford) request(uri string) (*http.Request, error) {
|
||||
if v.tokens.AccessToken == "" || time.Since(v.tokens.Valid) > 0 {
|
||||
if err := v.login(v.user, v.password); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
|
||||
"Content-type": "application/json",
|
||||
"Application-Id": "71A3AD0A-CF46-4CCF-B473-FC7FE5BC4592",
|
||||
"Auth-Token": v.tokens.AccessToken,
|
||||
})
|
||||
|
||||
return req, err
|
||||
}
|
||||
|
||||
// vehicles implements returns the list of user vehicles
|
||||
func (v *Ford) vehicles() ([]string, error) {
|
||||
var resp struct {
|
||||
Vehicles struct {
|
||||
Values []struct {
|
||||
VIN string
|
||||
} `json:"$values"`
|
||||
}
|
||||
}
|
||||
|
||||
var vehicles []string
|
||||
|
||||
req, err := v.request("https://api.mps.ford.com/api/users/vehicles")
|
||||
if err == nil {
|
||||
err = v.DoJSON(req, &resp)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
for _, v := range resp.Vehicles.Values {
|
||||
vehicles = append(vehicles, v.VIN)
|
||||
}
|
||||
}
|
||||
|
||||
return vehicles, err
|
||||
}
|
||||
|
||||
// chargeState implements the Vehicle.ChargeState interface
|
||||
func (v *Ford) chargeState() (float64, error) {
|
||||
var resp struct {
|
||||
VehicleStatus struct {
|
||||
Battery struct {
|
||||
BatteryStatusActual struct {
|
||||
Value int
|
||||
}
|
||||
}
|
||||
BatteryFillLevel struct {
|
||||
Value float64
|
||||
}
|
||||
}
|
||||
|
||||
// "chargingStatus": { "value": "EvseNotDetected", ...
|
||||
// "plugStatus": { "value": 1, ...
|
||||
// "batteryChargeStatus": null, ...
|
||||
}
|
||||
|
||||
uri := fmt.Sprintf("%s/api/vehicles/v4/%s/status", fordAPI, v.vin)
|
||||
|
||||
req, err := v.request(uri)
|
||||
if err == nil {
|
||||
err = v.DoJSON(req, &resp)
|
||||
}
|
||||
|
||||
return resp.VehicleStatus.BatteryFillLevel.Value, err
|
||||
}
|
||||
|
||||
// ChargeState implements the Vehicle.ChargeState interface
|
||||
func (v *Ford) ChargeState() (float64, error) {
|
||||
return v.chargeStateG()
|
||||
}
|
||||
24
vehicle/oidc/oidc.go
Normal file
24
vehicle/oidc/oidc.go
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
package oidc
|
||||
|
||||
import "time"
|
||||
|
||||
// Tokens is an OAuth tokens response
|
||||
type Tokens struct {
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
Valid time.Time // helper to store validity timestamp
|
||||
}
|
||||
|
||||
// OIDCResponse is the well-known OIDC provider response
|
||||
// https://{oauth-provider-hostname}/.well-known/openid-configuration
|
||||
type OIDCResponse struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
UserInfoURL string `json:"userinfo_endpoint"`
|
||||
Algorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/andig/evcc/api"
|
||||
"github.com/andig/evcc/util"
|
||||
"github.com/andig/evcc/util/request"
|
||||
"github.com/andig/evcc/vehicle/oidc"
|
||||
"github.com/andig/evcc/vehicle/vw"
|
||||
"golang.org/x/net/publicsuffix"
|
||||
)
|
||||
|
|
@ -23,7 +24,7 @@ type VW struct {
|
|||
*request.Helper
|
||||
user, password string
|
||||
clientID string
|
||||
tokens vw.Tokens
|
||||
tokens oidc.Tokens
|
||||
api *vw.API
|
||||
*vw.Implementation // provides the api implementations
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/andig/evcc/util/request"
|
||||
"github.com/andig/evcc/vehicle/oidc"
|
||||
)
|
||||
|
||||
// BaseURI is the VW api base URI
|
||||
|
|
@ -75,7 +76,7 @@ func Temp2Float(val int) float64 {
|
|||
// API is the VW api client
|
||||
type API struct {
|
||||
*request.Helper
|
||||
tokens *Tokens
|
||||
tokens *oidc.Tokens
|
||||
authFlow func() error
|
||||
refreshHeaders func() map[string]string
|
||||
brand, country string
|
||||
|
|
@ -84,7 +85,7 @@ type API struct {
|
|||
|
||||
// NewAPI creates a new api client
|
||||
func NewAPI(
|
||||
helper *request.Helper, tokens *Tokens,
|
||||
helper *request.Helper, tokens *oidc.Tokens,
|
||||
authFlow func() error, refreshHeaders func() map[string]string,
|
||||
vin, brand, country string,
|
||||
) *API {
|
||||
|
|
@ -114,7 +115,7 @@ func (v *API) refreshToken() error {
|
|||
req, err := request.New(http.MethodPost, OauthTokenURI, strings.NewReader(data.Encode()), v.refreshHeaders())
|
||||
|
||||
if err == nil {
|
||||
var tokens Tokens
|
||||
var tokens oidc.Tokens
|
||||
|
||||
err = v.DoJSON(req, &tokens)
|
||||
if err == nil {
|
||||
|
|
|
|||
|
|
@ -17,26 +17,6 @@ const (
|
|||
OauthTokenURI = "https://mbboauth-1d.prd.ece.vwg-connect.com/mbbcoauth/mobile/oauth2/v1/token"
|
||||
)
|
||||
|
||||
// Tokens is an OAuth tokens response
|
||||
type Tokens struct {
|
||||
TokenType string `json:"token_type"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
IDToken string `json:"id_token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
}
|
||||
|
||||
// OIDCResponse is the well-known OIDC provider response
|
||||
// https://{oauth-provider-hostname}/.well-known/openid-configuration
|
||||
type OIDCResponse struct {
|
||||
Issuer string `json:"issuer"`
|
||||
AuthURL string `json:"authorization_endpoint"`
|
||||
TokenURL string `json:"token_endpoint"`
|
||||
JWKSURL string `json:"jwks_uri"`
|
||||
UserInfoURL string `json:"userinfo_endpoint"`
|
||||
Algorithms []string `json:"id_token_signing_alg_values_supported"`
|
||||
}
|
||||
|
||||
// Identity provides the identity.vwgroup.io login
|
||||
type Identity struct {
|
||||
*http.Client
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue