Add Tronity support (#1268)

This commit is contained in:
andig 2021-08-04 08:57:40 +02:00
parent aa4ade84ce
commit 383d450637
12 changed files with 567 additions and 49 deletions

View file

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

70
cmd/token.go Normal file
View file

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

View file

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

145
cmd/token_tronity.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

233
vehicle/tronity.go Normal file
View file

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

19
vehicle/tronity/auth.go Normal file
View file

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

39
vehicle/tronity/types.go Normal file
View file

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

35
vehicle/types.go Normal file
View file

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