Add Fiat 500e (#1229)

This commit is contained in:
andig 2021-07-21 09:30:45 +02:00
parent 699bee4dec
commit 14cfc97d22
8 changed files with 428 additions and 12 deletions

1
go.mod
View file

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

6
go.sum
View file

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

13
util/helper.go Normal file
View file

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

168
vehicle/fiat.go Normal file
View file

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

202
vehicle/fiat/identity.go Normal file
View file

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

36
vehicle/fiat/types.go Normal file
View file

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

View file

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

View file

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