Repository: andig/evcc
Pull request #1298 (2021-08-07 09:47:41)

Add ABL eMH1
Fix https://github.com/andig/evcc/issues/526

- [x] Die Logik für enable/enabled fehlt noch, siehe Frage in https://github.com/andig/evcc/issues/526#issuecomment-894630646


Patch:
diff --git a/README.md b/README.md
index fcbbefa0..e284b7f6 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/)
@@ -199,6 +199,7 @@ Available charger implementations are:
 - `custom`: default charger implementation using configurable [plugins](#plugins) for integrating any type of charger
 
 Smart-Home outlet charger implementations:
+
 - `fritzdect`: Fritz!DECT 200/210 outlets
 - `shelly`: Shelly outlets
 - `tasmota`: Tasmota outlets
@@ -261,11 +262,12 @@ Available vehicle remote interface implementations are:
 - `audi`: Audi (eTron, Q55)
 - `bmw`: BMW (i3)
 - `carwings`: Nissan (Leaf pre 2019)
-- `citroen`, `opel`, `peugeot`: Follow this [tutorial](https://github.com/flobz/psa_car_controller) to obtain client credentials for PSA.
+- `citroen`, `ds`, `opel`, `peugeot`: All PSA brands
 - `fiat`: Fiat (500e)
 - `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/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/go-e.go b/charger/go-e.go
index 738ecf69..31f34d5c 100644
--- a/charger/go-e.go
+++ b/charger/go-e.go
@@ -34,6 +34,44 @@ type goeStatusResponse struct {
 	Tmp int    `json:"tmp,string"` // temperature [°C]
 	Dws int    `json:"dws,string"` // energy [Ws]
 	Nrg []int  `json:"nrg"`        // voltage, current, power
+	Uby int    `json:"uby,string"` // unlocked_by
+	Rna string `json:"rna"`        // RFID 1
+	Rnm string `json:"rnm"`        // RFID 2
+	Rne string `json:"rne"`        // RFID 3
+	Rn4 string `json:"rn4"`        // RFID 4
+	Rn5 string `json:"rn5"`        // RFID 5
+	Rn6 string `json:"rn6"`        // RFID 6
+	Rn7 string `json:"rn7"`        // RFID 7
+	Rn8 string `json:"rn8"`        // RFID 8
+	Rn9 string `json:"rn9"`        // RFID 9
+	Rn1 string `json:"rn1"`        // RFID 10
+}
+
+func (g goeStatusResponse) RFIDName(id int) string {
+	switch id {
+	case 1:
+		return g.Rna
+	case 2:
+		return g.Rnm
+	case 3:
+		return g.Rne
+	case 4:
+		return g.Rn4
+	case 5:
+		return g.Rn5
+	case 6:
+		return g.Rn6
+	case 7:
+		return g.Rn7
+	case 8:
+		return g.Rn8
+	case 9:
+		return g.Rn9
+	case 10:
+		return g.Rn1
+	default:
+		return ""
+	}
 }
 
 // GoE charger implementation
@@ -131,6 +169,8 @@ func (c *GoE) apiStatus() (status goeStatusResponse, err error) {
 // goeStatusResponse is only valid for local api. Use Fwv if valid.
 func (c *GoE) apiUpdate(payload string) (goeStatusResponse, error) {
 	if c.token == "" {
+		// let charger settle after update
+		defer time.Sleep(2 * time.Second)
 		return c.localResponse("mqtt", payload)
 	}
 
@@ -143,11 +183,6 @@ func (c *GoE) apiUpdate(payload string) (goeStatusResponse, error) {
 	return status, err
 }
 
-// isValid checks is status response is local
-func isValid(status goeStatusResponse) bool {
-	return status.Fwv != ""
-}
-
 // Status implements the api.Charger interface
 func (c *GoE) Status() (api.ChargeStatus, error) {
 	status, err := c.apiStatus()
@@ -192,8 +227,10 @@ func (c *GoE) Enable(enable bool) error {
 	}
 
 	status, err := c.apiUpdate(fmt.Sprintf("alw=%d", b))
-	if err == nil && isValid(status) && status.Alw != b {
-		return fmt.Errorf("alw update failed: %d", status.Alw)
+	if err == nil {
+		if status, err = c.apiStatus(); err == nil && status.Alw != b {
+			return fmt.Errorf("alw update failed: %d", status.Alw)
+		}
 	}
 
 	return err
@@ -202,8 +239,10 @@ func (c *GoE) Enable(enable bool) error {
 // MaxCurrent implements the api.Charger interface
 func (c *GoE) MaxCurrent(current int64) error {
 	status, err := c.apiUpdate(fmt.Sprintf("amx=%d", current))
-	if err == nil && isValid(status) && int64(status.Amp) != current {
-		return fmt.Errorf("amp update failed: %d", status.Amp)
+	if err == nil {
+		if status, err = c.apiStatus(); err == nil && int64(status.Amp) != current {
+			return fmt.Errorf("amp update failed: %d", status.Amp)
+		}
 	}
 
 	return err
@@ -240,3 +279,14 @@ func (c *GoE) Currents() (float64, float64, float64, error) {
 	}
 	return 0, 0, 0, err
 }
+
+var _ api.Identifier = (*GoE)(nil)
+
+// Identify implements the api.Identifier interface
+func (c *GoE) Identify() (string, error) {
+	status, err := c.apiStatus()
+	if status.Uby == 0 {
+		return "", api.ErrNotAvailable
+	}
+	return status.RFIDName(status.Uby), err
+}
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
new file mode 100644
index 00000000..d2ee716e
--- /dev/null
+++ b/core/coordinator.go
@@ -0,0 +1,75 @@
+package core
+
+import (
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/util"
+)
+
+type vehicleCoordinator struct {
+	tracked map[api.Vehicle]interface{}
+}
+
+var coordinator *vehicleCoordinator
+
+func init() {
+	coordinator = &vehicleCoordinator{
+		tracked: make(map[api.Vehicle]interface{}),
+	}
+}
+
+func (lp *vehicleCoordinator) aquire(owner interface{}, vehicle api.Vehicle) {
+	lp.tracked[vehicle] = owner
+}
+
+func (lp *vehicleCoordinator) release(vehicle api.Vehicle) {
+	delete(lp.tracked, vehicle)
+}
+
+func (lp *vehicleCoordinator) availableVehicles(owner interface{}, vehicles []api.Vehicle) []api.Vehicle {
+	var res []api.Vehicle
+
+	for _, vv := range vehicles {
+		if _, ok := vv.(api.ChargeState); ok {
+			if o, ok := lp.tracked[vv]; o == owner || !ok {
+				res = append(res, vv)
+			}
+		}
+	}
+
+	return res
+}
+
+// find active vehicle by charge state
+func (lp *vehicleCoordinator) findActiveVehicleByStatus(log *util.Logger, owner interface{}, vehicles []api.Vehicle) api.Vehicle {
+	var res api.Vehicle
+
+	available := lp.availableVehicles(owner, vehicles)
+	// log.DEBUG.Printf("!!available vehicles: %v", funk.Map(available, func(v api.Vehicle) string {
+	// 	return v.Title()
+	// }))
+
+	for _, vehicle := range available {
+		if vs, ok := vehicle.(api.ChargeState); ok {
+			status, err := vs.Status()
+
+			if err != nil {
+				log.ERROR.Println("vehicle status:", err)
+				continue
+			}
+
+			log.DEBUG.Printf("vehicle status: %s (%s)", status, vehicle.Title())
+
+			// vehicle is plugged or charging, so it should be the right one
+			if status == api.StatusB || status == api.StatusC {
+				if res != nil {
+					log.DEBUG.Printf("vehicle status: >1 matches, giving up")
+					return nil
+				}
+
+				res = vehicle
+			}
+		}
+	}
+
+	return res
+}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index f7689ef7..1ab21219 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -427,10 +427,13 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish("minSoC", lp.SoC.Min)
 	lp.Unlock()
 
-	// use first vehicle for estimator
 	// run during prepare() to ensure cache has been attached
 	if len(lp.vehicles) > 0 {
-		lp.setActiveVehicle(lp.vehicles[0])
+		// associate first vehicle if it cannot be auto-detected
+		if _, ok := lp.vehicles[0].(api.ChargeState); !ok {
+			lp.setActiveVehicle(lp.vehicles[0])
+		}
+
 		lp.startVehicleDetection()
 	}
 
@@ -607,10 +610,12 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
 
 	from := "unknown"
 	if lp.vehicle != nil {
+		coordinator.release(lp.vehicle)
 		from = lp.vehicle.Title()
 	}
 	to := "unknown"
 	if vehicle != nil {
+		coordinator.aquire(lp, vehicle)
 		to = vehicle.Title()
 	}
 	lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
@@ -691,29 +696,6 @@ func (lp *LoadPoint) findActiveVehicleByID() api.Vehicle {
 	return nil
 }
 
-// find active vehicle by charge state
-func (lp *LoadPoint) findActiveVehicleByStatus() api.Vehicle {
-	for _, vehicle := range lp.vehicles {
-		if vs, ok := vehicle.(api.ChargeState); ok {
-			status, err := vs.Status()
-
-			if err != nil {
-				lp.log.ERROR.Println("vehicle status:", err)
-				return nil
-			}
-
-			lp.log.DEBUG.Printf("vehicle status: %s (%s)", status, vehicle.Title())
-
-			// vehicle is plugged or charging, so it should be the right one
-			if status == api.StatusB || status == api.StatusC {
-				return vehicle
-			}
-		}
-	}
-
-	return nil
-}
-
 // findActiveVehicle validates if the active vehicle is still connected to the loadpoint
 func (lp *LoadPoint) findActiveVehicle() {
 	if len(lp.vehicles) <= 1 {
@@ -725,7 +707,7 @@ func (lp *LoadPoint) findActiveVehicle() {
 		return
 	}
 
-	if vehicle := lp.findActiveVehicleByStatus(); vehicle != nil {
+	if vehicle := coordinator.findActiveVehicleByStatus(lp.log, lp, lp.vehicles); vehicle != nil {
 		lp.setActiveVehicle(vehicle)
 		return
 	}
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/nissan.go b/vehicle/nissan.go
index 803fb29f..fe1cbd9d 100644
--- a/vehicle/nissan.go
+++ b/vehicle/nissan.go
@@ -50,14 +50,14 @@ func NewNissanFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	log := util.NewLogger("nissan")
 	identity := nissan.NewIdentity(log)
 
-	err := identity.Login(cc.User, cc.Password)
-	if err != nil {
+	if err := identity.Login(cc.User, cc.Password); err != nil {
 		return v, fmt.Errorf("login failed: %w", err)
 	}
 
 	api := nissan.NewAPI(log, identity, strings.ToUpper(cc.VIN))
 
-	if err == nil && cc.VIN == "" {
+	var err error
+	if cc.VIN == "" {
 		api.VIN, err = findVehicle(api.Vehicles())
 		if err == nil {
 			log.DEBUG.Printf("found vehicle: %v", api.VIN)
diff --git a/vehicle/ovms.go b/vehicle/ovms.go
index ecb36025..747232cb 100644
--- a/vehicle/ovms.go
+++ b/vehicle/ovms.go
@@ -32,7 +32,6 @@ type Ovms struct {
 	*embed
 	*request.Helper
 	user, password, vehicleId, server string
-	interval                          time.Duration
 	chargeG                           func() (interface{}, error)
 }
 
@@ -63,7 +62,6 @@ func NewOvmsFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		password:  cc.Password,
 		vehicleId: cc.VehicleID,
 		server:    cc.Server,
-		interval:  cc.Cache,
 	}
 
 	v.chargeG = provider.NewCached(v.batteryAPI, cc.Cache).InterfaceGetter()
@@ -87,24 +85,16 @@ func (v *Ovms) loginToServer() (err error) {
 	return err
 }
 
-func (v *Ovms) delete(url string) error {
-	req, err := request.New(http.MethodDelete, url, nil)
-	if err == nil {
-		var resp *http.Response
-		if resp, err = v.Do(req); err == nil {
-			resp.Body.Close()
-		}
-	}
-	return err
-}
-
-func (v *Ovms) authFlow() (bool, error) {
+func (v *Ovms) authFlow() error {
 	var resp ovmsConnectResponse
 	err := v.loginToServer()
 	if err == nil {
 		resp, err = v.connectRequest()
+		if err == nil && resp.NetConnected != 1 {
+			return api.ErrMustRetry
+		}
 	}
-	return resp.NetConnected == 1, err
+	return err
 }
 
 func (v *Ovms) connectRequest() (ovmsConnectResponse, error) {
@@ -121,34 +111,21 @@ func (v *Ovms) chargeRequest() (ovmsChargeResponse, error) {
 	return res, err
 }
 
-func (v *Ovms) disconnect() error {
-	uri := fmt.Sprintf("http://%s:6868/api/vehicle/%s", v.server, v.vehicleId)
-
-	err := v.delete(uri)
-	if err == nil {
-		uri = fmt.Sprintf("http://%s:6868/api/cookie", v.server)
-		return v.delete(uri)
-	}
-
-	return err
-}
-
 // batteryAPI provides battery-status api response
 func (v *Ovms) batteryAPI() (interface{}, error) {
 	var resp ovmsChargeResponse
 
-	ovmsConnected, err := v.authFlow()
-	if err == nil {
-		resp, err = v.chargeRequest()
-
+	resp, err := v.chargeRequest()
+	if err != nil {
+		err = v.authFlow()
 		if err == nil {
-			err = v.disconnect()
+			resp, err = v.chargeRequest()
 		}
+	}
 
-		messageAge := time.Duration(resp.MessageAgeServer) * time.Second
-		if err == nil && messageAge > v.interval+time.Minute && ovmsConnected {
-			err = api.ErrMustRetry
-		}
+	messageAge := time.Duration(resp.MessageAgeServer) * time.Second
+	if err == nil && messageAge > time.Minute {
+		err = api.ErrMustRetry
 	}
 
 	return resp, err
@@ -181,7 +158,7 @@ func (v *Ovms) Status() (api.ChargeStatus, error) {
 		}
 	}
 
-	return status, nil
+	return status, err
 }
 
 var _ api.VehicleRange = (*Ovms)(nil)
@@ -194,7 +171,7 @@ func (v *Ovms) Range() (int64, error) {
 		return strconv.ParseInt(res.EstimatedRange, 0, 64)
 	}
 
-	return 0, nil
+	return 0, err
 }
 
 var _ api.VehicleFinishTimer = (*Ovms)(nil)
@@ -210,5 +187,5 @@ func (v *Ovms) FinishTime() (time.Time, error) {
 		}
 	}
 
-	return time.Time{}, api.ErrNotAvailable
+	return time.Time{}, err
 }
diff --git a/vehicle/psa.go b/vehicle/psa.go
index fe15ab0e..f88ddeb6 100644
--- a/vehicle/psa.go
+++ b/vehicle/psa.go
@@ -11,10 +11,11 @@ import (
 	"github.com/andig/evcc/vehicle/psa"
 )
 
-// https://github.com/flobz/psa_car_controller
+// https://github.com/TA2k/ioBroker.psa
 
 func init() {
 	registry.Add("citroen", NewCitroenFromConfig)
+	registry.Add("ds", NewDSFromConfig)
 	registry.Add("opel", NewOpelFromConfig)
 	registry.Add("peugeot", NewPeugeotFromConfig)
 }
@@ -22,19 +23,37 @@ func init() {
 // NewCitroenFromConfig creates a new vehicle
 func NewCitroenFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	log := util.NewLogger("citroen")
-	return newPSA(log, "citroen.com", "clientsB2CCitroen", other)
+	return newPSA(log,
+		"citroen.com", "clientsB2CCitroen",
+		"5364defc-80e6-447b-bec6-4af8d1542cae", "iE0cD8bB0yJ0dS6rO3nN1hI2wU7uA5xR4gP7lD6vM0oH0nS8dN",
+		other)
+}
+
+// NewDSFromConfig creates a new vehicle
+func NewDSFromConfig(other map[string]interface{}) (api.Vehicle, error) {
+	log := util.NewLogger("ds")
+	return newPSA(log,
+		"driveds.com", "clientsB2CDS",
+		"cbf74ee7-a303-4c3d-aba3-29f5994e2dfa", "X6bE6yQ3tH1cG5oA6aW4fS6hK0cR0aK5yN2wE4hP8vL8oW5gU3",
+		other)
 }
 
 // NewOpelFromConfig creates a new vehicle
 func NewOpelFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	log := util.NewLogger("opel")
-	return newPSA(log, "opel.com", "clientsB2COpel", other)
+	return newPSA(log,
+		"opel.com", "clientsB2COpel",
+		"07364655-93cb-4194-8158-6b035ac2c24c", "F2kK7lC5kF5qN7tM0wT8kE3cW1dP0wC5pI6vC0sQ5iP5cN8cJ8",
+		other)
 }
 
 // NewPeugeotFromConfig creates a new vehicle
 func NewPeugeotFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	log := util.NewLogger("peugeot")
-	return newPSA(log, "peugeot.com", "clientsB2CPeugeot", other)
+	return newPSA(log,
+		"peugeot.com", "clientsB2CPeugeot",
+		"1eebc2d5-5df3-459b-a624-20abfcf82530", "T5tP7iS0cO8sC0lA2iE2aR7gK6uE5rF3lJ8pC3nO1pR7tL8vU1",
+		other)
 }
 
 // PSA is an api.Vehicle implementation for PSA cars
@@ -44,13 +63,17 @@ type PSA struct {
 }
 
 // newPSA creates a new vehicle
-func newPSA(log *util.Logger, brand, realm string, other map[string]interface{}) (api.Vehicle, error) {
+func newPSA(log *util.Logger, brand, realm, id, secret string, other map[string]interface{}) (api.Vehicle, error) {
 	cc := struct {
-		embed                  `mapstructure:",squash"`
-		ClientID, ClientSecret string
-		User, Password, VIN    string
-		Cache                  time.Duration
+		embed               `mapstructure:",squash"`
+		Credentials         ClientCredentials
+		User, Password, VIN string
+		Cache               time.Duration
 	}{
+		Credentials: ClientCredentials{
+			ID:     id,
+			Secret: secret,
+		},
 		Cache: interval,
 	}
 
@@ -58,20 +81,18 @@ func newPSA(log *util.Logger, brand, realm string, other map[string]interface{})
 		return nil, err
 	}
 
-	if cc.ClientID == "" || cc.ClientSecret == "" {
-		return nil, errors.New("missing client id and/or secret (see https://github.com/flobz/psa_car_controller)")
-	}
-
 	v := &PSA{
 		embed: &cc.embed,
 	}
 
-	api := psa.NewAPI(log, brand, realm, cc.ClientID, cc.ClientSecret)
-	err := api.Login(cc.User, cc.Password)
-	if err != nil {
+	identity := psa.NewIdentity(log, brand, cc.Credentials.ID, cc.Credentials.Secret)
+
+	if err := identity.Login(cc.User, cc.Password); err != nil {
 		return v, fmt.Errorf("login failed: %w", err)
 	}
 
+	api := psa.NewAPI(log, identity, realm, cc.Credentials.ID)
+
 	vehicles, err := api.Vehicles()
 	if err != nil {
 		return nil, err
diff --git a/vehicle/psa/api.go b/vehicle/psa/api.go
index 304f5afa..1fb09c2f 100644
--- a/vehicle/psa/api.go
+++ b/vehicle/psa/api.go
@@ -1,7 +1,6 @@
 package psa
 
 import (
-	"context"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -12,7 +11,6 @@ import (
 	"golang.org/x/oauth2"
 )
 
-// https://github.com/flobz/psa_car_controller
 // https://developer.groupe-psa.io/webapi/b2c/api-reference/specification
 
 // BaseURL is the API base url
@@ -21,49 +19,25 @@ const BaseURL = "https://api.groupe-psa.com/connectedcar/v4"
 // API is an api.Vehicle implementation for PSA cars
 type API struct {
 	*request.Helper
-	brand, realm string
-	id, secret   string // client
+	realm string
+	id    string
 }
 
 // NewAPI creates a new vehicle
-func NewAPI(log *util.Logger, brand, realm, id, secret string) *API {
+func NewAPI(log *util.Logger, identity oauth2.TokenSource, realm, id string) *API {
 	v := &API{
 		Helper: request.NewHelper(log),
-		brand:  brand,
 		realm:  realm,
 		id:     id,
-		secret: secret,
 	}
 
-	return v
-}
-
-// Login performs the login
-func (v *API) Login(user, password string) error {
-	config := oauth2.Config{
-		ClientID:     v.id,
-		ClientSecret: v.secret,
-		Endpoint: oauth2.Endpoint{
-			AuthURL:   "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize",
-			TokenURL:  fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", v.brand),
-			AuthStyle: oauth2.AuthStyleInHeader,
-		},
-		Scopes: []string{"openid profile"},
-	}
-
-	ctx := context.WithValue(
-		context.Background(),
-		oauth2.HTTPClient,
-		v.Client,
-	)
-
-	// replace client with authenticated oauth client
-	token, err := config.PasswordCredentialsToken(ctx, user, password)
-	if err == nil {
-		v.Client = config.Client(ctx, token)
+	// replace client transport with authenticated transport
+	v.Client.Transport = &oauth2.Transport{
+		Source: identity,
+		Base:   v.Client.Transport,
 	}
 
-	return err
+	return v
 }
 
 // Vehicle is a single vehicle
diff --git a/vehicle/psa/identity.go b/vehicle/psa/identity.go
new file mode 100644
index 00000000..4b7c965a
--- /dev/null
+++ b/vehicle/psa/identity.go
@@ -0,0 +1,49 @@
+package psa
+
+import (
+	"context"
+	"fmt"
+
+	"github.com/andig/evcc/util"
+	"github.com/andig/evcc/util/request"
+	"golang.org/x/oauth2"
+)
+
+type Identity struct {
+	*request.Helper
+	oc *oauth2.Config
+	oauth2.TokenSource
+}
+
+// NewIdentity creates PSA identity
+func NewIdentity(log *util.Logger, brand, id, secret string) *Identity {
+	return &Identity{
+		Helper: request.NewHelper(log),
+		oc: &oauth2.Config{
+			ClientID:     id,
+			ClientSecret: secret,
+			Endpoint: oauth2.Endpoint{
+				AuthURL:   "https://api.mpsa.com/api/connectedcar/v2/oauth/authorize",
+				TokenURL:  fmt.Sprintf("https://idpcvs.%s/am/oauth2/access_token", brand),
+				AuthStyle: oauth2.AuthStyleInHeader,
+			},
+			Scopes: []string{"openid profile"},
+		},
+	}
+}
+
+func (v *Identity) Login(user, password string) error {
+	ctx := context.WithValue(
+		context.Background(),
+		oauth2.HTTPClient,
+		v.Client,
+	)
+
+	// replace client with authenticated oauth client
+	token, err := v.oc.PasswordCredentialsToken(ctx, user, password)
+	if err == nil {
+		v.TokenSource = v.oc.TokenSource(ctx, token)
+	}
+
+	return err
+}
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/tesla.go b/vehicle/tesla.go
index fe054965..48271299 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -26,7 +26,7 @@ func init() {
 	registry.Add("tesla", NewTeslaFromConfig)
 }
 
-// NewTeslaFromConfig creates a new Tesla vehicle
+// NewTeslaFromConfig creates a new vehicle
 func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	cc := struct {
 		embed          `mapstructure:",squash"`
diff --git a/vehicle/tronity.go b/vehicle/tronity.go
index 700ecb2c..24adcf80 100644
--- a/vehicle/tronity.go
+++ b/vehicle/tronity.go
@@ -32,7 +32,7 @@ func init() {
 	registry.Add("tronity", NewTronityFromConfig)
 }
 
-// NewTronityFromConfig creates a new Tronity vehicle
+// NewTronityFromConfig creates a new vehicle
 func NewTronityFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	cc := struct {
 		embed       `mapstructure:",squash"`
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index 6b6dc8fc..a9abec16 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -15,12 +15,12 @@ type embed struct {
 	Identifier_ string `mapstructure:"identifier"`
 }
 
-// Title implements the Vehicle.Title interface
+// Title implements the api.Vehicle interface
 func (v *embed) Title() string {
 	return v.Title_
 }
 
-// Capacity implements the Vehicle.Capacity interface
+// Capacity implements the api.Vehicle interface
 func (v *embed) Capacity() int64 {
 	return v.Capacity_
 }
diff --git a/vehicle/wrapper/wrapper.go b/vehicle/wrapper/wrapper.go
index f612cf90..2470ced9 100644
--- a/vehicle/wrapper/wrapper.go
+++ b/vehicle/wrapper/wrapper.go
@@ -20,12 +20,12 @@ func New(w api.Vehicle, err error) (api.Vehicle, error) {
 	return v, nil
 }
 
-// Title implements the Vehicle.Title interface
+// Title implements the api.Vehicle interface
 func (v *Wrapper) Title() string {
 	return "unavailable"
 }
 
-// Capacity implements the Vehicle.Capacity interface
+// Capacity implements the api.Vehicle interface
 func (v *Wrapper) Capacity() int64 {
 	return 0
 }
@@ -35,7 +35,7 @@ func (v *Wrapper) Identify() (string, error) {
 	return "", v.err
 }
 
-// SoC implements the api.Vehicle interface
+// SoC implements the api.Battery interface
 func (v *Wrapper) SoC() (float64, error) {
 	return 0, v.err
 }


Test patch:
diff --git a/core/coordinator_test.go b/core/coordinator_test.go
new file mode 100644
index 00000000..f424aee6
--- /dev/null
+++ b/core/coordinator_test.go
@@ -0,0 +1,63 @@
+package core
+
+import (
+	"testing"
+
+	"github.com/andig/evcc/api"
+	"github.com/andig/evcc/mock"
+	"github.com/andig/evcc/util"
+	"github.com/golang/mock/gomock"
+)
+
+func TestVehicleDetectByStatus(t *testing.T) {
+	ctrl := gomock.NewController(t)
+
+	type vehicle struct {
+		*mock.MockVehicle
+		*mock.MockChargeState
+	}
+
+	v1 := &vehicle{mock.NewMockVehicle(ctrl), mock.NewMockChargeState(ctrl)}
+	v2 := &vehicle{mock.NewMockVehicle(ctrl), mock.NewMockChargeState(ctrl)}
+
+	type testcase struct {
+		string
+		v1, v2 api.ChargeStatus
+		res    api.Vehicle
+	}
+	tc := []testcase{
+		{"A/A->0", api.StatusA, api.StatusA, nil},
+		{"B/A->1", api.StatusB, api.StatusA, v1},
+		{"B/A->1", api.StatusB, api.StatusA, v1},
+		{"A/B->2", api.StatusA, api.StatusB, v2},
+		{"A/B->2", api.StatusA, api.StatusB, v2},
+		{"B/B->1", api.StatusB, api.StatusB, nil},
+	}
+
+	log := util.NewLogger("foo")
+	vehicles := []api.Vehicle{v1, v2}
+
+	lp := &LoadPoint{}
+	c := &vehicleCoordinator{make(map[api.Vehicle]interface{})}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		v1.MockChargeState.EXPECT().Status().Return(tc.v1, nil)
+		v2.MockChargeState.EXPECT().Status().Return(tc.v2, nil)
+		v1.MockVehicle.EXPECT().Title().Return("v1")
+		v2.MockVehicle.EXPECT().Title().Return("v2")
+
+		res := c.findActiveVehicleByStatus(log, lp, vehicles)
+		if tc.res != res {
+			t.Errorf("expected %v, got %v", tc.res, res)
+		}
+
+		if res != nil {
+			c.aquire(lp, res)
+		} else {
+			c.release(res)
+		}
+	}
+
+}
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index afbb24a5..01d8dde8 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -828,61 +828,3 @@ func TestVehicleDetectByID(t *testing.T) {
 		}
 	}
 }
-
-func TestVehicleDetectByStatus(t *testing.T) {
-	ctrl := gomock.NewController(t)
-
-	type vehicle struct {
-		*mock.MockVehicle
-		*mock.MockChargeState
-	}
-
-	v1 := &vehicle{mock.NewMockVehicle(ctrl), mock.NewMockChargeState(ctrl)}
-	v2 := &vehicle{mock.NewMockVehicle(ctrl), mock.NewMockChargeState(ctrl)}
-
-	type testcase struct {
-		string
-		v1, v2  api.ChargeStatus
-		res     api.Vehicle
-		prepare func(testcase)
-	}
-	tc := []testcase{
-		{"A/A->0", api.StatusA, api.StatusA, nil, func(t testcase) {
-			v1.MockChargeState.EXPECT().Status().Return(t.v1, nil)
-			v1.MockVehicle.EXPECT().Title().Return("v1")
-			v2.MockChargeState.EXPECT().Status().Return(t.v2, nil)
-			v2.MockVehicle.EXPECT().Title().Return("v2")
-		}},
-		{"B/A->1", api.StatusB, api.StatusA, v1, func(t testcase) {
-			v1.MockChargeState.EXPECT().Status().Return(t.v1, nil)
-			v1.MockVehicle.EXPECT().Title().Return("v1")
-		}},
-		{"A/B->2", api.StatusA, api.StatusB, v2, func(t testcase) {
-			v1.MockChargeState.EXPECT().Status().Return(t.v1, nil)
-			v1.MockVehicle.EXPECT().Title().Return("v1")
-			v2.MockChargeState.EXPECT().Status().Return(t.v2, nil)
-			v2.MockVehicle.EXPECT().Title().Return("v2")
-		}},
-		{"B/B->1", api.StatusB, api.StatusB, v1, func(t testcase) {
-			v1.MockChargeState.EXPECT().Status().Return(t.v1, nil)
-			v1.MockVehicle.EXPECT().Title().Return("v1")
-		}},
-	}
-
-	for _, tc := range tc {
-		t.Logf("%+v", tc)
-
-		lp := &LoadPoint{
-			log:      util.NewLogger("foo"),
-			vehicles: []api.Vehicle{v1, v2},
-		}
-
-		if tc.prepare != nil {
-			tc.prepare(tc)
-		}
-
-		if res := lp.findActiveVehicleByStatus(); tc.res != res {
-			t.Errorf("expected %v, got %v", tc.res, res)
-		}
-	}
-}