BMW: upgrade api and add Mini (#1320)
This commit is contained in:
parent
9de343a9fd
commit
08894e2f8c
6 changed files with 277 additions and 140 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 (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)
|
||||
|
|
|
|||
155
vehicle/bmw.go
155
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()
|
||||
}
|
||||
|
|
|
|||
85
vehicle/bmw/api.go
Normal file
85
vehicle/bmw/api.go
Normal 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
93
vehicle/bmw/identity.go
Normal 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
79
vehicle/bmw/provider.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue