Ford: add status refresh, range and token refresh (#860)

This commit is contained in:
MrJayC 2021-04-18 10:57:38 +02:00 • committed by GitHub
parent 8562478f4a
commit 21cc2f4d90
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23

View file

@ -12,20 +12,26 @@ import (
"github.com/andig/evcc/util"
"github.com/andig/evcc/util/oauth"
"github.com/andig/evcc/util/request"
"golang.org/x/oauth2"
)
const (
fordAuth = "https://fcis.ice.ibmcloud.com"
fordAPI = "https://usapi.cv.ford.com"
fordAuth = "https://fcis.ice.ibmcloud.com"
fordAPI = "https://usapi.cv.ford.com"
fordVehicleList = "https://api.mps.ford.com/api/users/vehicles"
fordOutdatedAfter = 5 * time.Minute // if returned status value is older, evcc will init refresh
fordMaxRefreshTrials = 20 // max trials to get status after refresh, poll interval is 1.5s, i.e. timeout = maxTrials * 1.5s
fordTimeFormat = "01-02-2006 15:04:05" // time format used by Ford API, time is in UTC
)
// Ford is an api.Vehicle implementation for Ford cars
type Ford struct {
*embed
*request.Helper
log *util.Logger
user, password, vin string
tokens oauth.Token
chargeStateG func() (float64, error)
tokenSource oauth2.TokenSource
statusG func() (interface{}, error)
}
func init() {
@ -47,19 +53,30 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
return nil, err
}
if cc.User == "" || cc.Password == "" {
return nil, fmt.Errorf("missing credentials")
}
log := util.NewLogger("ford")
v := &Ford{
embed: &embed{cc.Title, cc.Capacity},
Helper: request.NewHelper(log),
log: log,
user: cc.User,
password: cc.Password,
vin: strings.ToUpper(cc.VIN),
}
v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter()
token, err := v.login()
if err == nil {
v.tokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v)
}
v.statusG = provider.NewCached(func() (interface{}, error) {
return v.vehicleStatus()
}, cc.Cache).InterfaceGetter()
var err error
if cc.VIN == "" {
v.vin, err = findVehicle(v.vehicles())
if err == nil {
@ -70,45 +87,93 @@ func NewFordFromConfig(other map[string]interface{}) (api.Vehicle, error) {
return v, err
}
func (v *Ford) login(user, password string) error {
// 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{user},
"password": []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)
if err != nil {
return err
var res oauth.Token
if err == nil {
err = v.DoJSON(req, &res)
}
var tokens oauth.Token
if err = v.DoJSON(req, &tokens); err == nil {
v.tokens = tokens
}
return err
return res, err
}
func (v *Ford) request(uri string) (*http.Request, error) {
if v.tokens.AccessToken == "" || time.Until(v.tokens.Expiry) < time.Minute {
if err := v.login(v.user, v.password); err != nil {
return nil, err
}
var _ oauth.TokenRefresher = (*Ford)(nil)
// Refresh implements the oauth.TokenRefresher interface
func (v *Ford) Refresh(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},
}
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,
})
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
}
// vehicles implements returns the list of user vehicles
// 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 {
@ -120,7 +185,7 @@ func (v *Ford) vehicles() ([]string, error) {
var vehicles []string
req, err := v.request("https://api.mps.ford.com/api/users/vehicles")
req, err := v.request(http.MethodGet, fordVehicleList)
if err == nil {
err = v.DoJSON(req, &resp)
}
@ -134,36 +199,112 @@ func (v *Ford) vehicles() ([]string, error) {
return vehicles, err
}
// chargeState implements the api.Vehicle interface
func (v *Ford) chargeState() (float64, error) {
var resp struct {
VehicleStatus struct {
Battery struct {
BatteryStatusActual struct {
Value int
}
}
BatteryFillLevel struct {
Value float64
}
}
// vehicleStatus performs a /status request to the Ford API and triggers a refresh if
// the received status is too old
func (v *Ford) vehicleStatus() (fordVehicleStatus, error) {
uri := fmt.Sprintf("%s/api/vehicles/v3/%s/status", fordAPI, v.vin)
// "chargingStatus": { "value": "EvseNotDetected", ...
// "plugStatus": { "value": 1, ...
// "batteryChargeStatus": null, ...
var res fordVehicleStatus
req, err := v.request(http.MethodGet, uri)
if err == nil {
err = v.DoJSON(req, &res)
}
uri := fmt.Sprintf("%s/api/vehicles/v4/%s/status", fordAPI, v.vin)
if err == nil {
var lastUpdate time.Time
lastUpdate, err = time.Parse(fordTimeFormat, res.VehicleStatus.LastRefresh)
req, err := v.request(uri)
if elapsed := time.Since(lastUpdate); err == nil && elapsed > fordOutdatedAfter {
v.log.DEBUG.Printf("vehicle status is outdated (age %v > %v), requesting refresh", elapsed, fordOutdatedAfter)
res, err = v.vehicleStatusRefresh()
}
}
return res, err
}
// vehicleStatusRefresh triggers an update and waits until refreshed data is available or request times out
func (v *Ford) vehicleStatusRefresh() (fordVehicleStatus, error) {
commandId, err := v.requestRefresh()
var res fordVehicleStatus
if err == nil {
uri := fmt.Sprintf("%s/api/vehicles/v3/%s/statusrefresh/%s", fordAPI, v.vin, commandId)
// if status attribute in JSON response is 200, update is complete, otherwise server is still
// waiting for vehicle and the request needs to be repeated
for counter := 0; counter < fordMaxRefreshTrials && res.Status != 200 && err == nil; counter++ {
var req *http.Request
if req, err = v.request(http.MethodGet, uri); err == nil {
err = v.DoJSON(req, &res)
}
time.Sleep(1500 * time.Millisecond)
}
if err == nil && res.Status != 200 {
err = fmt.Errorf("refresh failed: status %d", res.Status)
}
}
return res, err
}
// requestRefresh requests Ford API to poll vehicle for updated data
// returns commandId to track the request and get the data after server received update from vehicle
func (v *Ford) requestRefresh() (string, 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)
}
return resp.VehicleStatus.BatteryFillLevel.Value, err
return resp.CommandId, err
}
// SoC implements the api.Vehicle interface
var _ api.Battery = (*Ford)(nil)
// SoC implements the api.Battery interface
func (v *Ford) SoC() (float64, error) {
return v.chargeStateG()
res, err := v.statusG()
if res, ok := res.(fordVehicleStatus); err == nil && ok {
return float64(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
}