Add Jaguar/Landrover api (#2468)

This commit is contained in:
andig 2022-02-02 15:55:47 +01:00 • committed by GitHub
parent 307c9062b4
commit acc581b903
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
7 changed files with 456 additions and 2 deletions

View file

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

View file

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

114
vehicle/jlr.go Normal file
View file

@ -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
vehicle/jlr/api.go Normal file
View file

@ -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
vehicle/jlr/identity.go Normal file
View file

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

97
vehicle/jlr/provider.go Normal file
View file

@ -0,0 +1,97 @@
package jlr
import (
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/provider"
)
type Provider struct {
statusG func() (interface{}, error)
}
func NewProvider(api *API, vin string, cache time.Duration) *Provider {
impl := &Provider{
statusG: provider.NewCached(func() (interface{}, error) {
return api.Status(vin)
}, cache).InterfaceGetter(),
}
return impl
}
var _ api.Battery = (*Provider)(nil)
// SoC implements the api.Battery interface
func (v *Provider) SoC() (float64, error) {
var val float64
res, err := v.statusG()
if res, ok := res.(StatusResponse); err == nil && ok {
val, err = res.VehicleStatus.EvStatus.FloatVal("EV_STATE_OF_CHARGE")
}
return val, err
}
var _ api.VehicleRange = (*Provider)(nil)
// Range implements the api.VehicleRange interface
func (v *Provider) Range() (int64, error) {
var val int64
res, err := v.statusG()
if res, ok := res.(StatusResponse); err == nil && ok {
val, err = res.VehicleStatus.EvStatus.IntVal("EV_RANGE_ON_BATTERY_KM")
}
return val, 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.statusG()
if res, ok := res.(StatusResponse); err == nil && ok {
if s, err := res.VehicleStatus.EvStatus.StringVal("EV_CHARGING_STATUS"); err == nil {
switch s {
case "NOTCONNECTED":
status = api.StatusA
case "INITIALIZATION", "PAUSED":
status = api.StatusB
case "CHARGING":
status = api.StatusC
}
}
}
return status, err
}
var _ api.VehicleFinishTimer = (*Provider)(nil)
// FinishTime implements the api.VehicleFinishTimer interface
func (v *Provider) FinishTime() (time.Time, error) {
res, err := v.statusG()
if res, ok := res.(StatusResponse); err == nil && ok {
i, err := res.VehicleStatus.CoreStatus.IntVal("EV_MINUTES_TO_FULLY_CHARGED")
return time.Now().Add(time.Duration(i) * time.Minute), err
}
return time.Time{}, err
}
var _ api.VehicleOdometer = (*Provider)(nil)
// Odometer implements the api.VehicleOdometer interface
func (v *Provider) Odometer() (float64, error) {
var val float64
res, err := v.statusG()
if res, ok := res.(StatusResponse); err == nil && ok {
val, err = res.VehicleStatus.CoreStatus.FloatVal("ODOMETER")
}
return val / 1e3, err
}

67
vehicle/jlr/types.go Normal file
View file

@ -0,0 +1,67 @@
package jlr
import (
"strconv"
"github.com/evcc-io/evcc/api"
"golang.org/x/oauth2"
)
type Token struct {
AuthToken string `json:"authorization_token"`
ExpiresIn int `json:"expires_in,string"`
oauth2.Token
}
type User struct {
HomeMarket string `json:"homeMarket"`
UserId string `json:"userId"`
}
type Vehicle struct {
UserId string `json:"userId"`
VIN string `json:"vin"`
Role string `json:"role"`
}
type VehiclesResponse struct {
Vehicles []Vehicle
}
type KeyValue struct {
Key, Value string
}
type KeyValueList []KeyValue
type StatusResponse struct {
VehicleStatus struct {
CoreStatus KeyValueList
EvStatus KeyValueList
}
}
func (l KeyValueList) StringVal(key string) (string, error) {
for _, el := range l {
if el.Key == key {
return el.Value, nil
}
}
return "", api.ErrNotAvailable
}
func (l KeyValueList) FloatVal(key string) (float64, error) {
s, err := l.StringVal(key)
if err != nil {
return 0, err
}
return strconv.ParseFloat(s, 64)
}
func (l KeyValueList) IntVal(key string) (int64, error) {
s, err := l.StringVal(key)
if err != nil {
return 0, err
}
return strconv.ParseInt(s, 10, 64)
}