diff --git a/vehicle/ford.go b/vehicle/ford.go index 3473768b4..ed5632e4f 100644 --- a/vehicle/ford.go +++ b/vehicle/ford.go @@ -1,40 +1,28 @@ package vehicle import ( - "errors" "fmt" - "net/http" - "net/url" "strings" "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/provider" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/oauth" - "github.com/evcc-io/evcc/util/request" - "golang.org/x/oauth2" + "github.com/evcc-io/evcc/vehicle/ford" ) -const ( - fordAuth = "https://fcis.ice.ibmcloud.com" - fordAPI = "https://usapi.cv.ford.com" - fordVehicleList = "https://api.mps.ford.com/api/users/vehicles" - fordStatusExpiry = 5 * time.Minute // if returned status value is older, evcc will init refresh - fordRefreshTimeout = time.Minute // timeout to get status after refresh - fordTimeFormat = "01-02-2006 15:04:05" // time format used by Ford API, time is in UTC -) +// https://github.com/d4v3y0rk/ffpass-module // Ford is an api.Vehicle implementation for Ford cars type Ford struct { *embed - *request.Helper - log *util.Logger - user, password, vin string - tokenSource oauth2.TokenSource - statusG func() (interface{}, error) - refreshId string - refreshTime time.Time + // *request.Helper + // log *util.Logger + // vin string + // tokenSource oauth2.TokenSource + // statusG func() (interface{}, error) + // refreshId string + // refreshTime time.Time + *ford.Provider } func init() { @@ -46,9 +34,11 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` User, Password, VIN string + Expiry time.Duration Cache time.Duration }{ - Cache: interval, + Expiry: expiry, + Cache: interval, } if err := util.DecodeOther(other, &cc); err != nil { @@ -59,268 +49,28 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, api.ErrMissingCredentials } - log := util.NewLogger("ford").Redact(cc.User, cc.Password, cc.VIN) - v := &Ford{ - embed: &cc.embed, - Helper: request.NewHelper(log), - log: log, - user: cc.User, - password: cc.Password, - vin: strings.ToUpper(cc.VIN), + embed: &cc.embed, } - token, err := v.login() - if err == nil { - v.tokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v) + log := util.NewLogger("ford").Redact(cc.User, cc.Password, cc.VIN) + identity := ford.NewIdentity(log, cc.User, cc.Password) + + err := identity.Login() + if err != nil { + return nil, fmt.Errorf("login failed: %w", err) } - v.statusG = provider.NewCached(func() (interface{}, error) { - return v.status() - }, cc.Cache).InterfaceGetter() + api := ford.NewAPI(log, identity) - if err == nil && cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) + if cc.VIN == "" { + cc.VIN, err = findVehicle(api.Vehicles()) if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + log.DEBUG.Printf("found vehicle: %v", cc.VIN) } } + v.Provider = ford.NewProvider(api, strings.ToUpper(cc.VIN), cc.Expiry, cc.Cache) + return v, err } - -// login authenticates with username/password to get new token -func (v *Ford) login() (oauth.Token, error) { - data := url.Values{ - "client_id": []string{"9fb503e0-715b-47e8-adfd-ad4b7770f73b"}, - "grant_type": []string{"password"}, - "username": []string{v.user}, - "password": []string{v.password}, - } - - uri := fordAuth + "/v1.0/endpoint/default/token" - req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) - - var res oauth.Token - if err == nil { - err = v.DoJSON(req, &res) - } - - return res, err -} - -// Refresh implements oauth.TokenRefresher -func (v *Ford) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { - data := url.Values{ - "client_id": []string{"9fb503e0-715b-47e8-adfd-ad4b7770f73b"}, - "grant_type": []string{"refresh_token"}, - "refresh_token": []string{token.RefreshToken}, - } - - uri := fordAuth + "/v1.0/endpoint/default/token" - req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) - - var res oauth.Token - if err == nil { - err = v.DoJSON(req, &res) - } - - if err != nil { - res, err = v.login() - } - - return (*oauth2.Token)(&res), err -} - -// request is a helper to send API requests, sets header the Ford API expects -func (v *Ford) request(method, uri string) (*http.Request, error) { - token, err := v.tokenSource.Token() - - var req *http.Request - if err == nil { - req, err = request.New(method, uri, nil, map[string]string{ - "Content-type": "application/json", - "Application-Id": "71A3AD0A-CF46-4CCF-B473-FC7FE5BC4592", - "Auth-Token": token.AccessToken, - }) - } - - return req, err -} - -// fordVehicleStatus holds the relevant data extracted from JSON that the server sends -// on vehicle status request -type fordVehicleStatus struct { - VehicleStatus struct { - BatteryFillLevel struct { - Value float64 - Timestamp string - } - ElVehDTE struct { - Value float64 - Timestamp string - } - ChargingStatus struct { - Value string - Timestamp string - } - PlugStatus struct { - Value int - Timestamp string - } - LastRefresh string - } - Status int -} - -// vehicles returns the list of user vehicles -func (v *Ford) vehicles() ([]string, error) { - var resp struct { - Vehicles struct { - Values []struct { - VIN string - } `json:"$values"` - } - } - - req, err := v.request(http.MethodGet, fordVehicleList) - if err == nil { - err = v.DoJSON(req, &resp) - } - - var vehicles []string - if err == nil { - for _, v := range resp.Vehicles.Values { - vehicles = append(vehicles, v.VIN) - } - } - - return vehicles, err -} - -// status performs a /status request to the Ford API and triggers a refresh if -// the received status is too old -func (v *Ford) status() (res fordVehicleStatus, err error) { - // follow up requested refresh - if v.refreshId != "" { - return v.refreshResult() - } - - // otherwise start normal workflow - uri := fmt.Sprintf("%s/api/vehicles/v3/%s/status", fordAPI, v.vin) - req, err := v.request(http.MethodGet, uri) - if err == nil { - err = v.DoJSON(req, &res) - } - - if err == nil { - var lastUpdate time.Time - lastUpdate, err = time.Parse(fordTimeFormat, res.VehicleStatus.LastRefresh) - - if elapsed := time.Since(lastUpdate); err == nil && elapsed > fordStatusExpiry { - v.log.DEBUG.Printf("vehicle status is outdated (age %v > %v), requesting refresh", elapsed, fordStatusExpiry) - - if err = v.refreshRequest(); err == nil { - err = api.ErrMustRetry - } - } - } - - return res, err -} - -// refreshResult triggers an update if not already in progress, otherwise gets result -func (v *Ford) refreshResult() (res fordVehicleStatus, err error) { - uri := fmt.Sprintf("%s/api/vehicles/v3/%s/statusrefresh/%s", fordAPI, v.vin, v.refreshId) - - var req *http.Request - if req, err = v.request(http.MethodGet, uri); err == nil { - err = v.DoJSON(req, &res) - } - - // update successful and completed - if err == nil && res.Status == 200 { - v.refreshId = "" - return res, nil - } - - // update still in progress, keep retrying - if time.Since(v.refreshTime) < fordRefreshTimeout { - return res, api.ErrMustRetry - } - - // give up - v.refreshId = "" - if err == nil { - err = api.ErrTimeout - } - - return res, err -} - -// refreshRequest requests status refresh tracked by commandId -func (v *Ford) refreshRequest() error { - var resp struct { - CommandId string - } - - uri := fmt.Sprintf("%s/api/vehicles/v2/%s/status", fordAPI, v.vin) - req, err := v.request(http.MethodPut, uri) - if err == nil { - err = v.DoJSON(req, &resp) - } - - if err == nil { - v.refreshId = resp.CommandId - v.refreshTime = time.Now() - - if resp.CommandId == "" { - err = errors.New("refresh failed") - } - } - - return err -} - -var _ api.Battery = (*Ford)(nil) - -// SoC implements the api.Battery interface -func (v *Ford) SoC() (float64, error) { - res, err := v.statusG() - if res, ok := res.(fordVehicleStatus); err == nil && ok { - return res.VehicleStatus.BatteryFillLevel.Value, nil - } - - return 0, err -} - -var _ api.VehicleRange = (*Ford)(nil) - -// Range implements the api.VehicleRange interface -func (v *Ford) Range() (int64, error) { - res, err := v.statusG() - if res, ok := res.(fordVehicleStatus); err == nil && ok { - return int64(res.VehicleStatus.ElVehDTE.Value), nil - } - - return 0, err -} - -var _ api.ChargeState = (*Ford)(nil) - -// Status implements the api.ChargeState interface -func (v *Ford) Status() (api.ChargeStatus, error) { - status := api.StatusA // disconnected - - res, err := v.statusG() - if res, ok := res.(fordVehicleStatus); err == nil && ok { - if res.VehicleStatus.PlugStatus.Value == 1 { - status = api.StatusB // connected, not charging - } - if res.VehicleStatus.ChargingStatus.Value == "ChargingAC" { - status = api.StatusC // charging - } - } - - return status, err -} diff --git a/vehicle/ford/api.go b/vehicle/ford/api.go new file mode 100644 index 000000000..8fbb979fd --- /dev/null +++ b/vehicle/ford/api.go @@ -0,0 +1,102 @@ +package ford + +import ( + "errors" + "fmt" + "net/http" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/transport" + "golang.org/x/oauth2" +) + +const ( + ApiURI = "https://usapi.cv.ford.com" + VehicleListURI = "https://api.mps.ford.com/api/users/vehicles" +) + +// API is the VW 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 = &transport.Decorator{ + Decorator: func(req *http.Request) error { + token, err := ts.Token() + if err == nil { + for k, v := range map[string]string{ + "Content-type": "application/json", + "Application-Id": "71A3AD0A-CF46-4CCF-B473-FC7FE5BC4592", + "Auth-Token": token.AccessToken, + } { + req.Header.Set(k, v) + } + } + return err + }, + Base: v.Client.Transport, + } + + return v +} + +// Vehicles returns the list of user vehicles +func (v *API) Vehicles() ([]string, error) { + var resp VehiclesResponse + var vehicles []string + + err := v.GetJSON(VehicleListURI, &resp) + if err == nil { + for _, v := range resp.Vehicles.Values { + vehicles = append(vehicles, v.VIN) + } + } + + return vehicles, err +} + +// Status performs a /status request +func (v *API) Status(vin string) (StatusResponse, error) { + uri := fmt.Sprintf("%s/api/vehicles/v3/%s/status", ApiURI, vin) + + var res StatusResponse + err := v.GetJSON(uri, &res) + + return res, err +} + +// RefreshResult retrieves a refresh result using /statusrefresh +func (v *API) RefreshResult(vin, refreshId string) (StatusResponse, error) { + var res StatusResponse + + uri := fmt.Sprintf("%s/api/vehicles/v3/%s/statusrefresh/%s", ApiURI, vin, refreshId) + err := v.GetJSON(uri, &res) + + return res, err +} + +// RefreshRequest requests status refresh tracked by commandId +func (v *API) RefreshRequest(vin string) (string, error) { + var resp struct { + CommandId string + } + + uri := fmt.Sprintf("%s/api/vehicles/v2/%s/status", ApiURI, vin) + req, err := http.NewRequest(http.MethodPut, uri, nil) + if err == nil { + err = v.DoJSON(req, &resp) + } + + if err == nil && resp.CommandId == "" { + err = errors.New("refresh failed") + } + + return resp.CommandId, err +} diff --git a/vehicle/ford/identity.go b/vehicle/ford/identity.go new file mode 100644 index 000000000..77d948028 --- /dev/null +++ b/vehicle/ford/identity.go @@ -0,0 +1,84 @@ +package ford + +import ( + "net/http" + "net/url" + "strings" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" + "github.com/evcc-io/evcc/util/request" + "golang.org/x/oauth2" +) + +const ( + AuthURI = "https://sso.ci.ford.ca" // fcis.ice.ibmcloud.com + ClientID = "9fb503e0-715b-47e8-adfd-ad4b7770f73b" +) + +type Identity struct { + *request.Helper + user, password string + oauth2.TokenSource +} + +// NewIdentity creates Fiat identity +func NewIdentity(log *util.Logger, user, password string) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + user: user, + password: password, + } +} + +// Login authenticates with username/password to get new aws credentials +func (v *Identity) Login() error { + token, err := v.login() + if err == nil { + v.TokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v) + } + return err +} + +// login authenticates with username/password to get new token +func (v *Identity) login() (oauth.Token, error) { + data := url.Values{ + "client_id": []string{ClientID}, + "grant_type": []string{"password"}, + "username": []string{v.user}, + "password": []string{v.password}, + } + + uri := AuthURI + "/v1.0/endpoint/default/token" + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) + + var res oauth.Token + if err == nil { + err = v.DoJSON(req, &res) + } + + return res, err +} + +// Refresh implements oauth.TokenRefresher +func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "client_id": []string{ClientID}, + "grant_type": []string{"refresh_token"}, + "refresh_token": []string{token.RefreshToken}, + } + + uri := AuthURI + "/v1.0/endpoint/default/token" + req, err := request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), request.URLEncoding) + + var res oauth.Token + if err == nil { + err = v.DoJSON(req, &res) + } + + if err != nil { + res, err = v.login() + } + + return (*oauth2.Token)(&res), err +} diff --git a/vehicle/ford/provider.go b/vehicle/ford/provider.go new file mode 100644 index 000000000..30e4e9aaf --- /dev/null +++ b/vehicle/ford/provider.go @@ -0,0 +1,114 @@ +package ford + +import ( + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/provider" +) + +const refreshTimeout = time.Minute + +type Provider struct { + statusG func() (interface{}, error) + expiry time.Duration + refreshTime time.Time + refreshId string +} + +func NewProvider(api *API, vin string, expiry, cache time.Duration) *Provider { + impl := &Provider{ + expiry: expiry, + } + + impl.statusG = provider.NewCached(func() (interface{}, error) { + return impl.status( + func() (StatusResponse, error) { return api.Status(vin) }, + func(id string) (StatusResponse, error) { return api.RefreshResult(vin, id) }, + func() (string, error) { return api.RefreshRequest(vin) }, + ) + }, cache).InterfaceGetter() + + return impl +} + +func (v *Provider) status( + statusG func() (StatusResponse, error), + refreshG func(id string) (StatusResponse, error), + refreshRequest func() (string, error), +) (StatusResponse, error) { + if v.refreshId != "" { + res, err := refreshG(v.refreshId) + + // update successful and completed + if err == nil { + v.refreshId = "" + return res, nil + } + + // update still in progress, keep retrying + if time.Since(v.refreshTime) < refreshTimeout { + return res, api.ErrMustRetry + } + + // give up + v.refreshId = "" + return res, api.ErrTimeout + } + + res, err := statusG() + + if err == nil { + if time.Since(res.VehicleStatus.LastRefresh.Time) > v.expiry { + if v.refreshId, err = refreshRequest(); err == nil { + v.refreshTime = time.Now() + err = api.ErrMustRetry + } + } + } + + return res, err +} + +var _ api.Battery = (*Provider)(nil) + +// SoC implements the api.Battery interface +func (v *Provider) SoC() (float64, error) { + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + return res.VehicleStatus.BatteryFillLevel.Value, nil + } + + return 0, err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + return int64(res.VehicleStatus.ElVehDTE.Value), nil + } + + return 0, err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + if res.VehicleStatus.PlugStatus.Value == 1 { + status = api.StatusB // connected, not charging + } + if res.VehicleStatus.ChargingStatus.Value == "ChargingAC" { + status = api.StatusC // charging + } + } + + return status, err +} diff --git a/vehicle/ford/types.go b/vehicle/ford/types.go new file mode 100644 index 000000000..a445e9b37 --- /dev/null +++ b/vehicle/ford/types.go @@ -0,0 +1,57 @@ +package ford + +import ( + "strings" + "time" +) + +type VehiclesResponse struct { + Vehicles struct { + Values []struct { + VIN string + } `json:"$values"` + } +} + +// StatusResponse is the response to the vehicle status request +type StatusResponse struct { + VehicleStatus struct { + BatteryFillLevel struct { + Value float64 + Timestamp Timestamp + } + ElVehDTE struct { + Value float64 + Timestamp Timestamp + } + ChargingStatus struct { + Value string + Timestamp Timestamp + } + PlugStatus struct { + Value int + Timestamp Timestamp + } + LastRefresh Timestamp + } + Status int +} + +const TimeFormat = "01-02-2006 15:04:05" // time format used by Ford API, time is in UTC + +// Timestamp implements JSON unmarshal +type Timestamp struct { + time.Time +} + +// UnmarshalJSON decodes string timestamp into time.Time +func (ct *Timestamp) UnmarshalJSON(data []byte) error { + s := strings.Trim(string(data), "\"") + + t, err := time.Parse(TimeFormat, s) + if err == nil { + (*ct).Time = t + } + + return err +}