From 169d2ca11d34eef4e65027ad78d0d7e4ecd55b36 Mon Sep 17 00:00:00 2001 From: andig Date: Fri, 14 May 2021 22:29:51 +0200 Subject: [PATCH] Nissan: async soc refresh (#1011) --- internal/vehicle/kamereon/api.go | 32 ++-- internal/vehicle/nissan.go | 264 ++-------------------------- internal/vehicle/nissan/api.go | 131 ++++++++++++++ internal/vehicle/nissan/identity.go | 149 ++++++++++++++++ internal/vehicle/nissan/types.go | 52 ++++++ 5 files changed, 365 insertions(+), 263 deletions(-) create mode 100644 internal/vehicle/nissan/api.go create mode 100644 internal/vehicle/nissan/identity.go create mode 100644 internal/vehicle/nissan/types.go diff --git a/internal/vehicle/kamereon/api.go b/internal/vehicle/kamereon/api.go index 85362b877..978e53987 100644 --- a/internal/vehicle/kamereon/api.go +++ b/internal/vehicle/kamereon/api.go @@ -4,6 +4,7 @@ import ( "time" "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" ) // Response structure for kamereon api @@ -12,6 +13,7 @@ type Response struct { } type data struct { + Type, ID string // battery refresh Attributes attributes `json:"attributes"` } @@ -30,20 +32,24 @@ type attributes struct { RemainingTime *int `json:"chargingRemainingTime"` } -// API is a kamereon API implementation -type API struct { +// Provider is a kamereon provider +type Provider struct { apiG func() (interface{}, error) } -// New returns a kamereon API implementation -func New(apiG func() (interface{}, error)) *API { - return &API{apiG: apiG} +// NewProvider returns a kamereon provider +func NewProvider(respG func() (Response, error), cache time.Duration) *Provider { + return &Provider{ + apiG: provider.NewCached(func() (interface{}, error) { + return respG() + }, cache).InterfaceGetter(), + } } -var _ api.Battery = (*API)(nil) +var _ api.Battery = (*Provider)(nil) // SoC implements the api.Vehicle interface -func (v *API) SoC() (float64, error) { +func (v *Provider) SoC() (float64, error) { res, err := v.apiG() if res, ok := res.(Response); err == nil && ok { @@ -53,10 +59,10 @@ func (v *API) SoC() (float64, error) { return 0, err } -var _ api.ChargeState = (*API)(nil) +var _ api.ChargeState = (*Provider)(nil) // Status implements the api.ChargeState interface -func (v *API) Status() (api.ChargeStatus, error) { +func (v *Provider) Status() (api.ChargeStatus, error) { status := api.StatusA // disconnected res, err := v.apiG() @@ -72,10 +78,10 @@ func (v *API) Status() (api.ChargeStatus, error) { return status, err } -var _ api.VehicleRange = (*API)(nil) +var _ api.VehicleRange = (*Provider)(nil) // Range implements the api.VehicleRange interface -func (v *API) Range() (int64, error) { +func (v *Provider) Range() (int64, error) { res, err := v.apiG() if res, ok := res.(Response); err == nil && ok { @@ -85,10 +91,10 @@ func (v *API) Range() (int64, error) { return 0, err } -var _ api.VehicleFinishTimer = (*API)(nil) +var _ api.VehicleFinishTimer = (*Provider)(nil) // FinishTime implements the api.VehicleFinishTimer interface -func (v *API) FinishTime() (time.Time, error) { +func (v *Provider) FinishTime() (time.Time, error) { res, err := v.apiG() if res, ok := res.(Response); err == nil && ok { diff --git a/internal/vehicle/nissan.go b/internal/vehicle/nissan.go index 1e7b62fad..9b5fbe2cf 100644 --- a/internal/vehicle/nissan.go +++ b/internal/vehicle/nissan.go @@ -1,21 +1,14 @@ package vehicle import ( - "bytes" - "encoding/json" "fmt" - "net/http" - "net/url" "strings" "time" "github.com/andig/evcc/api" "github.com/andig/evcc/internal/vehicle/kamereon" - "github.com/andig/evcc/provider" + "github.com/andig/evcc/internal/vehicle/nissan" "github.com/andig/evcc/util" - "github.com/andig/evcc/util/oauth" - "github.com/andig/evcc/util/request" - "golang.org/x/oauth2" ) // Credits to @@ -26,27 +19,10 @@ import ( // OAuth base url // https://prod.eu.auth.kamereon.org/kauth/oauth2/a-ncb-prod/.well-known/openid-configuration -// api constants -const ( - nissanAPIVersion = "protocol=1.0,resource=2.1" - nissanClientID = "a-ncb-prod-android" - nissanClientSecret = "3LBs0yOx2XO-3m4mMRW27rKeJzskhfWF0A8KUtnim8i/qYQPl8ZItp3IaqJXaYj_" - nissanScope = "openid profile vehicles" - nissanAuthBaseURL = "https://prod.eu.auth.kamereon.org/kauth" - nissanRealm = "a-ncb-prod" - nissanRedirectURI = "org.kamereon.service.nci:/oauth2redirect" - nissanCarAdapterBaseURL = "https://alliance-platform-caradapter-prod.apps.eu.kamereon.io/car-adapter" - nissanUserAdapterBaseURL = "https://alliance-platform-usersadapter-prod.apps.eu.kamereon.io/user-adapter" - nissanUserBaseURL = "https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web" -) - // Nissan is an api.Vehicle implementation for Nissan cars type Nissan struct { *embed - *request.Helper - log *util.Logger - user, password, vin string - *kamereon.API + *kamereon.Provider } func init() { @@ -68,240 +44,28 @@ func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } - log := util.NewLogger("nissan") - v := &Nissan{ - 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.authFlow() - if err == nil { - // replace client transport with authenticated transport - v.Client.Transport = &oauth2.Transport{ - Source: oauth.RefreshTokenSource((*oauth2.Token)(&token), v), - Base: v.Client.Transport, - } + log := util.NewLogger("nissan") + identity := nissan.NewIdentity(log) + + err := identity.Login(cc.User, cc.Password) + if err != nil { + return v, fmt.Errorf("login failed: %w", err) } + api := nissan.NewAPI(log, identity, strings.ToUpper(cc.VIN)) + if err == nil && cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) + api.VIN, err = findVehicle(api.Vehicles()) if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + log.DEBUG.Printf("found vehicle: %v", api.VIN) } } - v.API = kamereon.New(provider.NewCached(v.batteryAPI, cc.Cache).InterfaceGetter()) + v.Provider = kamereon.NewProvider(api.Battery, cc.Cache) return v, err } - -type nissanAuth struct { - AuthID string `json:"authId"` - Template string `json:"template"` - Stage string `json:"stage"` - Header string `json:"header"` - Callbacks []nissanAuthCallback `json:"callbacks"` -} - -type nissanAuthCallback struct { - Type string `json:"type"` - Output []nissanAuthCallbackValue `json:"output"` - Input []nissanAuthCallbackValue `json:"input"` -} - -type nissanAuthCallbackValue struct { - Name string `json:"name"` - Value string `json:"value"` -} - -type nissanToken struct { - TokenID string `json:"tokenId"` - SuccessURL string `json:"successUrl"` - Realm string `json:"realm"` -} - -func (v *Nissan) authFlow() (oauth.Token, error) { - client := request.NewHelper(v.log) // no underlying oauth transport - - uri := fmt.Sprintf("%s/json/realms/root/realms/%s/authenticate", nissanAuthBaseURL, nissanRealm) - req, err := request.New(http.MethodPost, uri, nil, map[string]string{ - "Accept-Api-Version": nissanAPIVersion, - "X-Username": "anonymous", - "X-Password": "anonymous", - "Accept": "application/json", - }) - - var nToken nissanToken - var realm string - var resp *http.Response - var code string - - if err == nil { - var res nissanAuth - if err = client.DoJSON(req, &res); err != nil { - return oauth.Token{}, err - } - - for id, cb := range res.Callbacks { - switch cb.Type { - case "NameCallback": - res.Callbacks[id].Input[0].Value = v.user - case "PasswordCallback": - res.Callbacks[id].Input[0].Value = v.password - } - } - - var body []byte - body, err = json.Marshal(res) - - if err == nil { - req, err = request.New(http.MethodPost, uri, bytes.NewReader(body), map[string]string{ - "Content-type": "application/json", - "Accept-Api-Version": nissanAPIVersion, - "X-Username": "anonymous", - "X-Password": "anonymous", - "Accept": "application/json", - }) - } - - if err == nil { - err = client.DoJSON(req, &nToken) - realm = strings.Trim(nToken.Realm, "/") - } - } - - if err == nil { - data := url.Values{ - "client_id": []string{nissanClientID}, - "redirect_uri": []string{nissanRedirectURI}, - "response_type": []string{"code"}, - "scope": []string{nissanScope}, - "nonce": []string{"sdfdsfez"}, - } - - uri := fmt.Sprintf("%s/oauth2/%s/authorize?%s", nissanAuthBaseURL, realm, data.Encode()) - req, err = request.New(http.MethodGet, uri, nil, map[string]string{ - "Cookie": "i18next=en-UK; amlbcookie=05; kauthSession=" + nToken.TokenID, - }) - - if err == nil { - client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } - resp, err = client.Do(req) - client.CheckRedirect = nil - - if err == nil { - resp.Body.Close() - - var location *url.URL - if location, err = url.Parse(resp.Header.Get("Location")); err == nil { - if code = location.Query().Get("code"); code == "" { - err = fmt.Errorf("missing auth code: %v", location) - } - } - } - } - } - - var res oauth.Token - if err == nil { - data := url.Values{ - "code": []string{code}, - "client_id": []string{nissanClientID}, - "client_secret": []string{nissanClientSecret}, - "redirect_uri": []string{nissanRedirectURI}, - "grant_type": []string{"authorization_code"}, - } - - uri = fmt.Sprintf("%s/oauth2/%s/access_token?%s", nissanAuthBaseURL, realm, data.Encode()) - req, err = request.New(http.MethodPost, uri, nil, request.URLEncoding) - if err == nil { - err = client.DoJSON(req, &res) - } - } - - return res, err -} - -func (v *Nissan) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { - data := url.Values{ - "client_id": []string{nissanClientID}, - "client_secret": []string{nissanClientSecret}, - "grant_type": {"refresh_token"}, - "refresh_token": {token.RefreshToken}, - } - - uri := fmt.Sprintf("%s/oauth2/%s/access_token?%s", nissanAuthBaseURL, nissanRealm, data.Encode()) - req, err := request.New(http.MethodPost, uri, nil, request.URLEncoding) - - var res oauth.Token - if err == nil { - client := request.NewHelper(v.log) - err = client.DoJSON(req, &res) - } - - if err != nil { - res, err = v.authFlow() - } - - return (*oauth2.Token)(&res), err -} - -type nissanVehicles struct { - Data []nissanVehicle -} - -type nissanVehicle struct { - VIN string - ModelName string - PictureURL string -} - -func (v *Nissan) vehicles() ([]string, error) { - var user struct{ UserID string } - uri := fmt.Sprintf("%s/v1/users/current", nissanUserAdapterBaseURL) - err := v.GetJSON(uri, &user) - - var res nissanVehicles - if err == nil { - uri := fmt.Sprintf("%s/v2/users/%s/cars", nissanUserBaseURL, user.UserID) - err = v.GetJSON(uri, &res) - } - - var vehicles []string - if err == nil { - for _, v := range res.Data { - vehicles = append(vehicles, v.VIN) - } - } - - return vehicles, err -} - -// batteryAPI provides battery api response -func (v *Nissan) batteryAPI() (interface{}, error) { - // refresh battery status - uri := fmt.Sprintf("%s/v1/cars/%s/actions/refresh-battery-status", nissanCarAdapterBaseURL, v.vin) - - data := strings.NewReader(`{"data": {"type": "RefreshBatteryStatus"}}`) - req, err := request.New(http.MethodPost, uri, data, map[string]string{ - "Content-Type": "application/vnd.api+json", - }) - - var res kamereon.Response - if err == nil { - err = v.DoJSON(req, &res) - } - - // request battery status - if err == nil { - uri = fmt.Sprintf("%s/v1/cars/%s/battery-status", nissanCarAdapterBaseURL, v.vin) - err = v.GetJSON(uri, &res) - } - - return res, err -} diff --git a/internal/vehicle/nissan/api.go b/internal/vehicle/nissan/api.go new file mode 100644 index 000000000..e955733ff --- /dev/null +++ b/internal/vehicle/nissan/api.go @@ -0,0 +1,131 @@ +package nissan + +import ( + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/internal/vehicle/kamereon" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +const ( + refreshTimeout = 5 * time.Minute + statusExpiry = time.Minute +) + +type API struct { + *request.Helper + VIN string + refreshID string + refreshTime time.Time +} + +func NewAPI(log *util.Logger, identity oauth2.TokenSource, vin string) *API { + v := &API{ + Helper: request.NewHelper(log), + VIN: vin, + } + + // replace client transport with authenticated transport + v.Client.Transport = &oauth2.Transport{ + Source: identity, + Base: v.Client.Transport, + } + + return v +} + +func (v *API) Vehicles() ([]string, error) { + var user struct{ UserID string } + uri := fmt.Sprintf("%s/v1/users/current", UserAdapterBaseURL) + err := v.GetJSON(uri, &user) + + var res Vehicles + if err == nil { + uri := fmt.Sprintf("%s/v2/users/%s/cars", UserBaseURL, user.UserID) + err = v.GetJSON(uri, &res) + } + + var vehicles []string + if err == nil { + for _, v := range res.Data { + vehicles = append(vehicles, v.VIN) + } + } + + return vehicles, err +} + +// Battery provides battery api response +func (v *API) Battery() (kamereon.Response, error) { + // request battery status + uri := fmt.Sprintf("%s/v1/cars/%s/battery-status", CarAdapterBaseURL, v.VIN) + + var res kamereon.Response + err := v.GetJSON(uri, &res) + + var ts time.Time + if err == nil { + ts, err = time.Parse(timeFormat, res.Data.Attributes.LastUpdateTime) + + // return the current value + if time.Since(ts) <= statusExpiry { + v.refreshID = "" + return res, err + } + } + + // request a refresh, irrespective of a previous error + if v.refreshID == "" { + if err = v.refreshRequest(); err == nil { + err = api.ErrMustRetry + } + + return res, err + } + + // refresh finally expired + if time.Since(v.refreshTime) > refreshTimeout { + v.refreshID = "" + if err == nil { + err = api.ErrTimeout + } + } else { + // wait for refresh, irrespective of a previous error + err = api.ErrMustRetry + } + + return res, err +} + +// refreshRequest requests battery status refresh +func (v *API) refreshRequest() error { + uri := fmt.Sprintf("%s/v1/cars/%s/actions/refresh-battery-status", CarAdapterBaseURL, v.VIN) + + data := strings.NewReader(`{"data": {"type": "RefreshBatteryStatus"}}`) + req, err := request.New(http.MethodPost, uri, data, map[string]string{ + "Content-Type": "application/vnd.api+json", + }) + + var res kamereon.Response + if err == nil { + err = v.DoJSON(req, &res) + } + + if err == nil { + v.refreshID = res.Data.ID + v.refreshTime = time.Now() + + if v.refreshID == "" { + err = errors.New("refresh failed") + } + } + + return err +} diff --git a/internal/vehicle/nissan/identity.go b/internal/vehicle/nissan/identity.go new file mode 100644 index 000000000..fe34c99da --- /dev/null +++ b/internal/vehicle/nissan/identity.go @@ -0,0 +1,149 @@ +package nissan + +import ( + "bytes" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/oauth" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +type Identity struct { + *request.Helper + oauth2.TokenSource +} + +func NewIdentity(log *util.Logger) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + } +} + +func (v *Identity) Login(user, password string) error { + uri := fmt.Sprintf("%s/json/realms/root/realms/%s/authenticate", AuthBaseURL, Realm) + req, err := request.New(http.MethodPost, uri, nil, map[string]string{ + "Accept-Api-Version": APIVersion, + "X-Username": "anonymous", + "X-Password": "anonymous", + "Accept": "application/json", + }) + + var nToken Token + var realm string + var resp *http.Response + var code string + + if err == nil { + var res Auth + if err = v.DoJSON(req, &res); err != nil { + return err + } + + for id, cb := range res.Callbacks { + switch cb.Type { + case "NameCallback": + res.Callbacks[id].Input[0].Value = user + case "PasswordCallback": + res.Callbacks[id].Input[0].Value = password + } + } + + var body []byte + body, err = json.Marshal(res) + + if err == nil { + req, err = request.New(http.MethodPost, uri, bytes.NewReader(body), map[string]string{ + "Content-type": "application/json", + "Accept": "application/json", + "Accept-Api-Version": APIVersion, + "X-Username": "anonymous", + "X-Password": "anonymous", + }) + } + + if err == nil { + err = v.DoJSON(req, &nToken) + realm = strings.Trim(nToken.Realm, "/") + } + } + + if err == nil { + data := url.Values{ + "client_id": []string{ClientID}, + "redirect_uri": []string{RedirectURI}, + "response_type": []string{"code"}, + "scope": []string{Scope}, + "nonce": []string{"sdfdsfez"}, + } + + uri := fmt.Sprintf("%s/oauth2/%s/authorize?%s", AuthBaseURL, realm, data.Encode()) + req, err = request.New(http.MethodGet, uri, nil, map[string]string{ + "Cookie": "i18next=en-UK; amlbcookie=05; kauthSession=" + nToken.TokenID, + }) + + if err == nil { + v.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + resp, err = v.Do(req) + v.CheckRedirect = nil + + if err == nil { + resp.Body.Close() + + var location *url.URL + if location, err = url.Parse(resp.Header.Get("Location")); err == nil { + if code = location.Query().Get("code"); code == "" { + err = fmt.Errorf("missing auth code: %v", location) + } + } + } + } + } + + var token oauth.Token + if err == nil { + data := url.Values{ + "code": []string{code}, + "client_id": []string{ClientID}, + "client_secret": []string{ClientSecret}, + "redirect_uri": []string{RedirectURI}, + "grant_type": []string{"authorization_code"}, + } + + uri = fmt.Sprintf("%s/oauth2/%s/access_token?%s", AuthBaseURL, realm, data.Encode()) + req, err = request.New(http.MethodPost, uri, nil, request.URLEncoding) + if err == nil { + err = v.DoJSON(req, &token) + } + } + + if err == nil { + v.TokenSource = oauth.RefreshTokenSource((*oauth2.Token)(&token), v) + } + + return err +} + +func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "client_id": []string{ClientID}, + "client_secret": []string{ClientSecret}, + "grant_type": {"refresh_token"}, + "refresh_token": {token.RefreshToken}, + } + + uri := fmt.Sprintf("%s/oauth2/%s/access_token?%s", AuthBaseURL, Realm, data.Encode()) + req, err := request.New(http.MethodPost, uri, nil, request.URLEncoding) + + var res oauth.Token + if err == nil { + err = v.DoJSON(req, &res) + } + + return (*oauth2.Token)(&res), err +} diff --git a/internal/vehicle/nissan/types.go b/internal/vehicle/nissan/types.go new file mode 100644 index 000000000..e95b83b4a --- /dev/null +++ b/internal/vehicle/nissan/types.go @@ -0,0 +1,52 @@ +package nissan + +// api constants +const ( + APIVersion = "protocol=1.0,resource=2.1" + ClientID = "a-ncb-prod-android" + ClientSecret = "3LBs0yOx2XO-3m4mMRW27rKeJzskhfWF0A8KUtnim8i/qYQPl8ZItp3IaqJXaYj_" + Scope = "openid profile vehicles" + AuthBaseURL = "https://prod.eu.auth.kamereon.org/kauth" + Realm = "a-ncb-prod" + RedirectURI = "org.kamereon.service.nci:/oauth2redirect" + CarAdapterBaseURL = "https://alliance-platform-caradapter-prod.apps.eu.kamereon.io/car-adapter" + UserAdapterBaseURL = "https://alliance-platform-usersadapter-prod.apps.eu.kamereon.io/user-adapter" + UserBaseURL = "https://nci-bff-web-prod.apps.eu.kamereon.io/bff-web" +) + +const timeFormat = "2006-01-02T15:04:05Z" + +type Auth struct { + AuthID string `json:"authId"` + Template string `json:"template"` + Stage string `json:"stage"` + Header string `json:"header"` + Callbacks []AuthCallback `json:"callbacks"` +} + +type AuthCallback struct { + Type string `json:"type"` + Output []AuthCallbackValue `json:"output"` + Input []AuthCallbackValue `json:"input"` +} + +type AuthCallbackValue struct { + Name string `json:"name"` + Value string `json:"value"` +} + +type Token struct { + TokenID string `json:"tokenId"` + SuccessURL string `json:"successUrl"` + Realm string `json:"realm"` +} + +type Vehicles struct { + Data []Vehicle +} + +type Vehicle struct { + VIN string + ModelName string + PictureURL string +}