diff --git a/go.mod b/go.mod index 6935909d8..87e9d5a2f 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08 // indirect github.com/hashicorp/go-version v1.2.0 // indirect github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d + github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a // indirect github.com/jsgoecke/tesla v0.0.0-20190206234002-112508e1374e github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 github.com/mitchellh/mapstructure v1.2.2 diff --git a/go.sum b/go.sum index 6082d1e40..cafae0918 100644 --- a/go.sum +++ b/go.sum @@ -135,6 +135,8 @@ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NH github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d h1:/WZQPMZNsjZ7IlCpsLGdQBINg5bxKQ1K1sh6awxLtkA= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a h1:wOlcxK/k5rPhtMyEFVayqVh0x3lWvnyEaK8Aw96zx4M= +github.com/joeshaw/carwings v0.0.0-20191118152321-61b46581307a/go.mod h1:tB0OlpicmRVTL1Vksc5XRiYo+wkK2kl/GI7eGMIl6Rs= github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo= github.com/jsgoecke/tesla v0.0.0-20190206234002-112508e1374e h1:IONO/IEnmILgcxbjiXKauCsm+47xnP3FHUlsaPkVTC4= github.com/jsgoecke/tesla v0.0.0-20190206234002-112508e1374e/go.mod h1:GlLJU/C6KA3FEAIZTFDvIDlNGE1+YlKGHHXdNVkt5ow= @@ -182,6 +184,7 @@ github.com/pelletier/go-toml v1.2.0 h1:T5zMGML61Wp+FlcbWjRDT7yAxhJNAiPPLOFECq181 github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pelletier/go-toml v1.6.0 h1:aetoXYr0Tv7xRU/V4B4IZJ2QcbtMUFoNb3ORp7TzIK4= github.com/pelletier/go-toml v1.6.0/go.mod h1:5N711Q9dKgbdkxHL+MEfF31hpT7l0S0s/t2kKREewys= +github.com/peterbourgon/ff v1.2.0/go.mod h1:ljiF7yxtUvZaxUDyUqQa0+uiEOgwVboj+Q2S2+0nq40= github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= @@ -257,6 +260,7 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2 h1:VklqNMn3ovrHsnt90Pveol golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 h1:ObdrDkeb4kJdCP557AjRjq69pTHfNouLtWZG7j9rPN8= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= diff --git a/vehicle/audi.go b/vehicle/audi.go index 204f2efff..6de4b3152 100644 --- a/vehicle/audi.go +++ b/vehicle/audi.go @@ -15,10 +15,9 @@ import ( ) const ( - audiURL = "https://msg.audi.de/fs-car" - audiDE = "Audi/DE" - audiAuthPrefix = "AudiAuth 1" - audiValidMargin = 10 * time.Second + audiURL = "https://msg.audi.de/fs-car" + audiDE = "Audi/DE" + audiAuthPrefix = "AudiAuth 1" ) // Audi is an api.Vehicle implementation for Audi cars @@ -27,7 +26,6 @@ type Audi struct { user, password, vin string token string tokenValid time.Time - cache time.Duration chargeStateG provider.FloatGetter } @@ -36,6 +34,7 @@ type audiTokenResponse struct { TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` } + type audiErrorResponse struct { Error string Description string `json:"error_description"` @@ -76,7 +75,6 @@ func NewAudiFromConfig(log *api.Logger, other map[string]interface{}) api.Vehicl user: cc.User, password: cc.Password, vin: cc.VIN, - cache: cc.Cache, } v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() @@ -150,7 +148,7 @@ func (v *Audi) login(user, password string) error { } v.token = tr.AccessToken - v.tokenValid = time.Now().Add(time.Duration(tr.ExpiresIn)*time.Second - audiValidMargin) + v.tokenValid = time.Now().Add(time.Duration(tr.ExpiresIn)*time.Second - tokenValidMargin) return nil } diff --git a/vehicle/bmw.go b/vehicle/bmw.go new file mode 100644 index 000000000..37c0d5f2c --- /dev/null +++ b/vehicle/bmw.go @@ -0,0 +1,174 @@ +package vehicle + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "strings" + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" +) + +const ( + bmwURL = "https://b2vapi.bmwgroup.com/webapi" +) + +type bmwTokenResponse struct { + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int `json:"expires_in"` + RefreshToken string `json:"refresh_token"` + Scope string `json:"scope"` +} + +type bmwStatusResponse struct { + VehicleStatus bmwVehicleStatus `json:"vehicleStatus"` +} + +type bmwVehicleStatus struct { + ConnectionStatus string `json:"connectionStatus"` + ChargingStatus string `json:"chargingStatus"` + ChargingLevelHv int `json:"chargingLevelHv"` + ChargingTimeRemaining int `json:"chargingTimeRemaining"` +} + +// BMW is an api.Vehicle implementation for BMW cars +type BMW struct { + *embed + user, password, vin string + token, refreshToken string + tokenValid time.Time + chargeStateG provider.FloatGetter +} + +// NewBMWFromConfig creates a new vehicle +func NewBMWFromConfig(log *api.Logger, other map[string]interface{}) api.Vehicle { + cc := struct { + Title string + Capacity int64 + User, Password, VIN string + Cache time.Duration + }{} + api.DecodeOther(log, other, &cc) + + v := &BMW{ + embed: &embed{cc.Title, cc.Capacity}, + user: cc.User, + password: cc.Password, + vin: cc.VIN, + } + + v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + + return v +} + +func (v *BMW) apiURL(service string) string { + return fmt.Sprintf("%s/%s", bmwURL, service) +} + +func (v *BMW) authHeader() string { + token := base64.StdEncoding.EncodeToString([]byte(fmt.Sprintf("%s:%s", v.user, v.password))) + return fmt.Sprintf("Basic %s", token) +} + +func (v *BMW) login(user, password string) error { + uri := v.apiURL("oauth/token") + + data := url.Values{ + "grant_type": []string{"password"}, + "username": []string{user}, + "password": []string{password}, + "scope": []string{"remote_services vehicle_data"}, + } + + req, err := http.NewRequest(http.MethodPost, uri, strings.NewReader(data.Encode())) + if err != nil { + return err + } + + req.Header.Set("Authorization", v.authHeader()) + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("unexpected response %d: %s", resp.StatusCode, string(b)) + } + + var tr bmwTokenResponse + if err = json.Unmarshal(b, &tr); err != nil { + return err + } + + v.token = tr.AccessToken + v.refreshToken = tr.RefreshToken + v.tokenValid = time.Now().Add(time.Duration(tr.ExpiresIn)*time.Second - tokenValidMargin) + + return nil +} + +// @TODO implement refresh_token +func (v *BMW) request(uri string) (*http.Request, error) { + // token invalid or expired + if v.token == "" || time.Now().After(v.tokenValid) { + if err := v.login(v.user, v.password); err != nil { + return nil, err + } + } + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err != nil { + return req, err + } + + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", v.token)) + + return req, nil +} + +// chargeState implements the Vehicle.ChargeState interface +func (v *BMW) chargeState() (float64, error) { + uri := v.apiURL(fmt.Sprintf("v1/user/vehicles/%s/status", v.vin)) + req, err := v.request(uri) + if err != nil { + return 0, err + } + + client := &http.Client{} + resp, err := client.Do(req) + if err != nil { + return 0, err + } + defer resp.Body.Close() + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return 0, err + } + + var br bmwStatusResponse + err = json.Unmarshal(b, &br) + + return float64(br.VehicleStatus.ChargingLevelHv), err +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *BMW) ChargeState() (float64, error) { + return v.chargeStateG() +} diff --git a/vehicle/config.go b/vehicle/config.go index 21f9e720d..574454ce3 100644 --- a/vehicle/config.go +++ b/vehicle/config.go @@ -6,17 +6,21 @@ import ( "github.com/andig/evcc/api" ) -// NewFromConfig creates charger from configuration +// NewFromConfig creates vehicle from configuration func NewFromConfig(log *api.Logger, typ string, other map[string]interface{}) api.Vehicle { var c api.Vehicle switch strings.ToLower(typ) { case "script", "exec": c = NewConfigurableFromConfig(log, other) - case "audi": + case "audi", "etron": c = NewAudiFromConfig(log, other) - case "tesla": + case "bmw", "i3": + c = NewBMWFromConfig(log, other) + case "tesla", "model3", "model 3", "models", "model s": c = NewTeslaFromConfig(log, other) + case "nissan", "leaf": + c = NewNissanFromConfig(log, other) default: log.FATAL.Fatalf("invalid vehicle type '%s'", typ) } diff --git a/vehicle/nissan.go b/vehicle/nissan.go new file mode 100644 index 000000000..61ef40838 --- /dev/null +++ b/vehicle/nissan.go @@ -0,0 +1,60 @@ +package vehicle + +import ( + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" + + "github.com/joeshaw/carwings" +) + +// Nissan is an api.Vehicle implementation for Nissan cars +type Nissan struct { + *embed + session *carwings.Session + chargeStateG provider.FloatGetter +} + +// NewNissanFromConfig creates a new vehicle +func NewNissanFromConfig(log *api.Logger, other map[string]interface{}) api.Vehicle { + cc := struct { + Title string + Capacity int64 + User, Password, Region string + Cache time.Duration + }{} + api.DecodeOther(log, other, &cc) + + if cc.Region == "" { + cc.Region = carwings.RegionEurope + } + + session := &carwings.Session{ + Region: cc.Region, + } + + if err := session.Connect(cc.User, cc.Password); err != nil { + log.FATAL.Fatalf("cannot create nissan: %v", err) + } + + v := &Nissan{ + embed: &embed{cc.Title, cc.Capacity}, + session: session, + } + + v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + + return v +} + +// chargeState implements the Vehicle.ChargeState interface +func (v *Nissan) chargeState() (float64, error) { + bs, err := v.session.BatteryStatus() + return float64(bs.StateOfCharge), err +} + +// ChargeState implements the Vehicle.ChargeState interface +func (v *Nissan) ChargeState() (float64, error) { + return v.chargeStateG() +} diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go index 289fe3c9a..b97b4afde 100644 --- a/vehicle/vehicle.go +++ b/vehicle/vehicle.go @@ -1,10 +1,16 @@ package vehicle import ( + "time" + "github.com/andig/evcc/api" "github.com/andig/evcc/provider" ) +const ( + tokenValidMargin = 10 * time.Second // safety margin for api token validity +) + type embed struct { title string capacity int64