BMW: upgrade api and add Mini (#1320)

This commit is contained in:
andig 2021-08-12 14:44:28 +02:00 • committed by GitHub
parent 9de343a9fd
commit 08894e2f8c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 277 additions and 140 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 (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)

View file

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

85
vehicle/bmw/api.go Normal file
View file

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

93
vehicle/bmw/identity.go Normal file
View file

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

79
vehicle/bmw/provider.go Normal file
View file

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

View file

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