diff --git a/go.mod b/go.mod index 485a6e0f1..e67b7ac93 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/andig/gosunspec v0.0.0-20210511114617-aa30cf9b7a3f // indirect github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef github.com/avast/retry-go v3.0.0+incompatible + github.com/aws/aws-sdk-go v1.40.3 github.com/benbjohnson/clock v1.1.0 github.com/bogosj/tesla v1.0.1-0.20210712193118-0e5b7bb5e924 github.com/containrrr/shoutrrr v0.4.4 diff --git a/go.sum b/go.sum index 4ce64404c..51165d4e7 100644 --- a/go.sum +++ b/go.sum @@ -71,6 +71,8 @@ github.com/asaskevich/EventBus v0.0.0-20200907212545-49d423059eef/go.mod h1:JS7h github.com/avast/retry-go v2.6.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= +github.com/aws/aws-sdk-go v1.40.3 h1:NzjcLRsb+C9L1dVPajdNbdzkuPBi0pQJWiQW0eYJGo8= +github.com/aws/aws-sdk-go v1.40.3/go.mod h1:585smgzpB/KqRA+K3y/NL/oYRqQvpNJYvLm+LY1U59Q= github.com/benbjohnson/clock v1.0.3/go.mod h1:bGMdMPoPVvcYyt1gHDf4J2KE153Yf9BuiUKYMaxlTDM= github.com/benbjohnson/clock v1.1.0 h1:Q92kusRqC1XV2MjkWETPvjJVqKetz1OzxZB7mHJLju8= github.com/benbjohnson/clock v1.1.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA= @@ -383,6 +385,10 @@ github.com/jeremywohl/flatten v1.0.1 h1:LrsxmB3hfwJuE+ptGOijix1PIfOoKLJ3Uee/mzbg github.com/jeremywohl/flatten v1.0.1/go.mod h1:4AmD/VxjWcI5SRB0n6szE2A6s2fsNHDLO0nAlMHgfLQ= github.com/jinzhu/copier v0.3.2 h1:QdBOCbaouLDYaIPFfi1bKv5F5tPpeTwXe4sD0jqtz5w= github.com/jinzhu/copier v0.3.2/go.mod h1:24xnZezI2Yqac9J61UC6/dG/k76ttpq0DdJI3QmUvro= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a/go.mod h1:tB0OlpicmRVTL1Vksc5XRiYo+wkK2kl/GI7eGMIl6Rs= github.com/joeshaw/carwings v0.0.0-20210616193757-7fbc6f7c3c73 h1:0GC7kKnW8vFHZB1txMdeiaVx1X65V9x8sWi5xoxUCfc= github.com/joeshaw/carwings v0.0.0-20210616193757-7fbc6f7c3c73/go.mod h1:tB0OlpicmRVTL1Vksc5XRiYo+wkK2kl/GI7eGMIl6Rs= diff --git a/util/helper.go b/util/helper.go new file mode 100644 index 000000000..1fcf0b54e --- /dev/null +++ b/util/helper.go @@ -0,0 +1,13 @@ +package util + +import "math/rand" + +// RandomString creates random string of N integers +func RandomString(n int) string { + var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") + s := make([]rune, n) + for i := range s { + s[i] = letters[rand.Intn(len(letters))] + } + return string(s) +} diff --git a/vehicle/fiat.go b/vehicle/fiat.go new file mode 100644 index 000000000..2a9d99401 --- /dev/null +++ b/vehicle/fiat.go @@ -0,0 +1,168 @@ +package vehicle + +import ( + "errors" + "fmt" + "io" + "net/http" + "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/fiat" +) + +// https://github.com/TA2k/ioBroker.fiat + +// Fiat is an api.Vehicle implementation for Fiat cars +type Fiat struct { + *embed + *request.Helper + vin string + identity *fiat.Identity + statusG func() (interface{}, error) +} + +func init() { + registry.Add("fiat", NewFiatFromConfig) +} + +// NewFiatFromConfig creates a new vehicle +func NewFiatFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + User, Password, VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.User == "" || cc.Password == "" { + return nil, errors.New("missing credentials") + } + + log := util.NewLogger("fiat") + + v := &Fiat{ + embed: &cc.embed, + Helper: request.NewHelper(log), + vin: strings.ToUpper(cc.VIN), + identity: fiat.NewIdentity(log, cc.User, cc.Password), + } + + err := v.identity.Login() + if err != nil { + return v, fmt.Errorf("login failed: %w", err) + } + + if cc.VIN == "" { + v.vin, err = findVehicle(v.vehicles()) + if err == nil { + log.DEBUG.Printf("found vehicle: %v", v.vin) + } + } + + v.statusG = provider.NewCached(func() (interface{}, error) { + return v.status() + }, cc.Cache).InterfaceGetter() + + return v, err +} + +func (v *Fiat) request(method, uri string, body io.ReadSeeker) (*http.Request, error) { + headers := map[string]string{ + "Content-Type": "application/json", + "X-Clientapp-Version": "1.0", + "ClientrequestId": util.RandomString(16), + "X-Api-Key": fiat.XApiKey, + "X-Originator-Type": "web", + } + + req, err := request.New(method, uri, body, headers) + if err == nil { + err = v.identity.Sign(req, body) + } + + return req, err +} + +func (v *Fiat) vehicles() ([]string, error) { + var res fiat.Vehicles + + uri := fmt.Sprintf("%s/v4/accounts/%s/vehicles?stage=ALL", fiat.ApiURI, v.identity.UID()) + + req, err := v.request(http.MethodGet, uri, nil) + if err == nil { + err = v.DoJSON(req, &res) + } + + var vehicles []string + if err == nil { + for _, v := range res.Vehicles { + vehicles = append(vehicles, v.VIN) + } + } + + return vehicles, err +} + +func (v *Fiat) status() (interface{}, error) { + var res fiat.Status + + uri := fmt.Sprintf("%s/v2/accounts/%s/vehicles/%s/status", fiat.ApiURI, v.identity.UID(), v.vin) + + req, err := v.request(http.MethodGet, uri, nil) + if err == nil { + err = v.DoJSON(req, &res) + } + + return res, err +} + +// SoC implements the api.Vehicle interface +func (v *Fiat) SoC() (float64, error) { + res, err := v.statusG() + if res, ok := res.(fiat.Status); err == nil && ok { + return float64(res.EvInfo.Battery.StateOfCharge), nil + } + + return 0, err +} + +var _ api.VehicleRange = (*Fiat)(nil) + +// Range implements the api.VehicleRange interface +func (v *Fiat) Range() (int64, error) { + res, err := v.statusG() + if res, ok := res.(fiat.Status); err == nil && ok { + return int64(res.EvInfo.Battery.DistanceToEmpty.Value), nil + } + + return 0, err +} + +var _ api.ChargeState = (*Fiat)(nil) + +// Status implements the api.ChargeState interface +func (v *Fiat) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + res, err := v.statusG() + if res, ok := res.(fiat.Status); err == nil && ok { + if res.EvInfo.Battery.PlugInStatus { + status = api.StatusB // connected, not charging + } + if res.EvInfo.Battery.ChargingStatus == "CHARGING" { + status = api.StatusC // charging + } + } + + return status, err +} diff --git a/vehicle/fiat/identity.go b/vehicle/fiat/identity.go new file mode 100644 index 000000000..dfc17b1f1 --- /dev/null +++ b/vehicle/fiat/identity.go @@ -0,0 +1,202 @@ +package fiat + +import ( + "fmt" + "io" + "net/http" + "net/http/cookiejar" + "net/url" + "strings" + "time" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + v4 "github.com/aws/aws-sdk-go/aws/signer/v4" + "github.com/aws/aws-sdk-go/service/cognitoidentity" +) + +const Region = "eu-west-1" + +type Identity struct { + *request.Helper + user, password string + uid string + creds *cognitoidentity.Credentials +} + +// 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 { + v.Client.Jar, _ = cookiejar.New(nil) + + uri := fmt.Sprintf("%s/accounts.webSdkBootstrap", LoginURI) + + data := url.Values(map[string][]string{ + "APIKey": {ApiKey}, + "pageURL": {"https://myuconnect.fiat.com/de/de/vehicle-services"}, + "sdk": {"js_latest"}, + "sdkBuild": {"12234"}, + "format": {"json"}, + }) + + headers := map[string]string{ + "Accept": "*/*", + } + + req, err := request.New(http.MethodGet, uri, nil, headers) + if err == nil { + req.URL.RawQuery = data.Encode() + _, err = v.Do(req) + } + + var res struct { + ErrorCode int + UID string + StatusReason string + SessionInfo struct { + LoginToken string `json:"login_token"` + ExpiresIn string `json:"expires_in"` + } + } + + if err == nil { + uri = fmt.Sprintf("%s/accounts.login", LoginURI) + + data := url.Values(map[string][]string{ + "loginID": {v.user}, + "password": {v.password}, + "sessionExpiration": {"7776000"}, + "APIKey": {ApiKey}, + "pageURL": {"https://myuconnect.fiat.com/de/de/login"}, + "sdk": {"js_latest"}, + "sdkBuild": {"12234"}, + "format": {"json"}, + "targetEnv": {"jssdk"}, + "include": {"profile,data,emails"}, // subscriptions,preferences + "includeUserInfo": {"true"}, + "loginMode": {"standard"}, + "lang": {"de0de"}, + "source": {"showScreenSet"}, + "authMode": {"cookie"}, + }) + + headers := map[string]string{ + "Accept": "*/*", + "Content-Type": "application/x-www-form-urlencoded", + } + + if req, err = request.New(http.MethodPost, uri, strings.NewReader(data.Encode()), headers); err == nil { + if err = v.DoJSON(req, &res); err == nil { + v.uid = res.UID + } + } + } + + var token struct { + ErrorCode int `json:"errorCode"` + StatusReason string + IDToken string `json:"id_token"` + } + + if err == nil { + uri = fmt.Sprintf("%s/accounts.getJWT", LoginURI) + + data := url.Values(map[string][]string{ + "fields": {"profile.firstName,profile.lastName,profile.email,country,locale,data.disclaimerCodeGSDP"}, // data.GSDPisVerified + "APIKey": {ApiKey}, + "pageURL": {"https://myuconnect.fiat.com/de/de/dashboard"}, + "sdk": {"js_latest"}, + "sdkBuild": {"12234"}, + "format": {"json"}, + "login_token": {res.SessionInfo.LoginToken}, + "authMode": {"cookie"}, + }) + + headers := map[string]string{ + "Accept": "*/*", + } + + if req, err = request.New(http.MethodGet, uri, nil, headers); err == nil { + req.URL.RawQuery = data.Encode() + err = v.DoJSON(req, &token) + } + } + + var identity struct { + Token, IdentityID string + } + + if err == nil { + data := struct { + GigyaToken string `json:"gigya_token"` + }{ + GigyaToken: token.IDToken, + } + + headers := map[string]string{ + "Content-Type": "application/json", + "X-Clientapp-Version": "1.0", + "ClientRequestId": util.RandomString(16), + "X-Api-Key": XApiKey, + "X-Originator-Type": "web", + } + + if req, err = request.New(http.MethodPost, TokenURI, request.MarshalJSON(data), headers); err == nil { + err = v.DoJSON(req, &identity) + } + } + + var credRes *cognitoidentity.GetCredentialsForIdentityOutput + + if err == nil { + session := session.Must(session.NewSession(&aws.Config{Region: aws.String(Region)})) + svc := cognitoidentity.New(session) + + credRes, err = svc.GetCredentialsForIdentity(&cognitoidentity.GetCredentialsForIdentityInput{ + IdentityId: &identity.IdentityID, + Logins: map[string]*string{ + "cognito-identity.amazonaws.com": &identity.Token, + }, + }) + } + + if err == nil { + v.creds = credRes.Credentials + } + + return err +} + +// UID returns the logged in users uid +func (v *Identity) UID() string { + return v.uid +} + +// Sign signs an AWS request using identity's credentials +func (v *Identity) Sign(req *http.Request, body io.ReadSeeker) error { + // refresh credentials + if v.creds.Expiration.After(time.Now().Add(-time.Minute)) { + if err := v.Login(); err != nil { + return fmt.Errorf("login failed: %w", err) + } + } + + // sign request + signer := v4.NewSigner(credentials.NewStaticCredentials( + *v.creds.AccessKeyId, *v.creds.SecretKey, *v.creds.SessionToken, + )) + _, err := signer.Sign(req, body, "execute-api", Region, time.Now()) + + return err +} diff --git a/vehicle/fiat/types.go b/vehicle/fiat/types.go new file mode 100644 index 000000000..0ff8ef3a2 --- /dev/null +++ b/vehicle/fiat/types.go @@ -0,0 +1,36 @@ +package fiat + +const ( + LoginURI = "https://loginmyuconnect.fiat.com" + ApiURI = "https://channels.sdpr-01.fcagcv.com" + TokenURI = "https://authz.sdpr-01.fcagcv.com/v2/cognito/identity/token" + ApiKey = "3_mOx_J2dRgjXYCdyhchv3b5lhi54eBcdCTX4BI8MORqmZCoQWhA0mV2PTlptLGUQI" + XApiKey = "qLYupk65UU1tw2Ih1cJhs4izijgRDbir2UFHA3Je" +) + +type Vehicle struct { + VIN string +} + +type Vehicles struct { + Vehicles []Vehicle +} + +type Status struct { + EvInfo struct { + Battery struct { + ChargingLevel string // LEVEL_2 + ChargingStatus string // CHARGING + DistanceToEmpty struct { + Value int + Unit string + } + PlugInStatus bool // true + StateOfCharge int // 75 + TimeToFullyChargeL1 int // 0 + TimeToFullyChargeL2 int // 540 + TotalRange int // 17 + } + Timestamp int64 + } +} diff --git a/vehicle/vw/functions.go b/vehicle/vw/functions.go index 057e0db82..8e75786f7 100644 --- a/vehicle/vw/functions.go +++ b/vehicle/vw/functions.go @@ -6,16 +6,6 @@ import ( "math/rand" ) -// RandomString creates random string of N integers -func RandomString(n int) string { - var letters = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") - s := make([]rune, n) - for i := range s { - s[i] = letters[rand.Intn(len(letters))] - } - return string(s) -} - // ChallengeVerifier returns a challenge/verifier base64-encoded code combination func ChallengeVerifier() (string, string, error) { bytes := make([]byte, 32) diff --git a/vehicle/vw/identity.go b/vehicle/vw/identity.go index 3fdf7fbb1..dfa7e2399 100644 --- a/vehicle/vw/identity.go +++ b/vehicle/vw/identity.go @@ -72,8 +72,8 @@ func (v *Identity) login(uri, user, password string) (url.Values, error) { // add nonce and state query := url.Values{ - "nonce": []string{RandomString(43)}, - "state": []string{RandomString(43)}, + "nonce": []string{util.RandomString(43)}, + "state": []string{util.RandomString(43)}, } uri += "&" + query.Encode()