Nissan: async soc refresh (#1011)

This commit is contained in:
andig 2021-05-14 22:29:51 +02:00 • committed by GitHub
parent 36582bff6a
commit 169d2ca11d
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
5 changed files with 365 additions and 263 deletions

View file

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

View file

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

View file

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

View file

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

View file

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