From 777cb7b7bcce74b5cdccd53697004e18b5ddbba2 Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 15 Feb 2024 18:28:27 +0100 Subject: [PATCH] Add Tesla proxy client (#12174) --- go.mod | 11 +- go.sum | 22 +- templates/definition/vehicle/tesla-proxy.yaml | 46 ++++ vehicle/tesla-proxy.go | 258 ++++++++++++++++++ vehicle/tesla.go | 2 - vehicle/types.go | 2 + 6 files changed, 324 insertions(+), 17 deletions(-) create mode 100644 templates/definition/vehicle/tesla-proxy.yaml create mode 100644 vehicle/tesla-proxy.go diff --git a/go.mod b/go.mod index 91681c914..8621d5aa1 100644 --- a/go.mod +++ b/go.mod @@ -30,6 +30,7 @@ require ( github.com/eclipse/paho.mqtt.golang v1.4.3 github.com/enbility/cemd v0.2.2 github.com/enbility/eebus-go v0.2.0 + github.com/evcc-io/tesla-proxy-client v0.0.0-20240210120646-d0be579ff7b7 github.com/fatih/structs v1.1.0 github.com/glebarez/sqlite v1.10.0 github.com/go-http-utils/etag v0.0.0-20161124023236-513ea8f21eb1 @@ -95,8 +96,8 @@ require ( go.uber.org/mock v0.4.0 golang.org/x/crypto/x509roots/fallback v0.0.0-20240108164429-dbb6ec16ecef golang.org/x/exp v0.0.0-20240119083558-1b970713d09a - golang.org/x/net v0.20.0 - golang.org/x/oauth2 v0.16.0 + golang.org/x/net v0.21.0 + golang.org/x/oauth2 v0.17.0 golang.org/x/sync v0.6.0 golang.org/x/text v0.14.0 google.golang.org/grpc v1.61.0 @@ -187,10 +188,10 @@ require ( github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect gitlab.com/c0b/go-ordered-json v0.0.0-20201030195603-febf46534d5a // indirect go.uber.org/multierr v1.11.0 // indirect - golang.org/x/crypto v0.18.0 // indirect + golang.org/x/crypto v0.19.0 // indirect golang.org/x/mod v0.14.0 // indirect - golang.org/x/sys v0.16.0 // indirect - golang.org/x/term v0.16.0 // indirect + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 // indirect golang.org/x/tools v0.17.0 // indirect google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240125205218-1f4bbc51befe // indirect diff --git a/go.sum b/go.sum index dd19c1f44..841fcbb60 100644 --- a/go.sum +++ b/go.sum @@ -143,6 +143,8 @@ github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.m github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/evcc-io/modbus v0.0.0-20230705160100-23e1df2c01c3 h1:QBtLqy85cy9KLRWqKxX+42bN1rWti8iU7pL7Vwf2UbE= github.com/evcc-io/modbus v0.0.0-20230705160100-23e1df2c01c3/go.mod h1:qVX2WhsI5xyAoM6I/MV1bXSKBPdLAjp7pCvieO/S0AY= +github.com/evcc-io/tesla-proxy-client v0.0.0-20240210120646-d0be579ff7b7 h1:O2/JI5Od2x2/faJadwMFtVtJnJJxeGGzIjPExcqzD9I= +github.com/evcc-io/tesla-proxy-client v0.0.0-20240210120646-d0be579ff7b7/go.mod h1:zWtAweBqXJTk3HSrPSecz3Q3a2hAUQ4vOE6paJfn03I= github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= @@ -735,8 +737,8 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.3.0/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.18.0 h1:PGVlW0xEltQnzFZ55hkuX5+KLyrMYhHld1YHO4AKcdc= -golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg= +golang.org/x/crypto v0.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= golang.org/x/crypto/x509roots/fallback v0.0.0-20240108164429-dbb6ec16ecef h1:vEvmmVshwWdgDSUnJQjVouHPkUqPmPX0l4yhjzxsrgE= golang.org/x/crypto/x509roots/fallback v0.0.0-20240108164429-dbb6ec16ecef/go.mod h1:kNa9WdvYnzFwC79zRpLRMJbdEFlhyM5RPFBBZp/wWH8= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -785,12 +787,12 @@ golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= -golang.org/x/net v0.20.0 h1:aCL9BSgETF1k+blQaYUBx9hJ9LOGP3gAVemcZlf1Kpo= -golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= -golang.org/x/oauth2 v0.16.0 h1:aDkGMBSYxElaoP81NpoUoz2oo2R2wHdZpGToUxfyQrQ= -golang.org/x/oauth2 v0.16.0/go.mod h1:hqZ+0LWXsiVoZpeld6jVt06P3adbS2Uu911W1SsJv2o= +golang.org/x/oauth2 v0.17.0 h1:6m3ZPmLEFdVxKKWnKq4VqZ60gutO35zm+zrAHVmHyDQ= +golang.org/x/oauth2 v0.17.0/go.mod h1:OzPDGQiuQMguemayvdylqddI7qcD9lnSDb+1FiwQ5HA= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -851,15 +853,15 @@ golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.7.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.16.0 h1:xWw16ngr6ZMtmxDyKyIgsE93KNKz5HKmMa3b8ALHidU= -golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.7.0/go.mod h1:P32HKFT3hSsZrRxla30E9HqToFYAQPCMs/zFMBUFqPY= -golang.org/x/term v0.16.0 h1:m+B6fahuftsE9qjo0VWp2FW0mB3MTJvR0BaMQrq0pmE= -golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= diff --git a/templates/definition/vehicle/tesla-proxy.yaml b/templates/definition/vehicle/tesla-proxy.yaml new file mode 100644 index 000000000..822cfc830 --- /dev/null +++ b/templates/definition/vehicle/tesla-proxy.yaml @@ -0,0 +1,46 @@ +template: tesla-proxy +products: + - brand: Tesla + description: + de: Fleet API Proxy + en: Fleet API Proxy +requirements: + description: + de: | + Es muss ein `access` und ein `refresh` Token für die Kommunikation mit der Tesla API erstellt werden. + Für die Steuerung des Fahrzeugs muss ferner der Virtual Key auf dem Fahrzeug installiert werden. + Siehe https://tesla.evcc.io. + en: | + You need to generate an `access` and a `refresh` token for communicating with the Tesla API. + For vehicle control, the virtual key must be installed on the vehicle. + See https://tesla.evcc.io. + evcc: ["sponsorship"] +params: + - name: title + - name: icon + default: car + advanced: true + - name: accessToken + required: true + mask: true + - name: refreshToken + required: true + mask: true + - name: vin + example: W... + - name: capacity + - name: phases + advanced: true + - preset: vehicle-identify +render: | + type: tesla-proxy + title: {{ .title }} + icon: {{ .icon }} + tokens: + access: {{ .accessToken }} + refresh: {{ .refreshToken }} + capacity: {{ .capacity }} + phases: {{ .phases }} + vin: {{ .vin }} + {{ include "vehicle-identify" . }} + features: ["coarsecurrent"] diff --git a/vehicle/tesla-proxy.go b/vehicle/tesla-proxy.go new file mode 100644 index 000000000..3ab62860b --- /dev/null +++ b/vehicle/tesla-proxy.go @@ -0,0 +1,258 @@ +package vehicle + +import ( + "context" + "errors" + "slices" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/provider" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" + "github.com/evcc-io/evcc/util/sponsor" + "github.com/evcc-io/evcc/util/transport" + vc "github.com/evcc-io/evcc/vehicle/tesla-vehicle-command" + tesla "github.com/evcc-io/tesla-proxy-client" + "golang.org/x/oauth2" +) + +// TeslaProxy is an api.Vehicle implementation for Tesla cars +type TeslaProxy struct { + *embed + vehicle *tesla.Vehicle + dataG func() (*tesla.VehicleData, error) +} + +func init() { + registry.Add("tesla-proxy", NewTeslaProxyFromConfig) +} + +// NewTeslaProxyFromConfig creates a new vehicle +func NewTeslaProxyFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + Tokens Tokens + URI string + VIN string + Cache time.Duration + }{ + URI: "https://tesla.evcc.io/api/1", + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + token, err := cc.Tokens.Token() + if err != nil { + return nil, err + } + + if !sponsor.IsAuthorized() { + return nil, api.ErrSponsorRequired + } + + v := &TeslaProxy{ + embed: &cc.embed, + } + + // authenticated http client with logging injected to the Tesla client + log := util.NewLogger("tesla-proxy").Redact( + cc.Tokens.Access, cc.Tokens.Refresh, + vc.OAuth2Config.ClientID, vc.OAuth2Config.ClientSecret, + ) + + identity, err := vc.NewIdentity(log, token) + if err != nil { + return nil, err + } + + hc := request.NewClient(log) + hc.Transport = &transport.Decorator{ + Decorator: transport.DecorateHeaders(map[string]string{ + "X-Auth-Token": sponsor.Token, + }), + Base: &oauth2.Transport{ + Source: identity, + Base: hc.Transport, + }, + } + + options := []tesla.ClientOption{ + tesla.WithClient(hc), + tesla.WithBaseURL(cc.URI), + } + + client, err := tesla.NewClient(context.Background(), options...) + if err != nil { + return nil, err + } + + v.vehicle, err = ensureVehicleEx( + cc.VIN, client.Vehicles, + func(v *tesla.Vehicle) string { + return v.Vin + }, + ) + if err != nil { + return nil, err + } + + if v.Title_ == "" { + v.Title_ = v.vehicle.DisplayName + } + + v.dataG = provider.Cached(func() (*tesla.VehicleData, error) { + res, err := v.vehicle.Data() + return res, v.apiError(err) + }, cc.Cache) + + return v, nil +} + +// apiError converts HTTP 408 error to ErrTimeout +func (v *TeslaProxy) apiError(err error) error { + if err != nil && err.Error() == "408 Request Timeout" { + err = api.ErrAsleep + } + return err +} + +// Soc implements the api.Vehicle interface +func (v *TeslaProxy) Soc() (float64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + return float64(res.Response.ChargeState.UsableBatteryLevel), nil +} + +var _ api.ChargeState = (*TeslaProxy)(nil) + +// Status implements the api.ChargeState interface +func (v *TeslaProxy) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + res, err := v.dataG() + if err != nil { + return status, err + } + + switch res.Response.ChargeState.ChargingState { + case "Stopped", "NoPower", "Complete": + status = api.StatusB + case "Charging": + status = api.StatusC + } + + return status, nil +} + +var _ api.ChargeRater = (*TeslaProxy)(nil) + +// ChargedEnergy implements the api.ChargeRater interface +func (v *TeslaProxy) ChargedEnergy() (float64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + return res.Response.ChargeState.ChargeEnergyAdded, nil +} + +var _ api.VehicleRange = (*TeslaProxy)(nil) + +// Range implements the api.VehicleRange interface +func (v *TeslaProxy) Range() (int64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + // miles to km + return int64(kmPerMile * res.Response.ChargeState.BatteryRange), nil +} + +var _ api.VehicleOdometer = (*TeslaProxy)(nil) + +// Odometer implements the api.VehicleOdometer interface +func (v *TeslaProxy) Odometer() (float64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + // miles to km + return kmPerMile * res.Response.VehicleState.Odometer, nil +} + +var _ api.VehicleFinishTimer = (*TeslaProxy)(nil) + +// FinishTime implements the api.VehicleFinishTimer interface +func (v *TeslaProxy) FinishTime() (time.Time, error) { + res, err := v.dataG() + if err != nil { + return time.Time{}, err + } + return time.Now().Add(time.Duration(res.Response.ChargeState.MinutesToFullCharge) * time.Minute), nil +} + +// TODO api.Climater implementation has been removed as it drains battery. Re-check at a later time. + +var _ api.VehiclePosition = (*TeslaProxy)(nil) + +// Position implements the api.VehiclePosition interface +func (v *TeslaProxy) Position() (float64, float64, error) { + res, err := v.dataG() + if err != nil { + return 0, 0, err + } + return res.Response.DriveState.Latitude, res.Response.DriveState.Longitude, nil +} + +var _ api.SocLimiter = (*TeslaProxy)(nil) + +// TargetSoc implements the api.SocLimiter interface +func (v *TeslaProxy) TargetSoc() (float64, error) { + res, err := v.dataG() + if err != nil { + return 0, err + } + return float64(res.Response.ChargeState.ChargeLimitSoc), nil +} + +var _ api.CurrentController = (*TeslaProxy)(nil) + +// MaxCurrent implements the api.CurrentLimiter interface +func (v *TeslaProxy) MaxCurrent(current int64) error { + return v.apiError(v.vehicle.SetChargingAmps(int(current))) +} + +var _ api.Resurrector = (*TeslaProxy)(nil) + +// WakeUp implements the api.Resurrector interface +func (v *TeslaProxy) WakeUp() error { + _, err := v.vehicle.Wakeup() + return v.apiError(err) +} + +var _ api.VehicleChargeController = (*TeslaProxy)(nil) + +// StartCharge implements the api.VehicleChargeController interface +func (v *TeslaProxy) StartCharge() error { + err := v.apiError(v.vehicle.StartCharging()) + if err != nil && slices.Contains([]string{"complete", "is_charging"}, err.Error()) { + return nil + } + return err +} + +// StopCharge implements the api.VehicleChargeController interface +func (v *TeslaProxy) StopCharge() error { + err := v.apiError(v.vehicle.StopCharging()) + + // ignore sleeping vehicle + if errors.Is(err, api.ErrAsleep) { + err = nil + } + + return err +} diff --git a/vehicle/tesla.go b/vehicle/tesla.go index 1e5eb2665..3089ab293 100644 --- a/vehicle/tesla.go +++ b/vehicle/tesla.go @@ -129,8 +129,6 @@ func (v *Tesla) ChargedEnergy() (float64, error) { return res.Response.ChargeState.ChargeEnergyAdded, nil } -const kmPerMile = 1.609344 - var _ api.VehicleRange = (*Tesla)(nil) // Range implements the api.VehicleRange interface diff --git a/vehicle/types.go b/vehicle/types.go index c30b58bf2..aef067ae1 100644 --- a/vehicle/types.go +++ b/vehicle/types.go @@ -7,6 +7,8 @@ import ( "golang.org/x/oauth2" ) +const kmPerMile = 1.609344 + // ClientCredentials contains OAuth2 client id and secret type ClientCredentials struct { ID, Secret string