Skip to content

Commit

Permalink
Add Jaguar/Landrover api (evcc-io#2468)
Browse files Browse the repository at this point in the history
  • Loading branch information
andig authored Feb 2, 2022
1 parent 307c906 commit acc581b
Show file tree
Hide file tree
Showing 7 changed files with 456 additions and 2 deletions.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ Evcc is an extensible EV Charge Controller with PV integration implemented in [G
- Build-your-own: Phoenix (includes ESL Walli), [EVSE DIN](https://www.evse-wifi.de/produkt-schlagwort/simple-evse-wb/)
- Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link
- multiple [meters](https://docs.evcc.io/docs/devices/meters): ModBus (Eastron SDM, MPM3PM, SBC ALE3 and many more), Discovergy (using HTTP plugin), SMA Sunny Home Manager and Energy Meter, KOSTAL Smart Energy Meter (KSEM, EMxx), any Sunspec-compatible inverter or home battery devices (Fronius, SMA, SolarEdge, KOSTAL, STECA, E3DC, ...), Tesla PowerWall, LG ESS HOME
- wide support of vendor-specific [vehicles](https://docs.evcc.io/docs/devices/vehicles) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Fiat, Ford, Hyundai, Kia, Mini, Nissan, Niu, Porsche, Renault, Seat, Smart, Skoda, Tesla, Volkswagen, Volvo, Tronity
- wide support of vendor-specific [vehicles](https://docs.evcc.io/docs/devices/vehicles) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Fiat, Ford, Hyundai, Jaguar, Kia, Landrover, Mini, Nissan, Niu, Porsche, Renault, Seat, Smart, Skoda, Tesla, Volkswagen, Volvo, Tronity
- [plugins](https://docs.evcc.io/docs/reference/plugins) for integrating with any charger/ meter/ vehicle: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts
- status [notifications](https://docs.evcc.io/docs/reference/configuration/messaging) using [Telegram](https://telegram.org), [PushOver](https://pushover.net) and [many more](https://containrrr.dev/shoutrrr/)
- logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/)
Expand Down
2 changes: 1 addition & 1 deletion vehicle/ford/identity.go
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,7 @@ func (v *Identity) login() (oauth.Token, error) {
return token, err
}

// Refresh implements oauth.TokenRefresher
// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := map[string]string{
"refresh_token": token.RefreshToken,
Expand Down
114 changes: 114 additions & 0 deletions vehicle/jlr.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package vehicle

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

"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/vehicle/jlr"
"github.com/google/uuid"
)

// https://github.com/ardevd/jlrpy

// JLR is an api.Vehicle implementation for Jaguar LandRover cars
type JLR struct {
*embed
*jlr.Provider
}

func init() {
registry.Add("jaguar", NewJLRFromConfig)
registry.Add("landrover", NewJLRFromConfig)
}

// NewJLRFromConfig creates a new vehicle
func NewJLRFromConfig(other map[string]interface{}) (api.Vehicle, error) {
cc := struct {
embed `mapstructure:",squash"`
User, Password, VIN string
DeviceID string
Expiry time.Duration
Cache time.Duration
}{
Expiry: expiry,
Cache: interval,
}

if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}

if cc.User == "" || cc.Password == "" {
return nil, api.ErrMissingCredentials
}

v := &JLR{
embed: &cc.embed,
}

log := util.NewLogger("jlr").Redact(cc.User, cc.Password, cc.VIN, cc.DeviceID)

if cc.DeviceID == "" {
uid := uuid.New()
cc.DeviceID = uid.String()
log.WARN.Println("new device id generated, add `deviceid` to config:", cc.DeviceID)
}

identity := jlr.NewIdentity(log, cc.User, cc.Password, cc.DeviceID)

token, err := identity.Login()
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}

if err := v.RegisterDevice(log, cc.User, cc.DeviceID, token); err != nil {
return nil, fmt.Errorf("device registry failed: %w", err)
}

api := jlr.NewAPI(log, cc.DeviceID, identity)

user, err := api.User(cc.User)
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}

cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) {
return api.Vehicles(user.UserId)
})

if err == nil {
v.Provider = jlr.NewProvider(api, cc.VIN, cc.Cache)
}

return v, err
}

func (v *JLR) RegisterDevice(log *util.Logger, user, device string, t jlr.Token) error {
c := request.NewHelper(log)

data := map[string]string{
"access_token": t.AccessToken,
"authorization_token": t.AuthToken,
"expires_in": "86400",
"deviceID": device}

uri := fmt.Sprintf("%s/users/%s/clients", jlr.IFOP_BASE_URL, url.PathEscape(user))

req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
"Authorization": "Bearer " + t.AccessToken,
"Content-type": "application/json",
"Accept": "application/json",
"X-Device-Id": device,
"x-telematicsprogramtype": "jlrpy",
})
if err == nil {
_, err = c.DoBody(req)
}

return err
}
98 changes: 98 additions & 0 deletions vehicle/jlr/api.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package jlr

import (
"fmt"
"net/http"
"net/url"

"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/request"
"github.com/evcc-io/evcc/util/transport"
"golang.org/x/oauth2"
)

const (
IF9_BASE_URL = "https://if9.prod-row.jlrmotor.com/if9/jlr"
IFOP_BASE_URL = "https://ifop.prod-row.jlrmotor.com/ifop/jlr"
)

// API is the Jaguar/Landrover api client
type API struct {
*request.Helper
}

// NewAPI creates a new api client
func NewAPI(log *util.Logger, device string, ts oauth2.TokenSource) *API {
v := &API{
Helper: request.NewHelper(log),
}

v.Client.Transport = &transport.Decorator{
Decorator: func(req *http.Request) error {
token, err := ts.Token()
if err == nil {
for k, v := range map[string]string{
"Authorization": fmt.Sprintf("Bearer %s", token.AccessToken),
"Content-Type": request.JSONContent,
"X-Device-Id": device,
"x-telematicsprogramtype": "jlrpy",
} {
req.Header.Set(k, v)
}
}
return err
},
Base: v.Client.Transport,
}

return v
}

func (v *API) User(name string) (User, error) {
var res User

uri := fmt.Sprintf("%s/users?loginName=%s", IF9_BASE_URL, url.QueryEscape(name))
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Content-Type": request.JSONContent,
"Accept": "application/vnd.wirelesscar.ngtp.if9.User-v3+json",
})

if err == nil {
err = v.DoJSON(req, &res)
}

return res, err
}

func (v *API) Vehicles(user string) ([]string, error) {
var vehicles []string
var resp VehiclesResponse

uri := fmt.Sprintf("%s/users/%s/vehicles?primaryOnly=true", IF9_BASE_URL, user)

err := v.GetJSON(uri, &resp)
if err == nil {
for _, v := range resp.Vehicles {
vehicles = append(vehicles, v.VIN)
}
}

return vehicles, nil
}

// Status returns the vehicle status
func (v *API) Status(vin string) (StatusResponse, error) {
var status StatusResponse

uri := fmt.Sprintf("%s/vehicles/%s/status?includeInactive=true", IF9_BASE_URL, vin)
req, err := request.New(http.MethodGet, uri, nil, map[string]string{
"Content-Type": "application/json",
"Accept": "application/vnd.ngtp.org.if9.healthstatus-v3+json",
})

if err == nil {
err = v.DoJSON(req, &status)
}

return status, err
}
78 changes: 78 additions & 0 deletions vehicle/jlr/identity.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package jlr

import (
"fmt"
"net/http"
"time"

"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/oauth"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)

// https://github.com/ardevd/jlrpy

const IFAS_BASE_URL = "https://ifas.prod-row.jlrmotor.com/ifas/jlr"

type Identity struct {
*request.Helper
user, password, device string
oauth2.TokenSource
}

// NewIdentity creates Fiat identity
func NewIdentity(log *util.Logger, user, password, device string) *Identity {
return &Identity{
Helper: request.NewHelper(log),
user: user,
password: password,
device: device,
}
}

// Login authenticates with given payload
func (v *Identity) login(data map[string]string) (Token, error) {
uri := fmt.Sprintf("%s/tokens", IFAS_BASE_URL)
req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), map[string]string{
"Authorization": "Basic YXM6YXNwYXNz",
"Content-type": request.JSONContent,
"X-Device-Id": v.device,
})

var token Token
if err == nil {
err = v.DoJSON(req, &token)
token.Expiry = time.Now().Add(time.Duration(token.ExpiresIn) * time.Second)
}

return token, err
}

// Login authenticates with username/password
func (v *Identity) Login() (Token, error) {
data := map[string]string{
"grant_type": "password",
"username": v.user,
"password": v.password,
}

token, err := v.login(data)
if err == nil {
v.TokenSource = oauth.RefreshTokenSource(&token.Token, v)
}

return token, err
}

// RefreshToken implements oauth.TokenRefresher
func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
data := map[string]string{
"grant_type": "refresh_token",
"refresh_token": token.RefreshToken,
}

res, err := v.login(data)

return &res.Token, err
}
Loading

0 comments on commit acc581b

Please sign in to comment.