diff --git a/README.md b/README.md index 086dc0cdd6..232e0075a7 100644 --- a/README.md +++ b/README.md @@ -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/) diff --git a/vehicle/ford/identity.go b/vehicle/ford/identity.go index ec19eb2f8b..f03a1fae2b 100644 --- a/vehicle/ford/identity.go +++ b/vehicle/ford/identity.go @@ -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, diff --git a/vehicle/jlr.go b/vehicle/jlr.go new file mode 100644 index 0000000000..c3e1e1ef46 --- /dev/null +++ b/vehicle/jlr.go @@ -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 +} diff --git a/vehicle/jlr/api.go b/vehicle/jlr/api.go new file mode 100644 index 0000000000..216a62b2a5 --- /dev/null +++ b/vehicle/jlr/api.go @@ -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 +} diff --git a/vehicle/jlr/identity.go b/vehicle/jlr/identity.go new file mode 100644 index 0000000000..c04c685230 --- /dev/null +++ b/vehicle/jlr/identity.go @@ -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 +} diff --git a/vehicle/jlr/provider.go b/vehicle/jlr/provider.go new file mode 100644 index 0000000000..b1eee2597e --- /dev/null +++ b/vehicle/jlr/provider.go @@ -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 +} diff --git a/vehicle/jlr/types.go b/vehicle/jlr/types.go new file mode 100644 index 0000000000..d66cea86f2 --- /dev/null +++ b/vehicle/jlr/types.go @@ -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) +}