Add Jaguar/Landrover api (#2468)
This commit is contained in:
parent
307c9062b4
commit
acc581b903
7 changed files with 456 additions and 2 deletions
|
|
@ -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/)
|
||||
|
|
|
|||
|
|
@ -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
114
vehicle/jlr.go
Normal 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
98
vehicle/jlr/api.go
Normal 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
78
vehicle/jlr/identity.go
Normal 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
97
vehicle/jlr/provider.go
Normal 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
67
vehicle/jlr/types.go
Normal 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)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue