From abf4f76568463d067bbdb80f2f833229f8ab38b5 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 27 Jul 2022 10:35:17 +0200 Subject: [PATCH] Renault: refactor structure (#3950) --- vehicle/renault.go | 479 ++---------------------------- vehicle/renault/gigya/identity.go | 103 +++++++ vehicle/renault/kamereon/api.go | 113 +++++++ vehicle/renault/kamereon/types.go | 64 ++++ vehicle/renault/keys/keys.go | 51 ++++ vehicle/renault/provider.go | 151 ++++++++++ 6 files changed, 509 insertions(+), 452 deletions(-) create mode 100644 vehicle/renault/gigya/identity.go create mode 100644 vehicle/renault/kamereon/api.go create mode 100644 vehicle/renault/kamereon/types.go create mode 100644 vehicle/renault/keys/keys.go create mode 100644 vehicle/renault/provider.go diff --git a/vehicle/renault.go b/vehicle/renault.go index 093bcb91b..2cf49419e 100644 --- a/vehicle/renault.go +++ b/vehicle/renault.go @@ -2,18 +2,14 @@ package vehicle import ( "errors" - "fmt" - "io" - "net/http" - "net/url" - "strings" "time" "github.com/evcc-io/evcc/api" - "github.com/evcc-io/evcc/provider" "github.com/evcc-io/evcc/util" - "github.com/evcc-io/evcc/util/request" - "golang.org/x/exp/slices" + "github.com/evcc-io/evcc/vehicle/renault" + "github.com/evcc-io/evcc/vehicle/renault/gigya" + "github.com/evcc-io/evcc/vehicle/renault/kamereon" + "github.com/evcc-io/evcc/vehicle/renault/keys" ) // Credits to @@ -23,109 +19,10 @@ import ( // https://github.com/jamesremuscat/pyze // https://muscatoxblog.blogspot.com/2019/07/delving-into-renaults-new-api.html -const ( - keyStore = "https://renault-wrd-prod-1-euw1-myrapp-one.s3-eu-west-1.amazonaws.com/configuration/android/config_%s.json" -) - -type configResponse struct { - Servers configServers -} - -type configServers struct { - GigyaProd configServer `json:"gigyaProd"` - WiredProd configServer `json:"wiredProd"` -} - -type configServer struct { - Target string `json:"target"` - APIKey string `json:"apikey"` -} - -type gigyaResponse struct { - ErrorCode int `json:"errorCode"` // /accounts.login - ErrorMessage string `json:"errorMessage"` // /accounts.login - SessionInfo gigyaSessionInfo `json:"sessionInfo"` // /accounts.login - IDToken string `json:"id_token"` // /accounts.getJWT - Data gigyaData `json:"data"` // /accounts.getAccountInfo -} - -type gigyaSessionInfo struct { - CookieValue string `json:"cookieValue"` -} - -type gigyaData struct { - PersonID string `json:"personId"` -} - -type kamereonResponse struct { - Accounts []kamereonAccount `json:"accounts"` // /commerce/v1/persons/%s - AccessToken string `json:"accessToken"` // /commerce/v1/accounts/%s/kamereon/token - VehicleLinks []kamereonVehicle `json:"vehicleLinks"` // /commerce/v1/accounts/%s/vehicles - Data kamereonData `json:"data"` // /commerce/v1/accounts/%s/kamereon/kca/car-adapter/vX/cars/%s/... -} - -type kamereonAccount struct { - AccountID string `json:"accountId"` -} - -type kamereonVehicle struct { - Brand string `json:"brand"` - VIN string `json:"vin"` - Status string `json:"status"` - ConnectedDriver connectedDriver `json:"ConnectedDriver"` -} - -func (v *kamereonVehicle) Available() error { - if strings.ToUpper(v.Status) != "ACTIVE" { - return errors.New("vehicle is not active") - } - - if len(v.ConnectedDriver.Role) == 0 { - return errors.New("vehicle is not connected to driver") - } - - return nil -} - -type connectedDriver struct { - Role string `json:"role"` -} - -type kamereonData struct { - Attributes attributes `json:"attributes"` -} - -type attributes struct { - // battery-status - Timestamp string `json:"timestamp"` - ChargingStatus float32 `json:"chargingStatus"` - InstantaneousPower int `json:"instantaneousPower"` - RangeHvacOff int `json:"rangeHvacOff"` - BatteryAutonomy int `json:"batteryAutonomy"` - BatteryLevel int `json:"batteryLevel"` - BatteryTemperature int `json:"batteryTemperature"` - PlugStatus int `json:"plugStatus"` - LastUpdateTime string `json:"lastUpdateTime"` - ChargePower int `json:"chargePower"` - RemainingTime *int `json:"chargingRemainingTime"` - // hvac-status - ExternalTemperature float64 `json:"externalTemperature"` - HvacStatus string `json:"hvacStatus"` - // cockpit - TotalMileage float64 `json:"totalMileage"` -} - // Renault is an api.Vehicle implementation for Renault cars type Renault struct { *embed - *request.Helper - user, password, vin string - gigya, kamereon configServer - gigyaJwtToken string - accountID string - batteryG func() (kamereonResponse, error) - cockpitG func() (kamereonResponse, error) - hvacG func() (kamereonResponse, error) + *renault.Provider } func init() { @@ -151,24 +48,33 @@ func NewRenaultFromConfig(other map[string]interface{}) (api.Vehicle, error) { log := util.NewLogger("renault").Redact(cc.User, cc.Password, cc.VIN) v := &Renault{ - embed: &cc.embed, - Helper: request.NewHelper(log), - user: cc.User, - password: cc.Password, + embed: &cc.embed, } - err := v.apiKeys(cc.Region) - if err == nil { - err = v.authFlow() + keys := keys.New(log) + keys.Load(cc.Region) + + identity := gigya.NewIdentity(log, keys.Gigya) + if err := identity.Login(cc.User, cc.Password); err != nil { + return nil, err } - var car kamereonVehicle + api := kamereon.New(log, keys.Kamereon, identity, func() error { + return identity.Login(cc.User, cc.Password) + }) + + accountID, err := api.Person(identity.PersonID) + if err == nil && accountID == "" { + return nil, errors.New("missing accountID") + } + + var car kamereon.Vehicle if err == nil { - v.vin, car, err = ensureVehicleWithFeature(cc.VIN, - func() ([]kamereonVehicle, error) { - return v.kamereonVehicles(v.accountID) + cc.VIN, car, err = ensureVehicleWithFeature(cc.VIN, + func() ([]kamereon.Vehicle, error) { + return api.Vehicles(accountID) }, - func(v kamereonVehicle) (string, kamereonVehicle) { + func(v kamereon.Vehicle) (string, kamereon.Vehicle) { return v.VIN, v }, ) @@ -178,338 +84,7 @@ func NewRenaultFromConfig(other map[string]interface{}) (api.Vehicle, error) { err = car.Available() } - v.batteryG = provider.Cached(v.batteryAPI, cc.Cache) - v.cockpitG = provider.Cached(v.cockpitAPI, cc.Cache) - v.hvacG = provider.Cached(v.hvacAPI, cc.Cache) + v.Provider = renault.NewProvider(api, accountID, cc.VIN, cc.Cache) return v, err } - -func (v *Renault) apiKeys(region string) error { - uri := fmt.Sprintf(keyStore, region) - - var cr configResponse - err := v.GetJSON(uri, &cr) - if err != nil { - // Use of old fixed keys if keyStore is not accessible - v.gigya = configServer{"https://accounts.eu1.gigya.com", "3_7PLksOyBRkHv126x5WhHb-5pqC1qFR8pQjxSeLB6nhAnPERTUlwnYoznHSxwX668"} - v.kamereon = configServer{"https://api-wired-prod-1-euw1.wrd-aws.com", "VAX7XYKGfa92yMvXculCkEFyfZbuM7Ss"} - return nil - } else { - v.gigya = cr.Servers.GigyaProd - v.kamereon = cr.Servers.WiredProd - // Temporary fix of wrong kamereon APIKey in keyStore - v.kamereon.APIKey = "VAX7XYKGfa92yMvXculCkEFyfZbuM7Ss" - return err - } -} - -func (v *Renault) authFlow() error { - sessionCookie, err := v.sessionCookie(v.user, v.password) - - if err == nil { - v.gigyaJwtToken, err = v.jwtToken(sessionCookie) - - if err == nil { - if v.accountID != "" { - // personID, accountID and VIN have already been read, skip remainder of flow - return nil - } - - var personID string - personID, err = v.personID(sessionCookie) - if personID == "" { - return errors.New("missing personID") - } - - if err == nil { - v.accountID, err = v.kamereonPerson(personID) - if v.accountID == "" { - return errors.New("missing accountID") - } - } - } - } - - return err -} - -func (v *Renault) request(uri string, params url.Values, body io.Reader, headers ...map[string]string) (*http.Request, error) { - method := http.MethodGet - if body != nil { - method = http.MethodPost - } - - req, err := request.New(method, uri, body, headers...) - if err == nil { - req.URL.RawQuery = params.Encode() - } - - return req, err -} - -func (v *Renault) sessionCookie(user, password string) (string, error) { - uri := fmt.Sprintf("%s/accounts.login", v.gigya.Target) - - data := url.Values{ - "loginID": []string{user}, - "password": []string{password}, - "apiKey": []string{v.gigya.APIKey}, - } - - req, err := v.request(uri, data, nil) - - var res gigyaResponse - if err == nil { - err = v.DoJSON(req, &res) - if err == nil && res.ErrorCode > 0 { - err = errors.New(res.ErrorMessage) - } - } - - return res.SessionInfo.CookieValue, err -} - -func (v *Renault) personID(sessionCookie string) (string, error) { - uri := fmt.Sprintf("%s/accounts.getAccountInfo", v.gigya.Target) - - data := url.Values{ - "apiKey": []string{v.gigya.APIKey}, - "login_token": []string{sessionCookie}, - } - - req, err := v.request(uri, data, nil) - - var res gigyaResponse - if err == nil { - err = v.DoJSON(req, &res) - } - - return res.Data.PersonID, err -} - -func (v *Renault) jwtToken(sessionCookie string) (string, error) { - uri := fmt.Sprintf("%s/accounts.getJWT", v.gigya.Target) - - data := url.Values{ - "apiKey": []string{v.gigya.APIKey}, - "login_token": []string{sessionCookie}, - "fields": []string{"data.personId,data.gigyaDataCenter"}, - "expiration": []string{"900"}, - } - - req, err := v.request(uri, data, nil) - - var res gigyaResponse - if err == nil { - err = v.DoJSON(req, &res) - } - - return res.IDToken, err -} - -func (v *Renault) kamereonRequest(uri string, body io.Reader) (kamereonResponse, error) { - params := url.Values{"country": []string{"DE"}} - headers := map[string]string{ - "x-gigya-id_token": v.gigyaJwtToken, - "apikey": v.kamereon.APIKey, - } - - var res kamereonResponse - req, err := v.request(uri, params, body, headers) - if err == nil { - err = v.DoJSON(req, &res) - } - - return res, err -} - -func (v *Renault) kamereonPerson(personID string) (string, error) { - uri := fmt.Sprintf("%s/commerce/v1/persons/%s", v.kamereon.Target, personID) - res, err := v.kamereonRequest(uri, nil) - - if len(res.Accounts) == 0 { - return "", err - } - - return res.Accounts[0].AccountID, err -} - -func (v *Renault) kamereonVehicles(configVIN string) ([]kamereonVehicle, error) { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/vehicles", v.kamereon.Target, v.accountID) - res, err := v.kamereonRequest(uri, nil) - return res.VehicleLinks, err -} - -// batteryAPI provides battery-status api response -func (v *Renault) batteryAPI() (kamereonResponse, error) { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/battery-status", v.kamereon.Target, v.accountID, v.vin) - res, err := v.kamereonRequest(uri, nil) - - // repeat auth if error - if err != nil { - if err = v.authFlow(); err == nil { - res, err = v.kamereonRequest(uri, nil) - } - } - - return res, err -} - -// hvacAPI provides hvac-status api response -func (v *Renault) hvacAPI() (kamereonResponse, error) { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/hvac-status", v.kamereon.Target, v.accountID, v.vin) - res, err := v.kamereonRequest(uri, nil) - - // repeat auth if error - if err != nil { - if err = v.authFlow(); err == nil { - res, err = v.kamereonRequest(uri, nil) - } - } - - return res, err -} - -// cockpitAPI provides cockpit api response -func (v *Renault) cockpitAPI() (kamereonResponse, error) { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/cockpit", v.kamereon.Target, v.accountID, v.vin) - res, err := v.kamereonRequest(uri, nil) - - // repeat auth if error - if err != nil { - if err = v.authFlow(); err == nil { - res, err = v.kamereonRequest(uri, nil) - } - } - - return res, err -} - -// SoC implements the api.Vehicle interface -func (v *Renault) SoC() (float64, error) { - res, err := v.batteryG() - - if err == nil { - return float64(res.Data.Attributes.BatteryLevel), nil - } - - return 0, err -} - -var _ api.ChargeState = (*Renault)(nil) - -// Status implements the api.ChargeState interface -func (v *Renault) Status() (api.ChargeStatus, error) { - status := api.StatusA // disconnected - - res, err := v.batteryG() - if err == nil { - if res.Data.Attributes.PlugStatus > 0 { - status = api.StatusB - } - if res.Data.Attributes.ChargingStatus >= 1.0 { - status = api.StatusC - } - } - - return status, err -} - -var _ api.VehicleRange = (*Renault)(nil) - -// Range implements the api.VehicleRange interface -func (v *Renault) Range() (int64, error) { - res, err := v.batteryG() - - if err == nil { - return int64(res.Data.Attributes.BatteryAutonomy), nil - } - - return 0, err -} - -var _ api.VehicleOdometer = (*Renault)(nil) - -// Odometer implements the api.VehicleOdometer interface -func (v *Renault) Odometer() (float64, error) { - res, err := v.cockpitG() - - if err == nil { - return res.Data.Attributes.TotalMileage, nil - } - - return 0, err -} - -var _ api.VehicleFinishTimer = (*Renault)(nil) - -// FinishTime implements the api.VehicleFinishTimer interface -func (v *Renault) FinishTime() (time.Time, error) { - res, err := v.batteryG() - - 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 -} - -var _ api.VehicleClimater = (*Renault)(nil) - -// Climater implements the api.VehicleClimater interface -func (v *Renault) Climater() (active bool, outsideTemp, targetTemp float64, err error) { - res, err := v.hvacG() - - // Zoe Ph2 - if err, ok := err.(request.StatusError); ok && err.HasStatus(http.StatusForbidden) { - return false, 0, 0, api.ErrNotAvailable - } - - if err == nil { - state := strings.ToLower(res.Data.Attributes.HvacStatus) - - if state == "" { - return false, 0, 0, api.ErrNotAvailable - } - - active := !slices.Contains([]string{"off", "false", "invalid", "error"}, state) - - return active, res.Data.Attributes.ExternalTemperature, 20, nil - } - - return false, 0, 0, err -} - -var _ api.AlarmClock = (*Renault)(nil) - -// WakeUp implements the api.AlarmClock interface -func (v *Renault) WakeUp() error { - uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kcm/v1/vehicles/%s/charge/pause-resume", v.kamereon.Target, v.accountID, v.vin) - - data := map[string]interface{}{ - "data": map[string]interface{}{ - "type": "ChargePauseResume", - "attributes": map[string]interface{}{ - "action": "resume", - }, - }, - } - - _, err := v.kamereonRequest(uri, request.MarshalJSON(data)) - - // repeat auth if error - if err != nil { - if err = v.authFlow(); err == nil { - _, err = v.kamereonRequest(uri, request.MarshalJSON(data)) - } - } - - return err -} diff --git a/vehicle/renault/gigya/identity.go b/vehicle/renault/gigya/identity.go new file mode 100644 index 000000000..0ab35cf79 --- /dev/null +++ b/vehicle/renault/gigya/identity.go @@ -0,0 +1,103 @@ +package gigya + +import ( + "errors" + "fmt" + "net/url" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/renault/keys" +) + +type gigyaResponse struct { + ErrorCode int `json:"errorCode"` // /accounts.login + ErrorMessage string `json:"errorMessage"` // /accounts.login + SessionInfo gigyaSessionInfo `json:"sessionInfo"` // /accounts.login + IDToken string `json:"id_token"` // /accounts.getJWT + Data gigyaData `json:"data"` // /accounts.getAccountInfo +} + +type gigyaSessionInfo struct { + CookieValue string `json:"cookieValue"` +} + +type gigyaData struct { + PersonID string `json:"personId"` +} + +type Identity struct { + *request.Helper + gigya keys.ConfigServer + Token string + PersonID string +} + +func NewIdentity(log *util.Logger, gigya keys.ConfigServer) *Identity { + return &Identity{ + Helper: request.NewHelper(log), + gigya: gigya, + } +} + +func (v *Identity) Login(user, password string) error { + sessionCookie, err := v.sessionCookie(user, password) + + if err == nil { + v.Token, err = v.jwtToken(sessionCookie) + } + + if err == nil { + if v.PersonID, err = v.personID(sessionCookie); v.PersonID == "" { + err = errors.New("missing personID") + } + } + + return err +} + +func (v *Identity) sessionCookie(user, password string) (string, error) { + data := url.Values{ + "loginID": []string{user}, + "password": []string{password}, + "apiKey": []string{v.gigya.APIKey}, + } + + var res gigyaResponse + uri := fmt.Sprintf("%s/accounts.login?%s", v.gigya.Target, data.Encode()) + + err := v.GetJSON(uri, &res) + if err == nil && res.ErrorCode > 0 { + err = errors.New(res.ErrorMessage) + } + + return res.SessionInfo.CookieValue, err +} + +func (v *Identity) personID(sessionCookie string) (string, error) { + data := url.Values{ + "apiKey": []string{v.gigya.APIKey}, + "login_token": []string{sessionCookie}, + } + + var res gigyaResponse + uri := fmt.Sprintf("%s/accounts.getAccountInfo?%s", v.gigya.Target, data.Encode()) + err := v.GetJSON(uri, &res) + + return res.Data.PersonID, err +} + +func (v *Identity) jwtToken(sessionCookie string) (string, error) { + data := url.Values{ + "apiKey": []string{v.gigya.APIKey}, + "login_token": []string{sessionCookie}, + "fields": []string{"data.personId,data.gigyaDataCenter"}, + "expiration": []string{"900"}, + } + + var res gigyaResponse + uri := fmt.Sprintf("%s/accounts.getJWT?%s", v.gigya.Target, data.Encode()) + err := v.GetJSON(uri, &res) + + return res.IDToken, err +} diff --git a/vehicle/renault/kamereon/api.go b/vehicle/renault/kamereon/api.go new file mode 100644 index 000000000..0b4741106 --- /dev/null +++ b/vehicle/renault/kamereon/api.go @@ -0,0 +1,113 @@ +package kamereon + +import ( + "fmt" + "io" + "net/http" + "net/url" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/renault/gigya" + "github.com/evcc-io/evcc/vehicle/renault/keys" +) + +type API struct { + *request.Helper + keys keys.ConfigServer + identity *gigya.Identity + login func() error +} + +func New(log *util.Logger, keys keys.ConfigServer, identity *gigya.Identity, login func() error) *API { + return &API{ + Helper: request.NewHelper(log), + keys: keys, + identity: identity, + login: login, + } +} + +func (v *API) request_(uri string, body io.Reader) (Response, error) { + params := url.Values{"country": []string{"DE"}} + headers := map[string]string{ + "x-gigya-id_token": v.identity.Token, + "apikey": v.keys.APIKey, + } + + method := http.MethodGet + if body != nil { + method = http.MethodPost + } + + var res Response + req, err := request.New(method, uri+"?"+params.Encode(), body, headers) + if err == nil { + err = v.DoJSON(req, &res) + } + + return res, err +} + +func (v *API) request(uri string, body io.Reader) (Response, error) { + res, err := v.request_(uri, body) + + // repeat auth if error + if err != nil { + if err = v.login(); err == nil { + res, err = v.request_(uri, nil) + } + } + + return res, err +} + +func (v *API) Person(personID string) (string, error) { + uri := fmt.Sprintf("%s/commerce/v1/persons/%s", v.keys.Target, personID) + res, err := v.request(uri, nil) + + if len(res.Accounts) == 0 { + return "", err + } + + return res.Accounts[0].AccountID, err +} + +func (v *API) Vehicles(accountID string) ([]Vehicle, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/vehicles", v.keys.Target, accountID) + res, err := v.request(uri, nil) + return res.VehicleLinks, err +} + +// Battery provides battery-status api response +func (v *API) Battery(accountID string, vin string) (Response, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/battery-status", v.keys.Target, accountID, vin) + return v.request(uri, nil) +} + +// Hvac provides hvac-status api response +func (v *API) Hvac(accountID string, vin string) (Response, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/hvac-status", v.keys.Target, accountID, vin) + return v.request(uri, nil) +} + +// Cockpit provides cockpit api response +func (v *API) Cockpit(accountID string, vin string) (Response, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v2/cars/%s/cockpit", v.keys.Target, accountID, vin) + return v.request(uri, nil) +} + +func (v *API) WakeUp(accountID string, vin string) (Response, error) { + uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kcm/v1/vehicles/%s/charge/pause-resume", v.keys.Target, accountID, vin) + + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "ChargePauseResume", + "attributes": map[string]interface{}{ + "action": "resume", + }, + }, + } + + return v.request(uri, request.MarshalJSON(data)) +} diff --git a/vehicle/renault/kamereon/types.go b/vehicle/renault/kamereon/types.go new file mode 100644 index 000000000..f996a92c0 --- /dev/null +++ b/vehicle/renault/kamereon/types.go @@ -0,0 +1,64 @@ +package kamereon + +import ( + "errors" + "strings" +) + +type Response struct { + Accounts []Account `json:"accounts"` // /commerce/v1/persons/%s + AccessToken string `json:"accessToken"` // /commerce/v1/accounts/%s/kamereon/token + VehicleLinks []Vehicle `json:"vehicleLinks"` // /commerce/v1/accounts/%s/vehicles + Data Data `json:"data"` // /commerce/v1/accounts/%s/kamereon/kca/car-adapter/vX/cars/%s/... +} + +type Account struct { + AccountID string `json:"accountId"` +} + +type Vehicle struct { + Brand string + VIN string + Status string + ConnectedDriver connectedDriver +} + +func (v *Vehicle) Available() error { + if strings.ToUpper(v.Status) != "ACTIVE" { + return errors.New("vehicle is not active") + } + + if len(v.ConnectedDriver.Role) == 0 { + return errors.New("vehicle is not connected to driver") + } + + return nil +} + +type connectedDriver struct { + Role string `json:"role"` +} + +type Data struct { + Attributes attributes `json:"attributes"` +} + +type attributes struct { + // battery-status + Timestamp string `json:"timestamp"` + ChargingStatus float32 `json:"chargingStatus"` + InstantaneousPower int `json:"instantaneousPower"` + RangeHvacOff int `json:"rangeHvacOff"` + BatteryAutonomy int `json:"batteryAutonomy"` + BatteryLevel int `json:"batteryLevel"` + BatteryTemperature int `json:"batteryTemperature"` + PlugStatus int `json:"plugStatus"` + LastUpdateTime string `json:"lastUpdateTime"` + ChargePower int `json:"chargePower"` + RemainingTime *int `json:"chargingRemainingTime"` + // hvac-status + ExternalTemperature float64 `json:"externalTemperature"` + HvacStatus string `json:"hvacStatus"` + // cockpit + TotalMileage float64 `json:"totalMileage"` +} diff --git a/vehicle/renault/keys/keys.go b/vehicle/renault/keys/keys.go new file mode 100644 index 000000000..f244f503d --- /dev/null +++ b/vehicle/renault/keys/keys.go @@ -0,0 +1,51 @@ +package keys + +import ( + "fmt" + + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" +) + +const keyStore = "https://renault-wrd-prod-1-euw1-myrapp-one.s3-eu-west-1.amazonaws.com/configuration/android/config_%s.json" + +type configResponse struct { + Servers configServers +} + +type configServers struct { + GigyaProd ConfigServer `json:"gigyaProd"` + WiredProd ConfigServer `json:"wiredProd"` +} + +type ConfigServer struct { + Target string `json:"target"` + APIKey string `json:"apikey"` +} + +type Keys struct { + *request.Helper + Gigya, Kamereon ConfigServer +} + +func New(log *util.Logger) *Keys { + return &Keys{ + Helper: request.NewHelper(log), + } +} + +func (v *Keys) Load(region string) { + uri := fmt.Sprintf(keyStore, region) + + var cr configResponse + if err := v.GetJSON(uri, &cr); err == nil { + v.Gigya = cr.Servers.GigyaProd + v.Kamereon = cr.Servers.WiredProd + // temporary fix of wrong kamereon APIKey in keyStore + v.Kamereon.APIKey = "VAX7XYKGfa92yMvXculCkEFyfZbuM7Ss" + } else { + // use old fixed keys if keyStore is not accessible + v.Gigya = ConfigServer{"https://accounts.eu1.gigya.com", "3_7PLksOyBRkHv126x5WhHb-5pqC1qFR8pQjxSeLB6nhAnPERTUlwnYoznHSxwX668"} + v.Kamereon = ConfigServer{"https://api-wired-prod-1-euw1.wrd-aws.com", "VAX7XYKGfa92yMvXculCkEFyfZbuM7Ss"} + } +} diff --git a/vehicle/renault/provider.go b/vehicle/renault/provider.go new file mode 100644 index 000000000..3817da853 --- /dev/null +++ b/vehicle/renault/provider.go @@ -0,0 +1,151 @@ +package renault + +import ( + "net/http" + "strings" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/provider" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/vehicle/renault/kamereon" + "golang.org/x/exp/slices" +) + +// Provider is an api.Vehicle implementation for PSA cars +type Provider struct { + batteryG func() (kamereon.Response, error) + cockpitG func() (kamereon.Response, error) + hvacG func() (kamereon.Response, error) + wakeup func() (kamereon.Response, error) +} + +// NewProvider creates a new vehicle +func NewProvider(api *kamereon.API, accountID, vin string, cache time.Duration) *Provider { + impl := &Provider{ + batteryG: provider.Cached(func() (kamereon.Response, error) { + return api.Battery(accountID, vin) + }, cache), + cockpitG: provider.Cached(func() (kamereon.Response, error) { + return api.Cockpit(accountID, vin) + }, cache), + hvacG: provider.Cached(func() (kamereon.Response, error) { + return api.Hvac(accountID, vin) + }, cache), + wakeup: func() (kamereon.Response, error) { + return api.WakeUp(accountID, vin) + }, + } + return impl +} + +var _ api.Battery = (*Provider)(nil) + +// SoC implements the api.Vehicle interface +func (v *Provider) SoC() (float64, error) { + res, err := v.batteryG() + + if err == nil { + return float64(res.Data.Attributes.BatteryLevel), nil + } + + return 0, err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + res, err := v.batteryG() + if err == nil { + if res.Data.Attributes.PlugStatus > 0 { + status = api.StatusB + } + if res.Data.Attributes.ChargingStatus >= 1.0 { + status = api.StatusC + } + } + + return status, err +} + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (int64, error) { + res, err := v.batteryG() + + if err == nil { + return int64(res.Data.Attributes.BatteryAutonomy), nil + } + + return 0, err +} + +var _ api.VehicleOdometer = (*Provider)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *Provider) Odometer() (float64, error) { + res, err := v.cockpitG() + + if err == nil { + return res.Data.Attributes.TotalMileage, nil + } + + return 0, err +} + +var _ api.VehicleFinishTimer = (*Provider)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *Provider) FinishTime() (time.Time, error) { + res, err := v.batteryG() + + 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 +} + +var _ api.VehicleClimater = (*Provider)(nil) + +// Climater implements the api.VehicleClimater interface +func (v *Provider) Climater() (active bool, outsideTemp float64, targetTemp float64, err error) { + res, err := v.hvacG() + + // Zoe Ph2 + if err, ok := err.(request.StatusError); ok && err.HasStatus(http.StatusForbidden) { + return false, 0, 0, api.ErrNotAvailable + } + + if err == nil { + state := strings.ToLower(res.Data.Attributes.HvacStatus) + + if state == "" { + return false, 0, 0, api.ErrNotAvailable + } + + active := !slices.Contains([]string{"off", "false", "invalid", "error"}, state) + + return active, res.Data.Attributes.ExternalTemperature, 20, nil + } + + return false, 0, 0, err +} + +var _ api.AlarmClock = (*Provider)(nil) + +// WakeUp implements the api.AlarmClock interface +func (v *Provider) WakeUp() error { + _, err := v.wakeup() + return err +}