Add Nissan api (#465)

This commit is contained in:
andig 2020-11-22 18:52:00 +01:00 • committed by GitHub
parent 7d4035c1de
commit 651cfea209
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
3 changed files with 425 additions and 26 deletions

66
vehicle/carwings.go Normal file
View file

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

87
vehicle/kamereon/api.go Normal file
View file

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

View file

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