From 383d450637b09100bbd361d8d6d644072173c467 Mon Sep 17 00:00:00 2001 From: andig Date: Wed, 4 Aug 2021 08:57:40 +0200 Subject: [PATCH] Add Tronity support (#1268) --- README.md | 3 +- cmd/token.go | 70 ++++++++++ cmd/{tesla.go => token_tesla.go} | 45 ++---- cmd/token_tronity.go | 145 +++++++++++++++++++ util/request/functions.go | 8 ++ util/request/helper.go | 4 +- vehicle/psa.go | 2 +- vehicle/tesla.go | 13 +- vehicle/tronity.go | 233 +++++++++++++++++++++++++++++++ vehicle/tronity/auth.go | 19 +++ vehicle/tronity/types.go | 39 ++++++ vehicle/types.go | 35 +++++ 12 files changed, 567 insertions(+), 49 deletions(-) create mode 100644 cmd/token.go rename cmd/{tesla.go => token_tesla.go} (74%) create mode 100644 cmd/token_tronity.go create mode 100644 vehicle/tronity.go create mode 100644 vehicle/tronity/auth.go create mode 100644 vehicle/tronity/types.go create mode 100644 vehicle/types.go diff --git a/README.md b/README.md index f8fcff286..ca192c65f 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G - Wallbe, Phoenix (includes ESL Walli), go-eCharger, NRGkick (direct Bluetooth or via Connect device), SimpleEVSE, EVSEWifi, KEBA/BMW, openWB, Mobile Charger Connect and any other charger using scripting - Smart-Home outlets: FritzDECT, Shelly, Tasmota, TP-Link - multiple [meters](#meter): 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 -- wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Ford, Hyundai, Kia, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo and any other connected vehicle using scripting +- wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Ford, Hyundai, Kia, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo, Tronity and any other connected vehicle using scripting - [plugins](#plugins) for integrating with hardware devices and home automation: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts - status notifications using [Telegram](https://telegram.org) and [PushOver](https://pushover.net) - logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/) @@ -278,6 +278,7 @@ Available vehicle remote interface implementations are: - `vw`: Volkswagen (eGolf, eUp) - `id`: Volkswagen (ID.3, ID.4) - `volvo`: Volvo +- `tronity`: Tronity (available to Github sponsors only, request sponsor token at https://cloud.evcc.io/) - `custom`: default vehicle implementation using configurable [plugins](#plugins) for integrating any type of vehicle Configuration examples are documented at [andig/evcc-config#vehicles](https://github.com/andig/evcc-config#vehicles) diff --git a/cmd/token.go b/cmd/token.go new file mode 100644 index 000000000..e34100d79 --- /dev/null +++ b/cmd/token.go @@ -0,0 +1,70 @@ +package cmd + +import ( + "fmt" + "strings" + + "github.com/andig/evcc/server" + "github.com/andig/evcc/util" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "github.com/thoas/go-funk" + "golang.org/x/oauth2" +) + +// tokenCmd represents the vehicle command +var tokenCmd = &cobra.Command{ + Use: "token [vehicle name]", + Short: "Generate token credentials", + Run: runToken, +} + +func init() { + rootCmd.AddCommand(tokenCmd) +} + +func runToken(cmd *cobra.Command, args []string) { + util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels")) + log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit) + + // load config + conf, err := loadConfigFile(cfgFile) + if err != nil { + log.FATAL.Fatal(err) + } + + var vehicleConf qualifiedConfig + if len(conf.Vehicles) == 1 { + vehicleConf = conf.Vehicles[0] + } else if len(args) == 1 { + vehicleConf = funk.Find(conf.Vehicles, func(v qualifiedConfig) bool { + return strings.EqualFold(v.Name, args[0]) + }).(qualifiedConfig) + } + + if vehicleConf.Name == "" { + log.FATAL.Fatal("vehicle not found") + } + + var token *oauth2.Token + + switch strings.ToLower(vehicleConf.Type) { + case "tesla": + token, err = teslaToken() + case "tronity": + token, err = tronityToken(conf, vehicleConf) + default: + log.FATAL.Fatalf("vehicle type '%s' does not support token authentication", vehicleConf.Type) + } + + if err != nil { + log.FATAL.Fatal(err) + } + + fmt.Println() + fmt.Println("Add the following tokens to the vehicle config:") + fmt.Println() + fmt.Println(" tokens:") + fmt.Println(" access:", token.AccessToken) + fmt.Println(" refresh:", token.RefreshToken) +} diff --git a/cmd/tesla.go b/cmd/token_tesla.go similarity index 74% rename from cmd/tesla.go rename to cmd/token_tesla.go index ae98f896c..ab2d6f201 100644 --- a/cmd/tesla.go +++ b/cmd/token_tesla.go @@ -9,28 +9,13 @@ import ( "os" "strings" - "github.com/andig/evcc/server" - "github.com/andig/evcc/util" "github.com/andig/evcc/util/request" "github.com/bogosj/tesla" "github.com/manifoldco/promptui" "github.com/skratchdot/open-golang/open" - "github.com/spf13/cobra" - "github.com/spf13/viper" "golang.org/x/oauth2" ) -// teslaCmd represents the vehicle command -var teslaCmd = &cobra.Command{ - Use: "tesla-token", - Short: "Generate Tesla token credentials", - Run: runTeslaToken, -} - -func init() { - rootCmd.AddCommand(teslaCmd) -} - // copied from https://github.com/bogosj/tesla func getUsernameAndPassword() (string, string, error) { user, err := (&promptui.Prompt{ @@ -128,7 +113,12 @@ func captchaPrompt(ctx context.Context, svg io.Reader) (string, error) { return strings.TrimSpace(captcha), err } -func generateToken(username, password string) { +func teslaToken() (*oauth2.Token, error) { + username, password, err := getUsernameAndPassword() + if err != nil { + return nil, err + } + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewHelper(log).Client) client, err := tesla.NewClient( ctx, @@ -137,30 +127,13 @@ func generateToken(username, password string) { tesla.WithCredentials(username, password), ) if err != nil { - log.FATAL.Fatalln(err) + return nil, err } token, err := client.Token() if err != nil { - log.FATAL.Fatalln(err) + return nil, err } - fmt.Println() - fmt.Println("Add the following tokens to the tesla vehicle config:") - fmt.Println() - fmt.Println(" tokens:") - fmt.Println(" access:", token.AccessToken) - fmt.Println(" refresh:", token.RefreshToken) -} - -func runTeslaToken(cmd *cobra.Command, args []string) { - util.LogLevel(viper.GetString("log"), viper.GetStringMapString("levels")) - log.INFO.Printf("evcc %s (%s)", server.Version, server.Commit) - - user, password, err := getUsernameAndPassword() - if err != nil { - log.FATAL.Fatal(err) - } - - generateToken(user, password) + return token, nil } diff --git a/cmd/token_tronity.go b/cmd/token_tronity.go new file mode 100644 index 000000000..17474ac96 --- /dev/null +++ b/cmd/token_tronity.go @@ -0,0 +1,145 @@ +package cmd + +import ( + "context" + "crypto/rand" + "encoding/base64" + "errors" + "fmt" + "io" + "net" + "net/http" + "strings" + "sync" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/util" + "github.com/andig/evcc/vehicle" + "github.com/andig/evcc/vehicle/tronity" + "github.com/skratchdot/open-golang/open" + "golang.org/x/oauth2" +) + +// github.com/uhthomas/tesla +func state() string { + var b [9]byte + if _, err := io.ReadFull(rand.Reader, b[:]); err != nil { + panic(err) + } + return base64.RawURLEncoding.EncodeToString(b[:]) +} + +func tokenExchangeHandler(oc *oauth2.Config, state string, resC chan *oauth2.Token) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + if remote := r.URL.Query().Get("state"); state != remote { + w.WriteHeader(http.StatusBadRequest) + resC <- nil + return + } + + code := r.URL.Query().Get("code") + + ctx := context.Background() + token, err := oc.Exchange(ctx, code, + oauth2.SetAuthURLParam("grant_type", "code"), // app + ) + + if err != nil { + w.WriteHeader(http.StatusBadRequest) + fmt.Fprintln(w, err) + resC <- nil + return + } + + fmt.Fprintln(w, "Token received, see console") + resC <- token + } +} + +func tronityAuthorize(addr string, oc *oauth2.Config) (*oauth2.Token, error) { + state := state() + + uri := oc.AuthCodeURL(state, oauth2.AccessTypeOffline) + uri = strings.ReplaceAll(uri, "scope=", "scopes=") + + if err := open.Start(uri); err != nil { + return nil, err + } + + resC := make(chan *oauth2.Token) + handler := tokenExchangeHandler(oc, state, resC) + defer close(resC) + + // handle request + mux := &http.ServeMux{} + mux.HandleFunc("/auth/tronity", handler) + + wg := new(sync.WaitGroup) + s := &http.Server{ + Addr: addr, + Handler: mux, + } + + // start server + wg.Add(1) + go func() { + if err := s.ListenAndServe(); err != http.ErrServerClosed { + log.FATAL.Fatal(err) + } + wg.Done() + }() + + // close on exit + defer func() { + _ = s.Close() + wg.Wait() + close(resC) + }() + + t := time.NewTimer(time.Minute) + + select { + case <-t.C: + return nil, api.ErrTimeout + + case token := <-resC: + if token == nil { + return nil, errors.New("token not received") + } + + return token, nil + } +} + +func tronityToken(conf config, vehicleConf qualifiedConfig) (*oauth2.Token, error) { + cc := struct { + Credentials vehicle.ClientCredentials + RedirectURI string + Other map[string]interface{} `mapstructure:",remain"` + }{} + + if err := util.DecodeOther(vehicleConf.Other, &cc); err != nil { + return nil, err + } + + if err := cc.Credentials.Error(); err != nil { + return nil, err + } + + oc, err := tronity.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret) + if err != nil { + return nil, err + } + + if oc.RedirectURL = cc.RedirectURI; oc.RedirectURL == "" { + _, port, err := net.SplitHostPort(conf.URI) + if err != nil { + return nil, err + } + + oc.RedirectURL = fmt.Sprintf("http://%s/auth/tronity", net.JoinHostPort("localhost", port)) + } + + return tronityAuthorize(conf.URI, oc) +} diff --git a/util/request/functions.go b/util/request/functions.go index c30c85d28..2ac67501c 100644 --- a/util/request/functions.go +++ b/util/request/functions.go @@ -56,6 +56,14 @@ func (e StatusError) HasStatus(codes ...int) bool { return false } +// ResponseError turns an HTTP status code into an error +func ResponseError(resp *http.Response) error { + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return StatusError{resp: resp} + } + return nil +} + // ReadBody reads HTTP response and returns error on response codes other than HTTP 2xx. It closes the request body after reading. func ReadBody(resp *http.Response) ([]byte, error) { defer resp.Body.Close() diff --git a/util/request/helper.go b/util/request/helper.go index aca42b26d..7ab46116b 100644 --- a/util/request/helper.go +++ b/util/request/helper.go @@ -50,8 +50,8 @@ func (r *Helper) GetBody(url string) ([]byte, error) { // decodeJSON reads HTTP response and decodes JSON body if error is nil func decodeJSON(resp *http.Response, res interface{}) error { - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return StatusError{resp: resp} + if err := ResponseError(resp); err != nil { + return err } return json.NewDecoder(resp.Body).Decode(&res) diff --git a/vehicle/psa.go b/vehicle/psa.go index 8fa41f948..fe15ab0ed 100644 --- a/vehicle/psa.go +++ b/vehicle/psa.go @@ -59,7 +59,7 @@ func newPSA(log *util.Logger, brand, realm string, other map[string]interface{}) } if cc.ClientID == "" || cc.ClientSecret == "" { - return nil, errors.New("missing client id and secret (see https://github.com/flobz/psa_car_controller)") + return nil, errors.New("missing client id and/or secret (see https://github.com/flobz/psa_car_controller)") } v := &PSA{ diff --git a/vehicle/tesla.go b/vehicle/tesla.go index 3867a364b..fe054965e 100644 --- a/vehicle/tesla.go +++ b/vehicle/tesla.go @@ -22,11 +22,6 @@ type Tesla struct { climateStateG func() (interface{}, error) } -// teslaTokens contains access and refresh tokens -type teslaTokens struct { - Access, Refresh string -} - func init() { registry.Add("tesla", NewTeslaFromConfig) } @@ -36,7 +31,7 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` User, Password string // deprecated - Tokens teslaTokens + Tokens Tokens VIN string Cache time.Duration }{ @@ -48,11 +43,11 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) { } if cc.User != "" { - return nil, errors.New("user/password authentication deprecated, use tesla-token command to create credentials") + return nil, errors.New("user/password authentication deprecated, use `evcc token` to create credentials") } - if cc.Tokens.Access == "" { - return nil, errors.New("missing token credentials") + if err := cc.Tokens.Error(); err != nil { + return nil, err } v := &Tesla{ diff --git a/vehicle/tronity.go b/vehicle/tronity.go new file mode 100644 index 000000000..3e3176da2 --- /dev/null +++ b/vehicle/tronity.go @@ -0,0 +1,233 @@ +package vehicle + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/oauth" + "github.com/andig/evcc/util/request" + "github.com/andig/evcc/util/sponsor" + "github.com/andig/evcc/vehicle/tronity" + "golang.org/x/oauth2" +) + +// Tronity is an api.Vehicle implementation for the Tronity api +type Tronity struct { + *embed + *request.Helper + log *util.Logger + oc *oauth2.Config + vid string + bulkG func() (interface{}, error) +} + +func init() { + registry.Add("tronity", NewTronityFromConfig) +} + +// NewTronityFromConfig creates a new Tronity vehicle +func NewTronityFromConfig(other map[string]interface{}) (api.Vehicle, error) { + cc := struct { + embed `mapstructure:",squash"` + Credentials ClientCredentials + Tokens Tokens + VIN string + Cache time.Duration + }{ + Cache: interval, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if err := cc.Credentials.Error(); err != nil { + return nil, err + } + + if !sponsor.IsAuthorized() { + return nil, errors.New("tronity requires evcc sponsorship, register at https://cloud.evcc.io") + } + + // authenticated http client with logging injected to the tronity client + log := util.NewLogger("tronity") + + oc, err := tronity.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret) + if err != nil { + return nil, err + } + + v := &Tronity{ + log: log, + embed: &cc.embed, + Helper: request.NewHelper(log), + oc: oc, + } + + var ts oauth2.TokenSource + + // https://app.platform.tronity.io/docs#tag/Authentication + if err := cc.Tokens.Error(); err != nil { + // use app flow if we don't have tokens + ts = oauth.RefreshTokenSource(&oauth2.Token{}, v) + } else { + // use provided tokens generated by code flow + ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewHelper(log).Client) + ts = oc.TokenSource(ctx, &oauth2.Token{ + AccessToken: cc.Tokens.Access, + RefreshToken: cc.Tokens.Refresh, + Expiry: time.Now(), + }) + } + + // replace client transport with authenticated transport + v.Client.Transport = &oauth2.Transport{ + Source: ts, + Base: v.Client.Transport, + } + + vehicles, err := v.vehicles() + if err != nil { + return nil, err + } + + if cc.VIN == "" && len(vehicles) == 1 { + v.vid = vehicles[0].ID + } else { + for _, vehicle := range vehicles { + if vehicle.VIN == strings.ToUpper(cc.VIN) { + v.vid = vehicle.ID + } + } + } + + if v.vid == "" { + return nil, errors.New("vin not found") + } + + v.bulkG = provider.NewCached(v.bulk, cc.Cache).InterfaceGetter() + + return v, nil +} + +// RefreshToken performs token refresh by logging in with app context +func (v *Tronity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { + data := struct { + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` + GrantType string `json:"grant_type"` + }{ + ClientID: v.oc.ClientID, + ClientSecret: v.oc.ClientSecret, + GrantType: "app", + } + + req, err := request.New(http.MethodPost, v.oc.Endpoint.TokenURL, request.MarshalJSON(data), request.JSONEncoding) + if err != nil { + return nil, err + } + + var token oauth2.Token + err = request.NewHelper(v.log).DoJSON(req, &token) + + return &token, err +} + +// vehicles implements the vehicles api +func (v *Tronity) vehicles() ([]tronity.Vehicle, error) { + uri := fmt.Sprintf("%s/v1/vehicles", tronity.URI) + + var res tronity.Vehicles + err := v.GetJSON(uri, &res) + + return res.Data, err +} + +// bulk implements the bulk api +func (v *Tronity) bulk() (interface{}, error) { + uri := fmt.Sprintf("%s/v1/vehicles/%s/bulk", tronity.URI, v.vid) + + var res tronity.Bulk + err := v.GetJSON(uri, &res) + + return res, err +} + +// SoC implements the api.Vehicle interface +func (v *Tronity) SoC() (float64, error) { + res, err := v.bulkG() + + if res, ok := res.(tronity.Bulk); err == nil && ok { + return float64(res.Level), nil + } + + return 0, err +} + +var _ api.ChargeState = (*Tronity)(nil) + +// Status implements the api.ChargeState interface +func (v *Tronity) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + res, err := v.bulkG() + + if res, ok := res.(tronity.Bulk); err == nil && ok { + if res.Charging == "Charging" { + status = api.StatusC + } + } + + return status, err +} + +var _ api.VehicleRange = (*Tronity)(nil) + +// Range implements the api.VehicleRange interface +func (v *Tronity) Range() (int64, error) { + res, err := v.bulkG() + + if res, ok := res.(tronity.Bulk); err == nil && ok { + return int64(res.Range), nil + } + + return 0, err +} + +var _ api.VehicleStartCharge = (*Tronity)(nil) + +func (v *Tronity) post(uri string) error { + resp, err := v.Post(uri, "", nil) + if err == nil { + err = request.ResponseError(resp) + } + + // ignore HTTP 405 + if err != nil { + if err2, ok := err.(request.StatusError); ok && err2.HasStatus(http.StatusMethodNotAllowed) { + err = nil + } + } + + return err +} + +// StartCharge implements the api.VehicleStartCharge interface +func (v *Tronity) StartCharge() error { + uri := fmt.Sprintf("%s/v1/vehicles/%s/charge_start", tronity.URI, v.vid) + return v.post(uri) +} + +var _ api.VehicleStopCharge = (*Tronity)(nil) + +// StopCharge implements the api.VehicleStopCharge interface +func (v *Tronity) StopCharge() error { + uri := fmt.Sprintf("%s/v1/vehicles/%s/charge_stop", tronity.URI, v.vid) + return v.post(uri) +} diff --git a/vehicle/tronity/auth.go b/vehicle/tronity/auth.go new file mode 100644 index 000000000..00257ddde --- /dev/null +++ b/vehicle/tronity/auth.go @@ -0,0 +1,19 @@ +package tronity + +import ( + "golang.org/x/oauth2" +) + +const URI = "https://api-eu.tronity.io" + +func OAuth2Config(id, secret string) (*oauth2.Config, error) { + return &oauth2.Config{ + ClientID: id, + ClientSecret: secret, + Endpoint: oauth2.Endpoint{ + AuthURL: "https://api-eu.tronity.io/oauth/authorize", + TokenURL: "https://api-eu.tronity.io/oauth/authentication", + }, + Scopes: []string{"read_vin", "read_vehicle_info", "read_odometer", "read_charge", "read_charge", "read_battery", "read_location", "write_charge_start_stop", "write_wake_up"}, + }, nil +} diff --git a/vehicle/tronity/types.go b/vehicle/tronity/types.go new file mode 100644 index 000000000..806f82a2f --- /dev/null +++ b/vehicle/tronity/types.go @@ -0,0 +1,39 @@ +package tronity + +type Vehicles struct { + Data []Vehicle +} + +type Vehicle struct { + ID string + VIN string + DisplayName string + Manufacture string +} + +type Bulk struct { + VIN string + Odometer int + Range int + Level int + Charging string // Charging + Latitude float64 + Longitude float64 + Timestamp int64 +} + +type Odometer struct { + Odometer int + Timestamp int64 +} + +type EVBatteryLevel struct { + Range int + Level int + Timestamp int64 +} + +type EVChargingStatus struct { + Charging string + Timestamp int64 +} diff --git a/vehicle/types.go b/vehicle/types.go new file mode 100644 index 000000000..21a128b25 --- /dev/null +++ b/vehicle/types.go @@ -0,0 +1,35 @@ +package vehicle + +import "errors" + +// ClientCredentials contains OAuth2 client id and secret +type ClientCredentials struct { + ID, Secret string +} + +// Error validates the credentials and returns an error if they are incomplete +func (c *ClientCredentials) Error() error { + if c.ID == "" { + return errors.New("missing credentials id") + } + + if c.Secret == "" { + return errors.New("missing credentials secret") + } + + return nil +} + +// Tokens contains access and refresh tokens +type Tokens struct { + Access, Refresh string +} + +// Error validates the token and returns an error if they are incomplete +func (t *Tokens) Error() error { + if t.Access == "" || t.Refresh == "" { + return errors.New("missing access and/or refresh token, use `evcc token` to create") + } + + return nil +}