diff --git a/README.md b/README.md index dac7f5015..7eb7df591 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/vehicle/audi.go b/vehicle/audi.go index 169a664f5..51646b2b0 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -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 } diff --git a/vehicle/ford.go b/vehicle/ford.go new file mode 100644 index 000000000..428a0e1f3 --- /dev/null +++ b/vehicle/ford.go @@ -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() +} diff --git a/vehicle/oidc/oidc.go b/vehicle/oidc/oidc.go new file mode 100644 index 000000000..a7787964a --- /dev/null +++ b/vehicle/oidc/oidc.go @@ -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"` +} diff --git a/vehicle/vw.go b/vehicle/vw.go index 7f1434bc0..5d0a4b26a 100644 --- a/vehicle/vw.go +++ b/vehicle/vw.go @@ -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 } diff --git a/vehicle/vw/api.go b/vehicle/vw/api.go index 81dd3c730..656af4e9d 100644 --- a/vehicle/vw/api.go +++ b/vehicle/vw/api.go @@ -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 { diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index 6e57a6c36..15f133c5c 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -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