From 08894e2f8c460fd16aa9962a03a8763e400228cc Mon Sep 17 00:00:00 2001 From: andig Date: Thu, 12 Aug 2021 14:44:28 +0200 Subject: [PATCH] BMW: upgrade api and add Mini (#1320) --- README.md | 3 +- vehicle/bmw.go | 155 +++++----------------------------------- vehicle/bmw/api.go | 85 ++++++++++++++++++++++ vehicle/bmw/identity.go | 93 ++++++++++++++++++++++++ vehicle/bmw/provider.go | 79 ++++++++++++++++++++ vehicle/cloud.go | 2 +- 6 files changed, 277 insertions(+), 140 deletions(-) create mode 100644 vehicle/bmw/api.go create mode 100644 vehicle/bmw/identity.go create mode 100644 vehicle/bmw/provider.go diff --git a/README.md b/README.md index 2fbc7dae0..e284b7f66 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 (includes Heidelberg Energy Control), NRGkick, SimpleEVSE, EVSEWifi, KEBA/BMW, openWB, Mobile Charger Connect - 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, Tronity +- wide support of vendor-specific [vehicles](#vehicle) interfaces (remote charge, battery and preconditioning status): Audi, BMW, Ford, Hyundai, Kia, Mini, Nissan, Niu, Porsche, Renault, Seat, Skoda, Tesla, Volkswagen, Volvo, Tronity - [plugins](#plugins) for integrating with any charger/ meter/ vehicle: Modbus (meters and grid inverters), HTTP, MQTT, Javascript, WebSockets and shell scripts - status notifications using [Telegram](https://telegram.org), [PushOver](https://pushover.net) and [many more](https://containrrr.dev/shoutrrr/) - logging using [InfluxDB](https://www.influxdata.com) and [Grafana](https://grafana.com/grafana/) @@ -267,6 +267,7 @@ Available vehicle remote interface implementations are: - `ford`: Ford (Kuga, Mustang) - `kia`: Kia (Soul and other Bluelink models) - `hyundai`: Hyundai (Bluelink vehicles like Kona or Ioniq) +- `mini`: Mini (Cooper SE) - `nissan`: Nissan (Leaf) - `niu`: Niu Scooter - `tesla`: Tesla (any model) diff --git a/vehicle/bmw.go b/vehicle/bmw.go index d83db8358..6ef342d08 100644 --- a/vehicle/bmw.go +++ b/vehicle/bmw.go @@ -1,47 +1,23 @@ package vehicle import ( - "errors" - "fmt" - "net/http" - "net/url" - "strconv" "strings" "time" "github.com/andig/evcc/api" - "github.com/andig/evcc/provider" "github.com/andig/evcc/util" - "github.com/andig/evcc/util/request" + "github.com/andig/evcc/vehicle/bmw" ) -const ( - bmwAuth = "https://customer.bmwgroup.com/gcdm/oauth/authenticate" - bmwAPI = "https://www.bmw-connecteddrive.com/api" -) - -type bmwDynamicResponse struct { - AttributesMap struct { - ChargingLevelHv float64 `json:"chargingLevelHv,string"` - } -} - -type bmwVehiclesResponse []struct { - VIN string `json:"vin"` -} - -// BMW is an api.Vehicle implementation for BMW cars +// BMW is an api.Vehicle implementation for BMW and Mini cars type BMW struct { *embed - *request.Helper - user, password, vin string - token string - tokenValid time.Time - chargeStateG func() (float64, error) + *bmw.Provider // provides the api implementations } func init() { registry.Add("bmw", NewBMWFromConfig) + registry.Add("mini", NewBMWFromConfig) } // NewBMWFromConfig creates a new vehicle @@ -58,125 +34,28 @@ func NewBMWFromConfig(other map[string]interface{}) (api.Vehicle, error) { return nil, err } - log := util.NewLogger("bmw") - v := &BMW{ - embed: &cc.embed, - Helper: request.NewHelper(log), - user: cc.User, - password: cc.Password, - vin: strings.ToUpper(cc.VIN), + embed: &cc.embed, } - v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter() + log := util.NewLogger("bmw") + identity := bmw.NewIdentity(log) + + if err := identity.Login(cc.User, cc.Password); err != nil { + return nil, err + } + + api := bmw.NewAPI(log, identity) var err error if cc.VIN == "" { - v.vin, err = findVehicle(v.vehicles()) + cc.VIN, err = findVehicle(api.Vehicles()) if err == nil { - log.DEBUG.Printf("found vehicle: %v", v.vin) + log.DEBUG.Printf("found vehicle: %v", cc.VIN) } } + v.Provider = bmw.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache) + return v, err } - -func (v *BMW) login(user, password string) error { - data := url.Values{ - "username": []string{user}, - "password": []string{password}, - "client_id": []string{"dbf0a542-ebd1-4ff0-a9a7-55172fbfce35"}, - "redirect_uri": []string{"https://www.bmw-connecteddrive.com/app/default/static/external-dispatch.html"}, - "response_type": []string{"token"}, - "scope": []string{"authenticate_user fupo"}, - "state": []string{"eyJtYXJrZXQiOiJkZSIsImxhbmd1YWdlIjoiZGUiLCJkZXN0aW5hdGlvbiI6ImxhbmRpbmdQYWdlIn0"}, - "locale": []string{"DE-de"}, - } - - req, err := request.New(http.MethodPost, bmwAuth, strings.NewReader(data.Encode()), request.URLEncoding) - if err != nil { - return err - } - - client := &http.Client{ - Timeout: v.Helper.Client.Timeout, - CheckRedirect: func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse }, // don't follow redirects - } - - resp, err := client.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - - query, err := url.ParseQuery(resp.Header.Get("Location")) - if err != nil { - return err - } - - token := query.Get("access_token") - expires, err := strconv.Atoi(query.Get("expires_in")) - if err != nil || token == "" || expires == 0 { - return errors.New("could not obtain token") - } - - v.token = token - v.tokenValid = time.Now().Add(time.Duration(expires) * time.Second) - - return nil -} - -func (v *BMW) request(uri string) (*http.Request, error) { - if v.token == "" || time.Since(v.tokenValid) > 0 { - if err := v.login(v.user, v.password); err != nil { - return nil, err - } - } - - req, err := http.NewRequest(http.MethodGet, uri, nil) - if err == nil { - req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", v.token)) - } - - return req, nil -} - -// vehicles implements returns the list of user vehicles -func (v *BMW) vehicles() ([]string, error) { - var resp bmwVehiclesResponse - uri := fmt.Sprintf("%s/me/vehicles/v2/", bmwAPI) - - var vehicles []string - - req, err := v.request(uri) - if err == nil { - err = v.DoJSON(req, &resp) - } - - if err == nil { - for _, v := range resp { - vehicles = append(vehicles, v.VIN) - } - } - - return vehicles, err -} - -// chargeState implements the api.Vehicle interface -func (v *BMW) chargeState() (float64, error) { - var resp bmwDynamicResponse - uri := fmt.Sprintf("%s/vehicle/dynamic/v1/%s", bmwAPI, v.vin) - - req, err := v.request(uri) - if err != nil { - return 0, err - } - - err = v.DoJSON(req, &resp) - return resp.AttributesMap.ChargingLevelHv, err -} - -// SoC implements the api.Vehicle interface -func (v *BMW) SoC() (float64, error) { - return v.chargeStateG() -} diff --git a/vehicle/bmw/api.go b/vehicle/bmw/api.go new file mode 100644 index 000000000..89ce89961 --- /dev/null +++ b/vehicle/bmw/api.go @@ -0,0 +1,85 @@ +package bmw + +import ( + "fmt" + "net/http" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +// https://github.com/bimmerconnected/bimmer_connected + +const ApiURI = "https://b2vapi.bmwgroup.com/webapi/v1" + +type StatusResponse struct { + VehicleStatus struct { + ConnectionStatus string // CONNECTED + ChargingStatus string // CHARGING, ERROR, FINISHED_FULLY_CHARGED, FINISHED_NOT_FULL, INVALID, NOT_CHARGING, WAITING_FOR_CHARGING + ChargingLevelHv int + RemainingRangeElectric int + Mileage int + // UpdateTime time.Time // 2021-08-12T12:00:08+0000 + } +} + +type VehiclesResponse struct { + Vehicles []Vehicle +} + +type Vehicle struct { + VIN string + Model string +} + +// API is an api.Vehicle implementation for BMW cars +type API struct { + *request.Helper +} + +// NewAPI creates a new vehicle +func NewAPI(log *util.Logger, identity oauth2.TokenSource) *API { + v := &API{ + Helper: request.NewHelper(log), + } + + // replace client transport with authenticated transport + v.Client.Transport = &oauth2.Transport{ + Source: identity, + Base: v.Client.Transport, + } + + return v +} + +// Vehicles implements returns the /user/vehicles api +func (v *API) Vehicles() ([]string, error) { + var resp VehiclesResponse + uri := fmt.Sprintf("%s/user/vehicles", ApiURI) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err == nil { + err = v.DoJSON(req, &resp) + } + + var vehicles []string + for _, v := range resp.Vehicles { + vehicles = append(vehicles, v.VIN) + } + + return vehicles, err +} + +// Status implements the /user/vehicles//status api +func (v *API) Status(vin string) (StatusResponse, error) { + var resp StatusResponse + uri := fmt.Sprintf("%s/user/vehicles/%s/status", ApiURI, vin) + + req, err := http.NewRequest(http.MethodGet, uri, nil) + if err == nil { + err = v.DoJSON(req, &resp) + } + + return resp, err +} diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/identity.go new file mode 100644 index 000000000..59f34dad2 --- /dev/null +++ b/vehicle/bmw/identity.go @@ -0,0 +1,93 @@ +package bmw + +import ( + "errors" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/andig/evcc/util" + "github.com/andig/evcc/util/oauth" + "github.com/andig/evcc/util/request" + "golang.org/x/oauth2" +) + +const AuthURI = "https://customer.bmwgroup.com/gcdm/oauth/authenticate" + +type Identity struct { + *request.Helper + log *util.Logger + oauth2.TokenSource + user, password string +} + +// NewIdentity creates BMW identity +func NewIdentity(log *util.Logger) *Identity { + v := &Identity{ + log: log, + Helper: request.NewHelper(log), + } + + return v +} + +func (v *Identity) Login(user, password string) error { + v.user = user + v.password = password + + token, err := v.RefreshToken(nil) + + if err == nil { + v.TokenSource = oauth.RefreshTokenSource(token, v) + } + + return err +} + +func (v *Identity) RefreshToken(_ *oauth2.Token) (*oauth2.Token, error) { + data := url.Values{ + "username": []string{v.user}, + "password": []string{v.password}, + "client_id": []string{"31c357a0-7a1d-4590-aa99-33b97244d048"}, + "redirect_uri": []string{"com.bmw.connected://oauth"}, + "response_type": []string{"token"}, + "scope": []string{"authenticate_user vehicle_data remote_services"}, + "nonce": []string{"login_nonce"}, + } + + req, err := request.New(http.MethodPost, AuthURI, strings.NewReader(data.Encode()), request.URLEncoding) + if err != nil { + return nil, err + } + + // don't follow redirects + v.Client.CheckRedirect = func(req *http.Request, via []*http.Request) error { return http.ErrUseLastResponse } + defer func() { v.Client.CheckRedirect = nil }() + + resp, err := v.Do(req) + if err != nil { + return nil, err + } + + uri := resp.Header.Get("Location") + + query, err := url.ParseQuery(strings.TrimPrefix(uri, "com.bmw.connected://oauth#")) + if err != nil { + return nil, err + } + + at := query.Get("access_token") + expires, err := strconv.Atoi(query.Get("expires_in")) + if err != nil || at == "" || expires == 0 { + return nil, errors.New("could not obtain token") + } + + token := &oauth2.Token{ + AccessToken: at, + Expiry: time.Now().Add(time.Duration(expires) * time.Second), + } + + return token, nil +} diff --git a/vehicle/bmw/provider.go b/vehicle/bmw/provider.go new file mode 100644 index 000000000..4f2222914 --- /dev/null +++ b/vehicle/bmw/provider.go @@ -0,0 +1,79 @@ +package bmw + +import ( + "time" + + "github.com/andig/evcc/api" + "github.com/andig/evcc/provider" +) + +// Provider implements the evcc vehicle api +type Provider struct { + statusG func() (interface{}, error) +} + +// NewProvider provides the evcc vehicle api provider +func NewProvider(api *API, vin string, cache time.Duration) *Provider { + impl := &Provider{ + statusG: provider.NewCached(func() (interface{}, error) { + return api.Status(vin) + }, cache).InterfaceGetter(), + } + return impl +} + +var _ api.Battery = (*Provider)(nil) + +// SoC implements the api.Vehicle interface +func (v *Provider) SoC() (float64, error) { + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + return float64(res.VehicleStatus.ChargingLevelHv), nil + } + + return 0, err +} + +var _ api.ChargeState = (*Provider)(nil) + +// Status implements the api.ChargeState interface +func (v *Provider) Status() (api.ChargeStatus, error) { + status := api.StatusA // disconnected + + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + if res.VehicleStatus.ConnectionStatus == "CONNECTED" { + status = api.StatusB + } + if res.VehicleStatus.ChargingStatus == "CHARGING" { + status = api.StatusC + } + } + + return status, err +} + +// var _ api.VehicleFinishTimer = (*Provider)(nil) + +// // FinishTime implements the api.VehicleFinishTimer interface +// func (v *Provider) FinishTime() (time.Time, error) { +// res, err := v.statusG() +// if res, ok := res.(StatusResponse); err == nil && ok { +// ctr := res.VehicleStatus.ChargingTimeRemaining +// return time.Now().Add(time.Duration(ctr) * time.Minute), err +// } + +// return time.Time{}, err +// } + +var _ api.VehicleRange = (*Provider)(nil) + +// Range implements the api.VehicleRange interface +func (v *Provider) Range() (rng int64, err error) { + res, err := v.statusG() + if res, ok := res.(StatusResponse); err == nil && ok { + rng = int64(res.VehicleStatus.RemainingRangeElectric) + } + + return rng, err +} diff --git a/vehicle/cloud.go b/vehicle/cloud.go index 4f2852a5a..3bc04eb30 100644 --- a/vehicle/cloud.go +++ b/vehicle/cloud.go @@ -14,7 +14,7 @@ import ( "github.com/andig/evcc/util/request" ) -// Cloud is an api.Vehicle implementation for Cloud cars +// Cloud is an api.Vehicle implementation type Cloud struct { *embed token string