Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Jaguar/Landrover api #2468

Merged
merged 7 commits into from
Feb 2, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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