Add Tronity support (#1268)
This commit is contained in:
parent
aa4ade84ce
commit
383d450637
12 changed files with 567 additions and 49 deletions
|
|
@ -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
70
cmd/token.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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
145
cmd/token_tronity.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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
233
vehicle/tronity.go
Normal 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
19
vehicle/tronity/auth.go
Normal 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
39
vehicle/tronity/types.go
Normal 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
35
vehicle/types.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue