Add Ford API support (#388)

This commit is contained in:
andig 2020-10-27 16:38:35 +01:00 • committed by GitHub
parent 89a717348d
commit 64a6b3a44f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 203 additions and 26 deletions

View file

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

View file

@ -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
View 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
View 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"`
}

View file

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

View file

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

View file

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