diff --git a/vehicle/carwings.go b/vehicle/carwings.go new file mode 100644 index 000000000..5b312c690 --- /dev/null +++ b/vehicle/carwings.go @@ -0,0 +1,66 @@ +package vehicle + +import ( + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/util" + + "github.com/joeshaw/carwings" +) + +// CarWings is an api.Vehicle implementation for CarWings cars +type CarWings struct { + *embed + session *carwings.Session + chargeStateG func() (float64, error) +} + +func init() { + registry.Add("carwings", NewCarWingsFromConfig) +} + +// NewCarWingsFromConfig creates a new vehicle +func NewCarWingsFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + Title string + Capacity int64 + User, Password, Region string + Cache time.Duration + }{ + Region: carwings.RegionEurope, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + session := &carwings.Session{ + Region: cc.Region, + } + + if err := session.Connect(cc.User, cc.Password); err != nil { + return nil, err + } + + v := &CarWings{ + embed: &embed{cc.Title, cc.Capacity}, + session: session, + } + + v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + + return v, nil +} + +// chargeState implements the Vehicle.ChargeState interface +func (v *CarWings) chargeState() (float64, error) { + bs, err := v.session.BatteryStatus() + return float64(bs.StateOfCharge), err +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *CarWings) ChargeState() (float64, error) { + return v.chargeStateG() +} diff --git a/vehicle/kamereon/api.go b/vehicle/kamereon/api.go new file mode 100644 index 000000000..abe770d01 --- /dev/null +++ b/vehicle/kamereon/api.go @@ -0,0 +1,87 @@ +package kamereon + +import ( + "time" + + "github.com/andig/evcc/api" +) + +type Response struct { + Data data `json:"data"` +} + +type data struct { + Attributes attributes `json:"attributes"` +} + +type attributes struct { + Timestamp string `json:"timestamp"` + ChargingStatus float32 `json:"chargingStatus"` + InstantaneousPower int `json:"instantaneousPower"` + RangeHvacOff int `json:"rangeHvacOff"` + BatteryLevel int `json:"batteryLevel"` + BatteryCapacity int `json:"batteryCapacity"` // Nissan + BatteryTemperature int `json:"batteryTemperature"` + PlugStatus int `json:"plugStatus"` + LastUpdateTime string `json:"lastUpdateTime"` + ChargePower int `json:"chargePower"` + RemainingTime *int `json:"chargingRemainingTime"` +} + +// API is a kamereon API implementation +type API struct { + apiG func() (interface{}, error) +} + +// New returns a kamereon API implementation +func New(apiG func() (interface{}, error)) *API { + return &API{apiG: apiG} +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *API) ChargeState() (float64, error) { + res, err := v.apiG() + + if res, ok := res.(Response); err == nil && ok { + return float64(res.Data.Attributes.BatteryLevel), nil + } + + return 0, err +} + +// Status implements the Vehicle.Status interface +func (v *API) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + res, err := v.apiG() + if res, ok := res.(Response); err == nil && ok { + if res.Data.Attributes.PlugStatus > 0 { + status = api.StatusB + } + if res.Data.Attributes.ChargingStatus > 1.0 { + status = api.StatusC + } + } + + return status, err +} + +// FinishTime implements the Vehicle.ChargeFinishTimer interface +func (v *API) FinishTime() (time.Time, error) { + res, err := v.apiG() + + if res, ok := res.(Response); err == nil && ok { + var timestamp time.Time + if err == nil { + timestamp, err = time.Parse(time.RFC3339, res.Data.Attributes.Timestamp) + } + + if res.Data.Attributes.RemainingTime == nil { + return time.Time{}, api.ErrNotAvailable + } + + return timestamp.Add(time.Duration(*res.Data.Attributes.RemainingTime) * time.Minute), err + } + + return time.Time{}, err +} diff --git a/vehicle/nissan.go b/vehicle/nissan.go index 93297f89b..85a7495de 100644 --- a/vehicle/nissan.go +++ b/vehicle/nissan.go @@ -1,20 +1,52 @@ package vehicle import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "strings" "time" "github.com/andig/evcc/api" "github.com/andig/evcc/provider" "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/kamereon" + "github.com/andig/evcc/vehicle/oidc" +) - "github.com/joeshaw/carwings" +// Credits to +// https://github.com/Tobiaswk/dartnissanconnect +// https://github.com/mitchellrj/kamereon-python + +// 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 - session *carwings.Session - chargeStateG func() (float64, error) + *request.Helper + user, password, vin string + userID string + tokens oidc.Tokens + *kamereon.API } func init() { @@ -24,43 +56,257 @@ func init() { // NewNissanFromConfig creates a new vehicle func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { - Title string - Capacity int64 - User, Password, Region string - Cache time.Duration + Title string + Capacity int64 + User, Password, Region, VIN string + Cache time.Duration }{ - Region: carwings.RegionEurope, + Region: "de_DE", } if err := util.DecodeOther(other, &cc); err != nil { return nil, err } - session := &carwings.Session{ - Region: cc.Region, - } - - if err := session.Connect(cc.User, cc.Password); err != nil { - return nil, err - } + log := util.NewLogger("nissan") v := &Nissan{ - embed: &embed{cc.Title, cc.Capacity}, - session: session, + embed: &embed{cc.Title, cc.Capacity}, + Helper: request.NewHelper(log), + user: cc.User, + password: cc.Password, + vin: strings.ToUpper(cc.VIN), } - v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + err := v.authFlow() - return v, nil + if err == nil && cc.VIN == "" { + v.vin, err = findVehicle(v.vehicles(v.userID)) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", v.vin) + } + } + + v.API = kamereon.New(provider.NewCached(v.batteryAPI, cc.Cache).InterfaceGetter()) + + return v, err } -// chargeState implements the Vehicle.ChargeState interface -func (v *Nissan) chargeState() (float64, error) { - bs, err := v.session.BatteryStatus() - return float64(bs.StateOfCharge), err +type nissanAuth struct { + AuthID string `json:"authId"` + Template string `json:"template"` + Stage string `json:"stage"` + Header string `json:"header"` + Callbacks []nissanAuthCallback `json:"callbacks"` } -// ChargeState implements the Vehicle.ChargeState interface -func (v *Nissan) ChargeState() (float64, error) { - return v.chargeStateG() +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() error { + 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 oauth nissanToken + var resp *http.Response + var code string + + if err == nil { + var res nissanAuth + 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 = 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 = v.DoJSON(req, &oauth) + } + } + + if err == nil { + uri := fmt.Sprintf("%s/oauth2/%s/authorize", nissanAuthBaseURL, strings.Trim(oauth.Realm, "/")) + + data := url.Values{ + "client_id": []string{nissanClientID}, + "redirect_uri": []string{nissanRedirectURI}, + "response_type": []string{"code"}, + "scope": []string{nissanScope}, + "nonce": []string{"sdfdsfez"}, + } + + uri += "?" + data.Encode() + req, err = request.New(http.MethodGet, uri, nil, map[string]string{ + "Cookie": "i18next=en-UK; amlbcookie=05; kauthSession=" + oauth.TokenID, + }) + + if err == nil { + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + resp, err = v.Do(req) + v.Client.CheckRedirect = nil + + if err == nil { + 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) + } + } + } + } + } + + if err == nil { + uri = fmt.Sprintf("%s/oauth2/%s/access_token", nissanAuthBaseURL, strings.Trim(oauth.Realm, "/")) + + data := url.Values{ + "code": []string{code}, + "client_id": []string{nissanClientID}, + "client_secret": []string{nissanClientSecret}, + "redirect_uri": []string{nissanRedirectURI}, + "grant_type": []string{"authorization_code"}, + } + + uri += "?" + data.Encode() + req, err = request.New(http.MethodPost, uri, nil, request.URLEncoding) + if err == nil { + if err = v.DoJSON(req, &v.tokens); err == nil && v.tokens.AccessToken == "" { + err = errors.New("missing access token") + } + } + } + + if err == nil { + uri = fmt.Sprintf("%s/v1/users/current", nissanUserAdapterBaseURL) + + var user struct{ UserID string } + if err = v.request(uri, &user); err == nil { + v.userID = user.UserID + } + + if v.userID == "" { + err = errors.New("missing user id") + } + } + + return err +} + +func (v *Nissan) refreshToken() error { + uri := fmt.Sprintf("%s/oauth2/%s/access_token", nissanAuthBaseURL, nissanRealm) + + data := url.Values{ + "client_id": []string{nissanClientID}, + "client_secret": []string{nissanClientSecret}, + "grant_type": {"refresh_token"}, + "refresh_token": {v.tokens.RefreshToken}, + } + + uri += "?" + data.Encode() + req, err := request.New(http.MethodPost, uri, nil, request.URLEncoding) + if err == nil { + var tokens oidc.Tokens + if err = v.DoJSON(req, &tokens); err == nil && v.tokens.AccessToken == "" { + err = errors.New("missing access token") + } + fmt.Println(tokens) + } + + return err +} + +func (v *Nissan) request(uri string, res interface{}) error { + req, err := request.New(http.MethodGet, uri, nil, nil) + if err == nil { + req.Header.Set("Authorization", "Bearer "+v.tokens.AccessToken) + err = v.DoJSON(req, &res) + } + + // repeat auth if error + if err != nil { + if err = v.refreshToken(); err != nil { + err = v.authFlow() + } + if err == nil { + err = v.request(uri, &res) + } + } + + return err +} + +type nissanVehicles struct { + Data []nissanVehicle +} + +type nissanVehicle struct { + VIN string + ModelName string + PictureURL string +} + +func (v *Nissan) vehicles(userID string) ([]string, error) { + uri := fmt.Sprintf("%s/v2/users/%s/cars", nissanUserBaseURL, userID) + + var res nissanVehicles + err := v.request(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) { + uri := fmt.Sprintf("%s/v1/cars/%s/battery-status", nissanCarAdapterBaseURL, v.vin) + + var res kamereon.Response + err := v.request(uri, &res) + + return res, err }