Skip to content

Commit

Permalink
Add Renault Zoe api (#23)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig committed May 1, 2020
1 parent e809c4d commit e0e2a64
Show file tree
Hide file tree
Showing 4 changed files with 356 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -218,6 +218,7 @@ Available vehicle implementations are:
- `bmw`: BMW (i3)
- `nissan`: Nissan (Leaf)
- `tesla`: Tesla (any model)
- `renault`: Renault (Zoe)
- `default`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle

## Plugins
Expand Down
9 changes: 9 additions & 0 deletions evcc.dist.yaml
Expand Up @@ -171,6 +171,15 @@ vehicles:
# password: # password
# region: NE # carwings region, leave empty for Europe
# cache: 5m
# - name: renault
# type: renault
# title: Zoe
# capacity: 60 # kWh
# user: # user
# password: # password
# region: de_DE # gigya region
# vin: WREN...
# cache: 5m

loadpoints:
- name: main # name for logging
Expand Down
2 changes: 2 additions & 0 deletions vehicle/config.go
Expand Up @@ -22,6 +22,8 @@ func NewFromConfig(log *util.Logger, typ string, other map[string]interface{}) a
c = NewTeslaFromConfig(log, other)
case "nissan", "leaf":
c = NewNissanFromConfig(log, other)
case "renault", "zoe":
c = NewRenaultFromConfig(log, other)
default:
log.FATAL.Fatalf("invalid vehicle type '%s'", typ)
}
Expand Down
344 changes: 344 additions & 0 deletions vehicle/renault.go
@@ -0,0 +1,344 @@
package vehicle

import (
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"time"

"github.com/andig/evcc/api"
"github.com/andig/evcc/provider"
"github.com/andig/evcc/util"
)

// Credits to
// https://github.com/edent/Renault-Zoe-API/issues/18
// https://github.com/epenet/Renault-Zoe-API/blob/newapimockup/Test/MyRenault.py
// https://github.com/jamesremuscat/pyze

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 {
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/v1/cars/%s/battery-status
}

type kamereonAccount struct {
AccountID string `json:"accountId"`
}

type kamereonVehicle struct {
Brand string `json:"brand"`
VIN string `json:"vin"`
Status string `json:"status"`
}

type kamereonData struct {
Attributes batteryAttributes `json:"attributes"`
}

type batteryAttributes struct {
ChargeStatus int `json:"chargeStatus"`
InstantaneousPower int `json:"instantaneousPower"`
RangeHvacOff int `json:"rangeHvacOff"`
BatteryLevel int `json:"batteryLevel"`
BatteryTemperature int `json:"batteryTemperature"`
PlugStatus int `json:"plugStatus"`
LastUpdateTime string `json:"lastUpdateTime"`
ChargePower int `json:"chargePower"`
}

// Renault is an api.Vehicle implementation for Renault cars
type Renault struct {
*embed
*util.HTTPHelper
user, password, vin string
gigya, kamereon configServer
gigyaJwtToken, kamereonAccessToken string
accountID string
chargeStateG provider.FloatGetter
}

// NewRenaultFromConfig creates a new vehicle
func NewRenaultFromConfig(log *util.Logger, other map[string]interface{}) api.Vehicle {
cc := struct {
Title string
Capacity int64
User, Password, Region, VIN string
Cache time.Duration
}{}
util.DecodeOther(log, other, &cc)

if cc.Region == "" {
cc.Region = "de_DE"
}

logger := util.NewLogger("zoe")

v := &Renault{
embed: &embed{cc.Title, cc.Capacity},
HTTPHelper: util.NewHTTPHelper(logger),
user: cc.User,
password: cc.Password,
vin: cc.VIN,
}

err := v.apiKeys(cc.Region)
if err == nil {
err = v.authFlow()
}
if err != nil {
v.HTTPHelper.Log.FATAL.Fatalf("cannot create renault: %v", err)
}

v.chargeStateG = provider.NewCached(logger, v.chargeState, cc.Cache).FloatGetter()

return v
}

func (v *Renault) apiKeys(region string) error {
uri := fmt.Sprintf(keyStore, region)

var cr configResponse
_, err := v.GetJSON(uri, &cr)

v.gigya = cr.Servers.GigyaProd
v.kamereon = cr.Servers.WiredProd

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 {
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")
}

// find VIN
if v.vin == "" {
v.vin, err = v.kamereonVehicles(v.accountID)
if v.vin == "" {
return errors.New("missing vin")
}
}

if err == nil {
v.kamereonAccessToken, err = v.kamereonToken(v.accountID)
if v.kamereonAccessToken == "" {
return errors.New("missing kamereon access token")
}
}
}
}
}

return err
}

func (v *Renault) request(uri string, data url.Values, headers ...map[string]string) (*http.Request, error) {
req, err := http.NewRequest(http.MethodGet, uri, nil)
if err != nil {
return req, err
}
req.URL.RawQuery = data.Encode()

for _, headers := range headers {
for k, v := range headers {
req.Header.Add(k, v)
}
}

return req, nil
}

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)

var gr gigyaResponse
if err == nil {
_, err = v.RequestJSON(req, &gr)
}

return gr.SessionInfo.CookieValue, err
}

func (v *Renault) personID(sessionCookie string) (string, error) {
uri := fmt.Sprintf("%s/accounts.getAccountInfo", v.gigya.Target)

data := url.Values{
"oauth_token": []string{sessionCookie},
}

req, err := v.request(uri, data)

var gr gigyaResponse
if err == nil {
_, err = v.RequestJSON(req, &gr)
}

return gr.Data.PersonID, err
}

func (v *Renault) jwtToken(sessionCookie string) (string, error) {
uri := fmt.Sprintf("%s/accounts.getJWT", v.gigya.Target)

data := url.Values{
"oauth_token": []string{sessionCookie},
"fields": []string{"data.personId,data.gigyaDataCenter"},
"expiration": []string{"900"},
}

req, err := v.request(uri, data)

var gr gigyaResponse
if err == nil {
_, err = v.RequestJSON(req, &gr)
}

return gr.IDToken, err
}

func (v *Renault) kamereonHeaders(additional ...map[string]string) map[string]string {
headers := map[string]string{
"x-gigya-id_token": v.gigyaJwtToken,
"apikey": v.kamereon.APIKey,
}

for _, h := range additional {
for k, v := range h {
headers[k] = v
}
}

return headers
}

func (v *Renault) kamereonPerson(personID string) (string, error) {
var kr kamereonResponse
uri := fmt.Sprintf("%s/commerce/v1/persons/%s", v.kamereon.Target, personID)

data := url.Values{"country": []string{"DE"}}
headers := v.kamereonHeaders()

req, err := v.request(uri, data, headers)
if err == nil {
_, err = v.RequestJSON(req, &kr)

if len(kr.Accounts) == 0 {
return "", nil
}
}

return kr.Accounts[0].AccountID, err
}

func (v *Renault) kamereonVehicles(accountID string) (string, error) {
var kr kamereonResponse
uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/vehicles", v.kamereon.Target, accountID)

data := url.Values{"country": []string{"DE"}}
headers := v.kamereonHeaders()

req, err := v.request(uri, data, headers)
if err == nil {
_, err = v.RequestJSON(req, &kr)

for _, v := range kr.VehicleLinks {
if strings.ToUpper(v.Status) == "ACTIVE" {
return v.VIN, nil
}
}
}

return "", err
}

func (v *Renault) kamereonToken(accountID string) (string, error) {
var kr kamereonResponse
uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/token", v.kamereon.Target, accountID)

data := url.Values{"country": []string{"DE"}}
headers := v.kamereonHeaders()

req, err := v.request(uri, data, headers)
if err == nil {
_, err = v.RequestJSON(req, &kr)
}

return kr.AccessToken, err
}

// chargeState implements the Vehicle.ChargeState interface
func (v *Renault) chargeState() (float64, error) {
var kr kamereonResponse
uri := fmt.Sprintf("%s/commerce/v1/accounts/%s/kamereon/kca/car-adapter/v1/cars/%s/battery-status", v.kamereon.Target, v.accountID, v.vin)

data := url.Values{"country": []string{"DE"}}
headers := v.kamereonHeaders(map[string]string{"x-kamereon-authorization": "Bearer " + v.kamereonAccessToken})

req, err := v.request(uri, data, headers)
if err == nil {
_, err = v.RequestJSON(req, &kr)
}
return float64(kr.Data.Attributes.BatteryLevel), err
}

// ChargeState implements the Vehicle.ChargeState interface
func (v *Renault) ChargeState() (float64, error) {
return v.chargeStateG()
}

0 comments on commit e0e2a64

Please sign in to comment.