Ford: refactor (#1898)

This commit is contained in:
andig 2021-11-20 11:35:19 +01:00 • committed by GitHub
parent 5e586fcea4
commit 1194568cf2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 383 additions and 276 deletions

View file

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

102
vehicle/ford/api.go Normal file
View file

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

84
vehicle/ford/identity.go Normal file
View file

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

114
vehicle/ford/provider.go Normal file
View file

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

57
vehicle/ford/types.go Normal file
View file

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