Repository: andig/evcc
Pull request #1329 (2021-08-11 19:12:33)

Add configurable actions to provide custom behaviour when vehicle is identified
Fix https://github.com/andig/evcc/issues/1328

Use case:
- have charger `off` for publicly accessible charger
- switch charger to `now` when boss car connects
- switch charger to `pv` when lower-priority car connects

Configure like this:

    onIdentify: # set defaults when vehicle is identified
    - e-Up: # vehicle id, e.g. from RFID
        mode: pv # switch back to pv mode
        targetSoC: 100 # charge to 100%

To be decided: 
- should we run the action also when the car is identified by status polling?
- @DerAndereAndi suggested to move the action configuration to the vehicle. Is there any reason to have different actions per vehicle depending on which loadpoint it is connected to?


Patch:
diff --git a/README.md b/README.md
index 2fbc7dae..3f16ef0a 100644
--- a/README.md
+++ b/README.md
@@ -13,10 +13,12 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
 
 - simple and clean user interface
 - multiple [chargers](#charger):
-  - Wallbe, Phoenix (includes ESL Walli), go-eCharger (includes Heidelberg Energy Control), NRGkick, SimpleEVSE, EVSEWifi, KEBA/BMW, openWB, Mobile Charger Connect
+  - Open source: [openWB](https://openwb.de/), [EVSEWifi](https://www.evse-wifi.de) (includes smartWB)
+  - Other commercial: ABL eMH1, go-eCharger, Heidelberg Energy Control, KEBA/BMW, NRGkick, Wallbe, Mobile Charger Connect
+  - Build-your-own: Phoenix (includes ESL Walli), [SimpleEVSE](https://www.evse-wifi.de/produkt-schlagwort/simple-evse-wb/)
   - 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/)
@@ -50,6 +52,7 @@ EVCC is an extensible EV Charge Controller with PV integration implemented in [G
 - [API](#api)
   - [REST API](#rest-api)
   - [MQTT API](#mqtt-api)
+- [Sponsorship](#sponsorship)
 - [Background](#background)
 
 
@@ -182,8 +185,11 @@ In general, due to the minimum value of 5% for signalling the EV duty cycle, the
 Charger is responsible for handling EV state and adjusting charge current.
 Available charger implementations are:
 
-- `evsewifi`: chargers with SimpleEVSE controllers using [EVSE-WiFi](https://www.evse-wifi.de/)
-- `go-e`: go-eCharger chargers (both local and cloud API are supported, at least firmware 040.0 required). Includes Heidelberg Energy Control using [github.com/steff393/wbec](https://github.com/steff393/wbec).
+- `abl`: ABL eMH1 (requires Modbus adapter; [sponsors only](#sponsorship))
+- `easee`: Easee Home charger ([sponsors only](#sponsorship))
+- `evsewifi`: chargers with SimpleEVSE controllers using [EVSE-WiFi](https://www.evse-wifi.de/) (includes smartWB)
+- `go-e`: go-eCharger chargers (both local and cloud API are supported, at least firmware 040.0 required)
+- `heidelberg`: Heidelberg Energy Control (requires Modbus adapter; [sponsors only](#sponsorship))
 - `keba`: KEBA KeContact P20/P30 and BMW chargers (see [Preparation](#keba-preparation-))
 - `mcc`: Mobile Charger Connect devices (Audi, Bentley, Porsche)
 - `nrgkick-bluetooth`: NRGkick chargers with Bluetooth connector (Linux only, not supported on Docker)
@@ -195,7 +201,6 @@ Available charger implementations are:
 - `simpleevse`: chargers with SimpleEVSE controllers connected via ModBus (e.g. OpenWB Wallbox, Easy Wallbox B163, ...)
 - `wallbe`: Wallbe Eco chargers (see [Preparation](#wallbe-preparation-)). For older Wallbe boxes (pre 2019) with Phoenix EV-CC-AC1-M3-CBC-RCM-ETH controllers make sure to set `legacy: true` to enable correct current configuration.
 - `warp`: Tinkerforge Warp/ Warp Pro charger
-- `easee`: Easee Home charger (available to Github sponsors only, request sponsor token at https://cloud.evcc.io/)
 - `custom`: default charger implementation using configurable [plugins](#plugins) for integrating any type of charger
 
 Smart-Home outlet charger implementations:
@@ -267,6 +272,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)
@@ -279,7 +285,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/)
+- `tronity`: Tronity ([sponsors only](#sponsorship))
 - `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)
@@ -598,6 +604,15 @@ The MQTT API follows the REST API's structure, with loadpoint ids starting at `0
 
 Note: to modify writable settings append `/set` to the topic for writing.
 
+## Sponsorship
+
+EVCC believes in open source software. We're committed to provide best in class EV charging experience.
+Maintaining EVCC consumes time and effort. With the vast amount of different devices to support, we depend on community and vendor support to keep EVCC alive.
+
+While EVCC is open source, we would also like to encourage vendors to provide open source hardware devices, public documentation and support open source projects like hours that provide additional value to otherwised closed hardware. Where this is not the case, EVCC requires "sponsor token" to finance ongoing development and support of evcc.
+
+The personal sponsor token requires a [Github Sponsorship](https://github.com/sponsors/andig) and can be requested at [cloud.evcc.io](https://cloud.evcc.io/). A sponsor token is valid for one year and can be renewed any time with active sponsorship.
+
 ## Background
 
 <img src="docs/logo.png" align="right" width="150" />
diff --git a/charger/abl.go b/charger/abl.go
new file mode 100644
index 00000000..8f0b0334
--- /dev/null
+++ b/charger/abl.go
@@ -0,0 +1,178 @@
+package charger
+
+import (
+	"encoding/binary"
+	"errors"
+	"fmt"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/util"
+	"github.com/andig/evcc/util/modbus"
+	"github.com/andig/evcc/util/sponsor"
+)
+
+// ABLeMH charger implementation
+type ABLeMH struct {
+	log     *util.Logger
+	conn    *modbus.Connection
+	current uint16
+}
+
+const (
+	ablRegFirmware      = 0x01
+	ablRegVehicleStatus = 0x04
+	ablRegAmpsConfig    = 0x14
+	ablRegStatus        = 0x2E
+
+	ablAmpsDisabled uint16 = 0x03E8
+
+	// ablRegMode          = 0x05
+	// ablReset   uint16 = 0x5A5A
+	// ablEnable  uint16 = 0xA1A1
+	// ablDisable uint16 = 0xE0E0
+)
+
+func init() {
+	registry.Add("abl", NewABLeMHFromConfig)
+}
+
+// https://www.goingelectric.de/forum/viewtopic.php?p=1550459#p1550459
+
+// NewABLeMHFromConfig creates a ABLeMH charger from generic config
+func NewABLeMHFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := modbus.Settings{
+		ID: 1,
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewABLeMH(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.ID)
+}
+
+// NewABLeMH creates ABLeMH charger
+func NewABLeMH(uri, device, comset string, baudrate int, slaveID uint8) (api.Charger, error) {
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, modbus.AsciiFormat, slaveID)
+	if err != nil {
+		return nil, err
+	}
+
+	if !sponsor.IsAuthorized() {
+		return nil, errors.New("abl requires evcc sponsorship, register at https://cloud.evcc.io")
+	}
+
+	log := util.NewLogger("abl")
+	conn.Logger(log.TRACE)
+
+	wb := &ABLeMH{
+		log:     log,
+		conn:    conn,
+		current: 0x64, // 6A
+	}
+
+	return wb, nil
+}
+
+// Status implements the api.Charger interface
+func (wb *ABLeMH) Status() (api.ChargeStatus, error) {
+	b, err := wb.conn.ReadHoldingRegisters(ablRegVehicleStatus, 1)
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	r := rune(b[1]>>4-0x0A) + 'A'
+
+	switch r {
+	case 'A', 'B', 'C':
+		return api.ChargeStatus(r), nil
+	default:
+		return api.StatusNone, fmt.Errorf("invalid status: %v", r)
+	}
+}
+
+// Enabled implements the api.Charger interface
+func (wb *ABLeMH) Enabled() (bool, error) {
+	b, err := wb.conn.ReadHoldingRegisters(ablRegAmpsConfig, 1)
+	if err != nil {
+		return false, err
+	}
+
+	enabled := binary.BigEndian.Uint16(b) != ablAmpsDisabled
+
+	return enabled, nil
+}
+
+// Enable implements the api.Charger interface
+func (wb *ABLeMH) Enable(enable bool) error {
+	u := ablAmpsDisabled
+	if enable {
+		u = wb.current
+	}
+
+	_, err := wb.conn.WriteSingleRegister(ablRegAmpsConfig, u)
+
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (wb *ABLeMH) MaxCurrent(current int64) error {
+	return wb.MaxCurrentMillis(float64(current))
+}
+
+var _ api.ChargerEx = (*ABLeMH)(nil)
+
+// MaxCurrent implements the api.ChargerEx interface
+func (wb *ABLeMH) MaxCurrentMillis(current float64) error {
+	// calculate duty cycle according to https://www.goingelectric.de/forum/viewtopic.php?p=1575287#p1575287
+	u := uint16(current / 0.06)
+
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, u)
+
+	_, err := wb.conn.WriteSingleRegister(ablRegAmpsConfig, u)
+	if err == nil {
+		wb.current = u
+	}
+
+	return err
+}
+
+var _ api.Meter = (*ABLeMH)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (wb *ABLeMH) CurrentPower() (float64, error) {
+	l1, l2, l3, err := wb.Currents()
+	return 230 * (l1 + l2 + l3), err
+}
+
+var _ api.MeterCurrent = (*ABLeMH)(nil)
+
+// Currents implements the api.MeterCurrent interface
+func (wb *ABLeMH) Currents() (float64, float64, float64, error) {
+	b, err := wb.conn.ReadHoldingRegisters(ablRegStatus, 5)
+	if err != nil {
+		return 0, 0, 0, err
+	}
+
+	var currents []float64
+	for i := 2; i < 5; i++ {
+		u := binary.BigEndian.Uint16(b[2*i:])
+		if u == ablAmpsDisabled {
+			u = 0
+		}
+
+		currents = append(currents, float64(u)/10)
+	}
+
+	return currents[0], currents[1], currents[2], nil
+}
+
+var _ api.Diagnosis = (*ABLeMH)(nil)
+
+// Diagnose implements the api.Diagnosis interface
+func (wb *ABLeMH) Diagnose() {
+	if b, err := wb.conn.ReadHoldingRegisters(ablRegFirmware, 2); err == nil {
+		fmt.Printf("Firmware: %0 x\n", b)
+	}
+}
diff --git a/charger/heidelberg-ec.go b/charger/heidelberg-ec.go
index 129f8e05..28ff6870 100644
--- a/charger/heidelberg-ec.go
+++ b/charger/heidelberg-ec.go
@@ -39,24 +39,20 @@ func init() {
 
 // NewHeidelbergECFromConfig creates a HeidelbergEC charger from generic config
 func NewHeidelbergECFromConfig(other map[string]interface{}) (api.Charger, error) {
-	cc := struct {
-		modbus.Settings `mapstructure:",squash"`
-	}{
-		Settings: modbus.Settings{
-			ID: 1,
-		},
+	cc := modbus.Settings{
+		ID: 1,
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
 
-	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, true, cc.ID)
+	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, cc.ID)
 }
 
 // NewHeidelbergEC creates HeidelbergEC charger
-func NewHeidelbergEC(uri, device, comset string, baudrate int, rtu bool, slaveID uint8) (api.Charger, error) {
-	conn, err := modbus.NewConnection(uri, device, comset, baudrate, rtu, slaveID)
+func NewHeidelbergEC(uri, device, comset string, baudrate int, slaveID uint8) (api.Charger, error) {
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, modbus.RtuFormat, slaveID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/phoenix-em-eth.go b/charger/phoenix-em-eth.go
index 7a27bb85..118c45c2 100644
--- a/charger/phoenix-em-eth.go
+++ b/charger/phoenix-em-eth.go
@@ -73,7 +73,7 @@ func NewPhoenixEMEthFromConfig(other map[string]interface{}) (api.Charger, error
 
 // NewPhoenixEMEth creates a Phoenix charger
 func NewPhoenixEMEth(uri string, id uint8) (*PhoenixEMEth, error) {
-	conn, err := modbus.NewConnection(uri, "", "", 0, false, id)
+	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.TcpFormat, id)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/phoenix-ev-eth.go b/charger/phoenix-ev-eth.go
index 4f53d73b..4999a311 100644
--- a/charger/phoenix-ev-eth.go
+++ b/charger/phoenix-ev-eth.go
@@ -74,7 +74,7 @@ func NewPhoenixEVEthFromConfig(other map[string]interface{}) (api.Charger, error
 
 // NewPhoenixEVEth creates a Phoenix charger
 func NewPhoenixEVEth(uri string, id uint8) (*PhoenixEVEth, error) {
-	conn, err := modbus.NewConnection(uri, "", "", 0, false, id)
+	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.TcpFormat, id)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/phoenix-ev-ser.go b/charger/phoenix-ev-ser.go
index 8f909ed6..578c3086 100644
--- a/charger/phoenix-ev-ser.go
+++ b/charger/phoenix-ev-ser.go
@@ -43,7 +43,7 @@ func NewPhoenixEVSerFromConfig(other map[string]interface{}) (api.Charger, error
 
 // NewPhoenixEVSer creates a Phoenix charger
 func NewPhoenixEVSer(uri, device, comset string, baudrate int, id uint8) (*PhoenixEVSer, error) {
-	conn, err := modbus.NewConnection(uri, device, comset, baudrate, true, id)
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, modbus.RtuFormat, id)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/simpleevse.go b/charger/simpleevse.go
index af41c136..24ea3d9d 100644
--- a/charger/simpleevse.go
+++ b/charger/simpleevse.go
@@ -45,7 +45,7 @@ func NewSimpleEVSEFromConfig(other map[string]interface{}) (api.Charger, error)
 func NewSimpleEVSE(uri, device, comset string, baudrate int, rtu bool, slaveID uint8) (api.Charger, error) {
 	log := util.NewLogger("evse")
 
-	conn, err := modbus.NewConnection(uri, device, comset, baudrate, rtu, slaveID)
+	conn, err := modbus.NewConnection(uri, device, comset, baudrate, modbus.RtuFormat, slaveID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/charger/wallbe.go b/charger/wallbe.go
index c9d56e8b..5dc4af82 100644
--- a/charger/wallbe.go
+++ b/charger/wallbe.go
@@ -104,7 +104,7 @@ func NewWallbeFromConfig(other map[string]interface{}) (api.Charger, error) {
 
 // NewWallbe creates a Wallbe charger
 func NewWallbe(uri string) (*Wallbe, error) {
-	conn, err := modbus.NewConnection(uri, "", "", 0, false, wbSlaveID)
+	conn, err := modbus.NewConnection(uri, "", "", 0, modbus.TcpFormat, wbSlaveID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/core/coordinator.go b/core/coordinator.go
index d2ee716e..8798ea1f 100644
--- a/core/coordinator.go
+++ b/core/coordinator.go
@@ -40,14 +40,10 @@ func (lp *vehicleCoordinator) availableVehicles(owner interface{}, vehicles []ap
 }
 
 // find active vehicle by charge state
-func (lp *vehicleCoordinator) findActiveVehicleByStatus(log *util.Logger, owner interface{}, vehicles []api.Vehicle) api.Vehicle {
-	var res api.Vehicle
-
+func (lp *vehicleCoordinator) identifyVehicleByStatus(log *util.Logger, owner interface{}, vehicles []api.Vehicle) api.Vehicle {
 	available := lp.availableVehicles(owner, vehicles)
-	// log.DEBUG.Printf("!!available vehicles: %v", funk.Map(available, func(v api.Vehicle) string {
-	// 	return v.Title()
-	// }))
 
+	var res api.Vehicle
 	for _, vehicle := range available {
 		if vs, ok := vehicle.(api.ChargeState); ok {
 			status, err := vs.Status()
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 1ab21219..3761f15e 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -64,6 +64,12 @@ type ThresholdConfig struct {
 	Threshold float64
 }
 
+// ActionConfig defines an action to take on event
+type ActionConfig struct {
+	Mode      api.ChargeMode `mapstructure:"mode"`      // Charge mode to apply when car disconnected
+	TargetSoC int            `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected
+}
+
 // LoadPoint is responsible for controlling charge depending on
 // SoC needs and power availability.
 type LoadPoint struct {
@@ -86,11 +92,9 @@ type LoadPoint struct {
 	Meters      struct {
 		ChargeMeterRef string `mapstructure:"charge"` // Charge meter reference
 	}
-	SoC          SoCConfig
-	OnDisconnect struct {
-		Mode      api.ChargeMode `mapstructure:"mode"`      // Charge mode to apply when car disconnected
-		TargetSoC int            `mapstructure:"targetSoC"` // Target SoC to apply when car disconnected
-	}
+	SoC             SoCConfig
+	OnDisconnect    ActionConfig            `mapstructure:"onDisconnect"`
+	OnIdentify      map[string]ActionConfig `mapstructure:"onIdentify"`
 	Enable, Disable ThresholdConfig
 
 	MinCurrent    float64       // PV mode: start current	Min+PV mode: min current
@@ -103,6 +107,7 @@ type LoadPoint struct {
 	socUpdated             time.Time // SoC updated timestamp (poll: connected)
 	vehicleConnected       time.Time // Vehicle connected timestamp
 	vehicleConnectedTicker *clock.Ticker
+	vehicleID              string
 
 	charger     api.Charger
 	chargeTimer api.ChargeTimer
@@ -335,7 +340,6 @@ func (lp *LoadPoint) evVehicleConnectHandler() {
 
 	// identify active vehicle
 	lp.startVehicleDetection()
-	lp.findActiveVehicle()
 
 	// immediately allow pv mode activity
 	lp.pvDisableTimer()
@@ -359,12 +363,7 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 	}
 
 	// set default mode on disconnect
-	if lp.OnDisconnect.Mode != "" && lp.GetMode() != api.ModeOff {
-		lp.SetMode(lp.OnDisconnect.Mode)
-	}
-	if lp.OnDisconnect.TargetSoC != 0 {
-		_ = lp.SetTargetSoC(lp.OnDisconnect.TargetSoC)
-	}
+	lp.applyAction(lp.OnDisconnect)
 
 	// soc update reset
 	lp.socUpdated = time.Time{}
@@ -395,6 +394,16 @@ func (lp *LoadPoint) evChargeCurrentWrappedMeterHandler(current float64) {
 	lp.chargeMeter.(*wrapper.ChargeMeter).SetPower(power)
 }
 
+// applyAction executes the action
+func (lp *LoadPoint) applyAction(action ActionConfig) {
+	if action.Mode != "" && lp.GetMode() != api.ModeOff {
+		lp.SetMode(action.Mode)
+	}
+	if action.TargetSoC != 0 {
+		_ = lp.SetTargetSoC(action.TargetSoC)
+	}
+}
+
 // Name returns the human-readable loadpoint title
 func (lp *LoadPoint) Name() string {
 	return lp.Title
@@ -419,7 +428,6 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish("maxCurrent", lp.MaxCurrent)
 	lp.publish("phases", lp.Phases)
 	lp.publish("activePhases", lp.Phases)
-	lp.publish("hasVehicle", len(lp.vehicles) > 0)
 
 	lp.Lock()
 	lp.publish("mode", lp.Mode)
@@ -602,6 +610,68 @@ func (lp *LoadPoint) remoteControlled(demand RemoteDemand) bool {
 	return lp.remoteDemand == demand
 }
 
+// identifyVehicle reads vehicle identification from charger
+func (lp *LoadPoint) identifyVehicle() {
+	identifier, ok := lp.charger.(api.Identifier)
+	if !ok {
+		return
+	}
+
+	id, err := identifier.Identify()
+	if err != nil {
+		lp.log.ERROR.Println("charger vehicle id:", err)
+		return
+	}
+
+	if lp.vehicleID == id {
+		return
+	}
+
+	// vehicle found or removed
+	lp.vehicleID = id
+
+	lp.log.DEBUG.Println("charger vehicle id:", id)
+	lp.publish("socIdentity", id)
+
+	if id != "" {
+		if vehicle := lp.selectVehicleByID(id); vehicle != nil {
+			lp.setActiveVehicle(vehicle)
+		}
+
+		if action, ok := lp.OnIdentify[id]; ok {
+			lp.log.DEBUG.Println("running vehicle action:", action)
+			lp.applyAction(action)
+		}
+	}
+}
+
+// selectVehicleByID selects the vehicle with the given ID
+func (lp *LoadPoint) selectVehicleByID(id string) api.Vehicle {
+	// find exact match
+	for _, vehicle := range lp.vehicles {
+		if vid, err := vehicle.Identify(); err == nil && vid == id {
+			return vehicle
+		}
+	}
+
+	// find placeholder match
+	for _, vehicle := range lp.vehicles {
+		if vid, err := vehicle.Identify(); err == nil && vid != "" {
+			re, err := regexp.Compile(strings.ReplaceAll(vid, "*", ".*?"))
+			if err != nil {
+				lp.log.ERROR.Printf("vehicle id: %v", err)
+				continue
+			}
+
+			if re.MatchString(id) {
+				return vehicle
+			}
+		}
+	}
+
+	return nil
+}
+
 // setActiveVehicle assigns currently active vehicle and configures soc estimator
 func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
 	if lp.vehicle == vehicle {
@@ -623,12 +693,14 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
 	if lp.vehicle = vehicle; vehicle != nil {
 		lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate)
 
+		lp.publish("hasVehicle", true)
 		lp.publish("socTitle", lp.vehicle.Title())
 		lp.publish("socCapacity", lp.vehicle.Capacity())
 	} else {
 		lp.socEstimator = nil
 
-		lp.publish("socTitle", "unknown")
+		lp.publish("hasVehicle", false)
+		lp.publish("socTitle", "")
 		lp.publish("socCapacity", 0)
 	}
 }
@@ -639,9 +711,9 @@ func (lp *LoadPoint) startVehicleDetection() {
 	lp.vehicleConnectedTicker = lp.clock.Ticker(vehicleDetectInterval)
 }
 
-// vehicleIdentificationAllowed checks if loadpoint has multiple vehicles associated and starts discovery period
-func (lp *LoadPoint) vehicleIdentificationAllowed() bool {
-	res := len(lp.vehicles) > 1 && lp.connected() && lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration
+// vehicleUnidentified checks if loadpoint has multiple vehicles associated and starts discovery period
+func (lp *LoadPoint) vehicleUnidentified() bool {
+	res := len(lp.vehicles) > 1 && lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration
 
 	// request vehicle api refresh while waiting to identify
 	if res {
@@ -656,58 +728,13 @@ func (lp *LoadPoint) vehicleIdentificationAllowed() bool {
 	return res
 }
 
-// find active vehicle by id
-func (lp *LoadPoint) findActiveVehicleByID() api.Vehicle {
-	if identifier, ok := lp.charger.(api.Identifier); ok {
-		id, err := identifier.Identify()
-
-		if err != nil {
-			lp.log.ERROR.Println("charger vehicle id:", err)
-			return nil
-		}
-
-		if id != "" {
-			lp.log.DEBUG.Println("charger vehicle id:", id)
-
-			// find exact match
-			for _, vehicle := range lp.vehicles {
-				if vid, err := vehicle.Identify(); err == nil && vid == id {
-					return vehicle
-				}
-			}
-
-			// find placeholder match
-			for _, vehicle := range lp.vehicles {
-				if vid, err := vehicle.Identify(); err == nil && vid != "" {
-					re, err := regexp.Compile(strings.ReplaceAll(vid, "*", ".*?"))
-					if err != nil {
-						lp.log.ERROR.Printf("vehicle identity: %v", err)
-						continue
-					}
-
-					if re.MatchString(id) {
-						return vehicle
-					}
-				}
-			}
-		}
-	}
-
-	return nil
-}
-
-// findActiveVehicle validates if the active vehicle is still connected to the loadpoint
-func (lp *LoadPoint) findActiveVehicle() {
+// identifyVehicleByStatus validates if the active vehicle is still connected to the loadpoint
+func (lp *LoadPoint) identifyVehicleByStatus() {
 	if len(lp.vehicles) <= 1 {
 		return
 	}
 
-	if vehicle := lp.findActiveVehicleByID(); vehicle != nil {
-		lp.setActiveVehicle(vehicle)
-		return
-	}
-
-	if vehicle := coordinator.findActiveVehicleByStatus(lp.log, lp, lp.vehicles); vehicle != nil {
+	if vehicle := coordinator.identifyVehicleByStatus(lp.log, lp, lp.vehicles); vehicle != nil {
 		lp.setActiveVehicle(vehicle)
 		return
 	}
@@ -1024,9 +1051,15 @@ func (lp *LoadPoint) Update(sitePower float64, cheap bool) {
 	lp.publish("charging", lp.charging())
 	lp.publish("enabled", lp.enabled)
 
-	// update active vehicle if not yet done
-	if lp.vehicleIdentificationAllowed() {
-		lp.findActiveVehicle()
+	// identify connected vehicle
+	if lp.connected() {
+		// read identity and run associated action
+		lp.identifyVehicle()
+
+		// find vehicle by status for a couple of minutes after connecting
+		if lp.vehicleUnidentified() {
+			lp.identifyVehicleByStatus()
+		}
 	}
 
 	// publish soc after updating charger status to make sure
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 840dbe58..d80a96ab 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -91,6 +91,10 @@ loadpoints:
   onDisconnect: # set defaults when vehicle disconnects
     mode: pv # switch back to pv mode
     targetSoC: 100 # charge to 100%
+  onIdentify: # set defaults when vehicle is identified
+  - e-Up:
+      mode: pv # switch back to pv mode
+      targetSoC: 100 # charge to 100%
   phases: 3 # ev phases (default 3)
   enable: # pv mode enable behavior
     delay: 1m # threshold must be exceeded for this long
diff --git a/go.mod b/go.mod
index 82df0a2f..3f3917a5 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,6 @@ require (
 	github.com/benbjohnson/clock v1.1.0
 	github.com/bogosj/tesla v1.0.2
 	github.com/containrrr/shoutrrr v0.4.4
-	github.com/deepmap/oapi-codegen v1.8.2 // indirect
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/dustin/go-humanize v1.0.0
 	github.com/dylanmei/iso8601 v0.1.0
@@ -67,10 +66,10 @@ require (
 	github.com/spf13/viper v1.8.1
 	github.com/thoas/go-funk v0.9.0
 	github.com/tv42/httpunix v0.0.0-20191220191345-2ba4b9c3382c
-	github.com/volkszaehler/mbmd v0.0.0-20210706160533-aa268417e0ef
+	github.com/volkszaehler/mbmd v0.0.0-20210808132733-8235dc7d1327
 	gitlab.com/bboehmke/sunny v0.14.2
 	golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97 // indirect
-	golang.org/x/net v0.0.0-20210716203947-853a461950ff
+	golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985
 	golang.org/x/oauth2 v0.0.0-20210628180205-a41e5a781914
 	golang.org/x/text v0.3.6
 	google.golang.org/genproto v0.0.0-20210722135532-667f2b7c528f // indirect
diff --git a/go.sum b/go.sum
index f57083ff..7c54b402 100644
--- a/go.sum
+++ b/go.sum
@@ -117,6 +117,7 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
 github.com/coreos/go-systemd/v22 v22.3.2/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
 github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
+github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
@@ -124,7 +125,6 @@ github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
 github.com/deepmap/oapi-codegen v1.3.6/go.mod h1:aBozjEveG+33xPiP55Iw/XbVkhtZHEGLq3nxlX0+hfU=
 github.com/deepmap/oapi-codegen v1.3.9/go.mod h1:suMvK7+rKlx3+tpa8ByptmvoXbAV70wERKTOGH3hLp0=
 github.com/deepmap/oapi-codegen v1.6.0/go.mod h1:ryDa9AgbELGeB+YEXE1dR53yAjHwFvE9iAUlWl9Al3M=
-github.com/deepmap/oapi-codegen v1.8.1/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw=
 github.com/deepmap/oapi-codegen v1.8.2 h1:SegyeYGcdi0jLLrpbCMoJxnUUn8GBXHsvr4rbzjuhfU=
 github.com/deepmap/oapi-codegen v1.8.2/go.mod h1:YLgSKSDv/bZQB7N4ws6luhozi3cEdRktEqrX88CvjIw=
 github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ=
@@ -319,7 +319,6 @@ github.com/gregdel/pushover v1.1.0 h1:dwHyvrcpZCOS9V1fAnKPaGRRI5OC55cVaKhMybqNsK
 github.com/gregdel/pushover v1.1.0/go.mod h1:EcaO66Nn1StkpEm1iKtBTV3d2A16SoMsVER1PthX7to=
 github.com/grid-x/modbus v0.0.0-20200108122021-57d05a9f1e1a/go.mod h1:cOS1YDRu+JId5eT7kRulYTlfpACFppj2MpftCvHeiuM=
 github.com/grid-x/modbus v0.0.0-20200704184817-73c05928caa1/go.mod h1:cOS1YDRu+JId5eT7kRulYTlfpACFppj2MpftCvHeiuM=
-github.com/grid-x/modbus v0.0.0-20210224155242-c4a3d042e99b/go.mod h1:YaK0rKJenZ74vZFcSSLlAQqtG74PMI68eDjpDCDDmTw=
 github.com/grid-x/modbus v0.0.0-20210714071042-7af2b65ec03b h1:ur6X0rPWCe3R5AC5X7qIySVhP/34pM3A1uQVJ0GEdKw=
 github.com/grid-x/modbus v0.0.0-20210714071042-7af2b65ec03b/go.mod h1:YaK0rKJenZ74vZFcSSLlAQqtG74PMI68eDjpDCDDmTw=
 github.com/grid-x/serial v0.0.0-20191104121038-e24bc9bf6f08 h1:syBxnRYnSPUDdkdo5U4sy2roxBPQDjNiw4od7xlsABQ=
@@ -609,11 +608,11 @@ github.com/spf13/afero v1.3.2/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY52
 github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY=
 github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=
 github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
-github.com/spf13/cast v1.3.1 h1:nFm6S0SMdyzrzcmThSipiEubIDy8WEXKNZ0UOgiRpng=
 github.com/spf13/cast v1.3.1/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
+github.com/spf13/cast v1.4.0 h1:WhlbjwB9EGCc8W5Rxdkus+wmH2ASRwwTJk6tgHKwdqQ=
+github.com/spf13/cast v1.4.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
 github.com/spf13/cobra v0.0.7/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
-github.com/spf13/cobra v1.2.0/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
 github.com/spf13/cobra v1.2.1 h1:+KmjbUw1hriSNMF55oPrkZcb27aECyrj8V2ytv7kWDw=
 github.com/spf13/cobra v1.2.1/go.mod h1:ExllRjgxM/piMAM+3tAZvg8fsklGAf3tPfi+i8t68Nk=
 github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
@@ -662,8 +661,8 @@ github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPU
 github.com/valyala/fasttemplate v1.1.0/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
 github.com/valyala/fasttemplate v1.2.1/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
 github.com/volkszaehler/mbmd v0.0.0-20200717102329-c4d965bd1eac/go.mod h1:sldLyJCKVO9GQkit55U5WPNr9U4KmUn1SCdqFN9Gjb4=
-github.com/volkszaehler/mbmd v0.0.0-20210706160533-aa268417e0ef h1:OIKfPaGDM8LjDBMyaNfCJ6vgmwbRdj6hsSj2bs2qzJ8=
-github.com/volkszaehler/mbmd v0.0.0-20210706160533-aa268417e0ef/go.mod h1:BDNStSIw76tsTTgAmRvdo6o9ILyCK9BNVXbeFf9jjvI=
+github.com/volkszaehler/mbmd v0.0.0-20210808132733-8235dc7d1327 h1:GhXik8mWOCLDq7sCJjHjSv49fYUvhxOMMhrGYqAFowI=
+github.com/volkszaehler/mbmd v0.0.0-20210808132733-8235dc7d1327/go.mod h1:oh17P6CJFJvVlFfLSgbOh+GdgmtuHpxik7gk7Zr/kxg=
 github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
 github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -793,8 +792,8 @@ golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLd
 golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.0.0-20210716203947-853a461950ff h1:j2EK/QoxYNBsXI4R7fQkkRUk8y6wnOBI+6hgPdP/6Ds=
-golang.org/x/net v0.0.0-20210716203947-853a461950ff/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985 h1:4CSI6oo7cOjJKajidEljs9h+uP0rRZBPPPhcCbj5mw8=
+golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -974,7 +973,6 @@ golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4f
 golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0=
 golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.2/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
-golang.org/x/tools v0.1.4/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/tools v0.1.5 h1:ouewzE6p+/VEB31YYnTbEJdi8pFqKp4P4n85vwo3DHA=
 golang.org/x/tools v0.1.5/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
 golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
diff --git a/meter/modbus.go b/meter/modbus.go
index 789d102a..86bd680e 100644
--- a/meter/modbus.go
+++ b/meter/modbus.go
@@ -53,9 +53,12 @@ func NewModbusFromConfig(other map[string]interface{}) (api.Meter, error) {
 		cc.RTU = &b
 	}
 
-	log := util.NewLogger("modbus")
+	format := modbus.TcpFormat
+	if cc.RTU != nil && *cc.RTU {
+		format = modbus.RtuFormat
+	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, *cc.RTU, cc.ID)
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, format, cc.ID)
 	if err != nil {
 		return nil, err
 	}
@@ -65,6 +68,7 @@ func NewModbusFromConfig(other map[string]interface{}) (api.Meter, error) {
 		conn.Timeout(cc.Timeout)
 	}
 
+	log := util.NewLogger("modbus")
 	conn.Logger(log.TRACE)
 
 	// prepare device
diff --git a/provider/modbus.go b/provider/modbus.go
index 23278372..e25756f7 100644
--- a/provider/modbus.go
+++ b/provider/modbus.go
@@ -49,7 +49,12 @@ func NewModbusFromConfig(other map[string]interface{}) (IntProvider, error) {
 		cc.RTU = &b
 	}
 
-	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, *cc.RTU, cc.ID)
+	format := modbus.TcpFormat
+	if cc.RTU != nil && *cc.RTU {
+		format = modbus.RtuFormat
+	}
+
+	conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, format, cc.ID)
 	if err != nil {
 		return nil, err
 	}
diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go
index 450e30f0..95826bad 100644
--- a/util/modbus/modbus.go
+++ b/util/modbus/modbus.go
@@ -16,6 +16,14 @@ import (
 // WriteSingleRegister 16-bit wise write access
 const WriteSingleRegister = 6 // modbus.FuncCodeWriteSingleRegister
 
+type WireFormat int
+
+const (
+	TcpFormat WireFormat = iota
+	RtuFormat
+	AsciiFormat
+)
+
 // Settings contains the ModBus settings
 type Settings struct {
 	ID                  uint8
@@ -140,7 +148,7 @@ func registeredConnection(key string, newConn meters.Connection) meters.Connecti
 }
 
 // NewConnection creates physical modbus device from config
-func NewConnection(uri, device, comset string, baudrate int, rtu bool, slaveID uint8) (*Connection, error) {
+func NewConnection(uri, device, comset string, baudrate int, wire WireFormat, slaveID uint8) (*Connection, error) {
 	var conn meters.Connection
 
 	if device != "" && uri != "" {
@@ -151,15 +159,23 @@ func NewConnection(uri, device, comset string, baudrate int, rtu bool, slaveID u
 		if baudrate == 0 || comset == "" {
 			return nil, errors.New("invalid modbus configuration: need baudrate and comset")
 		}
-		conn = registeredConnection(device, meters.NewRTU(device, baudrate, comset))
+
+		if wire == RtuFormat {
+			conn = registeredConnection(device, meters.NewRTU(device, baudrate, comset))
+		} else {
+			conn = registeredConnection(uri, meters.NewASCII(device, baudrate, comset))
+		}
 	}
 
 	if uri != "" {
 		uri = util.DefaultPort(uri, 502)
 
-		if rtu {
+		switch wire {
+		case RtuFormat:
 			conn = registeredConnection(uri, meters.NewRTUOverTCP(uri))
-		} else {
+		case AsciiFormat:
+			conn = registeredConnection(uri, meters.NewASCIIOverTCP(uri))
+		default:
 			conn = registeredConnection(uri, meters.NewTCP(uri))
 		}
 	}
diff --git a/vehicle/bmw.go b/vehicle/bmw.go
index d83db835..6ef342d0 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
 	}
 
+	v := &BMW{
+		embed: &cc.embed,
+	}
+
 	log := util.NewLogger("bmw")
+	identity := bmw.NewIdentity(log)
 
-	v := &BMW{
-		embed:    &cc.embed,
-		Helper:   request.NewHelper(log),
-		user:     cc.User,
-		password: cc.Password,
-		vin:      strings.ToUpper(cc.VIN),
+	if err := identity.Login(cc.User, cc.Password); err != nil {
+		return nil, err
 	}
 
-	v.chargeStateG = provider.NewCached(v.chargeState, cc.Cache).FloatGetter()
+	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)
 		}
 	}
 
-	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.Provider = bmw.NewProvider(api, strings.ToUpper(cc.VIN), cc.Cache)
 
-	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()
+	return v, err
 }
diff --git a/vehicle/bmw/api.go b/vehicle/bmw/api.go
new file mode 100644
index 00000000..89ce8996
--- /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/<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
+}
diff --git a/vehicle/bmw/identity.go b/vehicle/bmw/identity.go
new file mode 100644
index 00000000..59f34dad
--- /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 00000000..4f222291
--- /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 4f2852a5..3bc04eb3 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
diff --git a/vehicle/skoda/api.go b/vehicle/skoda/api.go
index 171e5bec..a9f010ed 100644
--- a/vehicle/skoda/api.go
+++ b/vehicle/skoda/api.go
@@ -91,13 +91,6 @@ type ChargerResponse struct {
 	}
 }
 
-// charging/settings
-// {
-//     "autoUnlockPlugWhenCharged": "Permanent",
-//     "maxChargeCurrentAc": "Maximum",
-//     "targetStateOfChargeInPercent": 100
-// }
-
 // Charger implements the /v1/charging/<vin>/status response
 func (v *API) Charger(vin string) (ChargerResponse, error) {
 	var res ChargerResponse
@@ -105,62 +98,3 @@ func (v *API) Charger(vin string) (ChargerResponse, error) {
 	err := v.getJSON(uri, &res)
 	return res, err
 }
-
-// ClimaterResponse is the /bs/climatisation/v1/%s/%s/vehicles/%s/climater api
-// type ClimaterResponse struct {
-// }
-
-// // Climater implements the /climater response
-// func (v *API) Climater(vin string) (ClimaterResponse, error) {
-// 	var res ClimaterResponse
-// 	uri := fmt.Sprintf("%s/bs/climatisation/v1/%s/%s/vehicles/%s/climater", BaseURI, v.brand, v.country, vin)
-// 	err := v.getJSON(uri, &res)
-// 	return res, err
-// }
-
-// const (
-// 	ActionCharge      = "batterycharge"
-// 	ActionChargeStart = "start"
-// 	ActionChargeStop  = "stop"
-// )
-
-// type actionDefinition struct {
-// 	contentType string
-// 	appendix    string
-// }
-
-// var actionDefinitions = map[string]actionDefinition{
-// 	ActionCharge: {
-// 		"application/vnd.vwg.mbb.ChargerAction_v1_0_0+xml",
-// 		"charger/actions",
-// 	},
-// }
-
-// // Action implements vehicle actions
-// func (v *API) Action(vin, action, value string) error {
-// 	def := actionDefinitions[action]
-
-// 	uri := fmt.Sprintf("%s/bs/%s/v1/%s/%s/vehicles/%s/%s", BaseURI, action, v.brand, v.country, vin, def.appendix)
-// 	body := "<?xml version=\"1.0\" encoding=\"UTF-8\" ?><action><type>" + value + "</type></action>"
-
-// 	req, err := request.New(http.MethodPost, uri, strings.NewReader(body), map[string]string{
-// 		"Content-type": def.contentType,
-// 	})
-
-// 	if err == nil {
-// 		var resp *http.Response
-// 		if resp, err = v.Do(req); err == nil {
-// 			resp.Body.Close()
-// 		}
-// 	}
-
-// 	return err
-// }
-
-// // Any implements any api response
-// func (v *API) Any(base, vin string) (interface{}, error) {
-// 	var res interface{}
-// 	uri := fmt.Sprintf("%s/"+strings.TrimLeft(base, "/"), BaseURI, v.brand, v.country, vin)
-// 	err := v.getJSON(uri, &res)
-// 	return res, err
-// }
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index a9abec16..05ef4bf3 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -61,12 +61,6 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error
 		return nil, err
 	}
 
-	for k, v := range map[string]string{"charge": cc.Charge.PluginType()} {
-		if v == "" {
-			return nil, fmt.Errorf("missing plugin configuration: %s", k)
-		}
-	}
-
 	getter, err := provider.NewFloatGetterFromConfig(cc.Charge)
 	if err != nil {
 		return nil, fmt.Errorf("charge: %w", err)


Test patch:
diff --git a/core/coordinator_test.go b/core/coordinator_test.go
index f424aee6..8706e555 100644
--- a/core/coordinator_test.go
+++ b/core/coordinator_test.go
@@ -48,7 +48,7 @@ func TestVehicleDetectByStatus(t *testing.T) {
 		v1.MockVehicle.EXPECT().Title().Return("v1")
 		v2.MockVehicle.EXPECT().Title().Return("v2")
 
-		res := c.findActiveVehicleByStatus(log, lp, vehicles)
+		res := c.identifyVehicleByStatus(log, lp, vehicles)
 		if tc.res != res {
 			t.Errorf("expected %v, got %v", tc.res, res)
 		}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 01d8dde8..bf54acc9 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -751,13 +751,6 @@ func TestMinSoC(t *testing.T) {
 func TestVehicleDetectByID(t *testing.T) {
 	ctrl := gomock.NewController(t)
 
-	type charger struct {
-		*mock.MockCharger
-		*mock.MockIdentifier
-	}
-
-	c := &charger{mock.NewMockCharger(ctrl), mock.NewMockIdentifier(ctrl)}
-
 	v1 := mock.NewMockVehicle(ctrl)
 	v2 := mock.NewMockVehicle(ctrl)
 
@@ -768,41 +761,32 @@ func TestVehicleDetectByID(t *testing.T) {
 		prepare    func(testcase)
 	}
 	tc := []testcase{
-		{"_/_/_->0", "", "", "", nil, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
-		}},
 		{"1/_/_->0", "1", "", "", nil, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 		}},
 		{"1/1/2->1", "1", "1", "2", v1, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 		}},
 		{"2/1/2->2", "2", "1", "2", v2, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 		}},
 		{"11/1*/2->1", "11", "1*", "2", v1, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			// v2.EXPECT().Identify().Return(tc.i2, nil)
 		}},
 		{"22/1*/2*->2", "22", "1*", "2*", v2, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 		}},
 		{"2/_/*->2", "2", "", "*", v2, func(tc testcase) {
-			c.MockIdentifier.EXPECT().Identify().Return(tc.id, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
 			v2.EXPECT().Identify().Return(tc.i2, nil)
 			v1.EXPECT().Identify().Return(tc.i1, nil)
@@ -815,7 +799,6 @@ func TestVehicleDetectByID(t *testing.T) {
 
 		lp := &LoadPoint{
 			log:      util.NewLogger("foo"),
-			charger:  c,
 			vehicles: []api.Vehicle{v1, v2},
 		}
 
@@ -823,7 +806,7 @@ func TestVehicleDetectByID(t *testing.T) {
 			tc.prepare(tc)
 		}
 
-		if res := lp.findActiveVehicleByID(); tc.res != res {
+		if res := lp.selectVehicleByID(tc.id); tc.res != res {
 			t.Errorf("expected %v, got %v", tc.res, res)
 		}
 	}