Repository: andig/evcc
Pull request #1292 (2021-08-05 15:57:45)

Fix slow go-e updates
Refs https://github.com/andig/evcc/discussions/1276

sebnaf: mit 7 (!)s funktioniert es. Issue ist bei go-e bekannt. Betrifft API v1 mit Hardware v3 Chargern.

andig: Mhhm. Und für v1 reichen die 2s? To merge or not to merge…
premultiply: Statt dem HTTP-Gebastel würde ich vorschlagen die gesamte Schnittstelle auf Modbus umstellen.
Die kann man auch eindeutig und ohne andere Abhängigkeiten aktivieren und deaktivieren...
sebnaf: > Statt dem HTTP-Gebastel würde ich vorschlagen die gesamte Schnittstelle auf Modbus umstellen.
> Die kann man auch eindeutig und ohne andere Abhängigkeiten aktivieren und deaktivieren...

Aber Modbus hat man doch ein Delay von mindestens 10s, oder?
premultiply: Wiso denn das?
sebnaf: > Mhhm. Und für v1 reichen die 2s? To merge or not to merge…

Lass es mal so. Vielleicht meldet sich go-e ja demnächst. Und bis dahin ist es bei mir lokal mit 7s…
sebnaf: > Wiso denn das?



> Wiso denn das?

Ich hatte gelesen, dass der Poll ausgehend vom charger alle 10s erfolgt.
Ich finde es schon OK, per HTTP dem charger zu sagen was er machen soll und er dies bestätigt.
Ich gehe davon aus, dass das mit einer der nächsten Funktionen auch wieder besser funktionieren wird. Die Hardware-Revision 2 kann das ja auch heute schon.

Wenn Du schaust, was in den letzten Wochen bei go-e an Funktionen kam, dann geht der Fokus in Richtung Cloud und REST/HTTP. Rein von der Architektur erwarte ich eher, dass Modbus und mqtt nicht mehr so aktiv gepflegt werden - und wenn, dann eher noch mqtt als Modbus. Aber das ist nur eine Vermutung.
premultiply: Modbus in Richtung des Ladepunkts kein Polling und ist die übliche Standardschnittstelle um Wallboxcontroller anzubinden.
Und auch sogar MQTT bietet ein solides und schnelles Push-Verfahren.
Weniger geeignet sind eher typische HTTP-Schnittstellen. Vor allem haben sie viel mehr Overhead und dadurch gerade schwachbrüstigen Embeddedsystemen systemimmanent größere Latenzen und erzeugen mehr Last und Datenverkehr als die genannten Alternativen die extra für solche Systeme entwickelt wurden.
sebnaf: > Modbus macht in Richtung des Ladepunkts kein Polling und ist zudem die übliche Standardschnittstelle um Wallboxcontroller anzubinden.
> Und auch sogar MQTT bietet ein solides und schnelles Push-Verfahren.
> Weniger geeignet sind eher typische HTTP-Schnittstellen. Vor allem haben sie viel mehr Overhead und dadurch gerade schwachbrüstigen Embeddedsystemen systemimmanent größere Latenzen und erzeugen mehr Last und Datenverkehr als die genannten Alternativen die extra für solche Systeme entwickelt wurden.

Dann lass uns anfangen - sag mir, wie ich helfen kann ;)

Patch:
diff --git a/README.md b/README.md
index fcbbefa0..2fbc7dae 100644
--- a/README.md
+++ b/README.md
@@ -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,7 +262,7 @@ 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)
diff --git a/api/api.go b/api/api.go
index df12724f..9b8b25c0 100644
--- a/api/api.go
+++ b/api/api.go
@@ -2,7 +2,7 @@ package api
 
 import "time"
 
-//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/andig/evcc/api Charger,Meter,MeterEnergy,Vehicle,ChargeRater
+//go:generate mockgen -package mock -destination ../mock/mock_api.go github.com/andig/evcc/api Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater
 
 // ChargeMode are charge modes modeled after OpenWB
 type ChargeMode string
diff --git a/charger/easee.go b/charger/easee.go
index b2604979..2dc569b5 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -12,6 +12,7 @@ import (
 	"github.com/andig/evcc/util"
 	"github.com/andig/evcc/util/request"
 	"github.com/andig/evcc/util/sponsor"
+	"github.com/thoas/go-funk"
 	"golang.org/x/oauth2"
 )
 
@@ -39,6 +40,7 @@ func NewEaseeFromConfig(other map[string]interface{}) (api.Charger, error) {
 		User     string
 		Password string
 		Charger  string
+		Circuit  int
 		Cache    time.Duration
 	}{
 		Cache: 10 * time.Second,
@@ -48,11 +50,11 @@ func NewEaseeFromConfig(other map[string]interface{}) (api.Charger, error) {
 		return nil, err
 	}
 
-	return NewEasee(cc.User, cc.Password, cc.Charger, cc.Cache)
+	return NewEasee(cc.User, cc.Password, cc.Charger, cc.Circuit, cc.Cache)
 }
 
 // NewEasee creates Easee charger
-func NewEasee(user, password, charger string, cache time.Duration) (*Easee, error) {
+func NewEasee(user, password, charger string, circuit int, cache time.Duration) (*Easee, error) {
 	log := util.NewLogger("easee")
 
 	if !sponsor.IsAuthorized() {
@@ -62,6 +64,7 @@ func NewEasee(user, password, charger string, cache time.Duration) (*Easee, erro
 	c := &Easee{
 		Helper:  request.NewHelper(log),
 		charger: charger,
+		circuit: circuit,
 		cache:   cache,
 		log:     log,
 	}
@@ -77,6 +80,14 @@ func NewEasee(user, password, charger string, cache time.Duration) (*Easee, erro
 		Base:   c.Client.Transport,
 	}
 
+	// find site
+	site, err := c.chargerDetails()
+	if err != nil {
+		return c, err
+	}
+
+	c.site = site.ID
+
 	// find charger
 	if charger == "" {
 		chargers, err := c.chargers()
@@ -85,47 +96,43 @@ func NewEasee(user, password, charger string, cache time.Duration) (*Easee, erro
 		}
 
 		if len(chargers) != 1 {
-			return c, fmt.Errorf("cannot determine charger id, found: %v", chargers)
+			return c, fmt.Errorf("cannot determine charger id, found: %v", funk.Map(chargers, func(c easee.Charger) string { return c.ID }))
 		}
 
 		c.charger = chargers[0].ID
 	}
 
-	// find site and circuit
-	site, err := c.chargerDetails()
-	if err != nil {
-		return c, err
-	}
+	// find circuit
+	if circuit == 0 {
+		if len(site.Circuits) != 1 {
+			return c, fmt.Errorf("cannot determine circuit id, found: %v", funk.Map(site.Circuits, func(c easee.Circuit) int { return c.ID }))
+		}
 
-	if len(site.Circuits) != 1 {
-		return c, fmt.Errorf("cannot determine circuit id, found: %v", site.Circuits)
+		c.circuit = site.Circuits[0].ID
 	}
 
-	c.site = site.ID
-	c.circuit = site.Circuits[0].ID
-
 	return c, err
 }
 
 func (c *Easee) chargers() (res []easee.Charger, err error) {
 	uri := fmt.Sprintf("%s/chargers", easee.API)
+
 	req, err := request.New(http.MethodGet, uri, nil, request.JSONEncoding)
-	if err != nil {
-		return nil, err
+	if err == nil {
+		err = c.DoJSON(req, &res)
 	}
 
-	err = c.DoJSON(req, &res)
 	return res, err
 }
 
 func (c *Easee) chargerDetails() (res easee.Site, err error) {
 	uri := fmt.Sprintf("%s/chargers/%s/site", easee.API, c.charger)
+
 	req, err := request.New(http.MethodGet, uri, nil, request.JSONEncoding)
-	if err != nil {
-		return res, err
+	if err == nil {
+		err = c.DoJSON(req, &res)
 	}
 
-	err = c.DoJSON(req, &res)
 	return res, err
 }
 
@@ -145,15 +152,17 @@ func (c *Easee) syncSmartCharging() error {
 			SmartCharging: &newSmartCharging,
 		}
 
-		var req *http.Request
 		uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
-		req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
+		resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
 		if err == nil {
-			_, err = c.Do(req)
-			c.updated = time.Time{} // clear cache
-			c.lastChargeMode = c.lp.GetMode()
-			c.lastSmartCharging = newSmartCharging
+			resp.Body.Close()
 		}
+
+		c.updated = time.Time{} // clear cache
+
+		c.lastChargeMode = c.lp.GetMode()
+		c.lastSmartCharging = newSmartCharging
+
 		return err
 	}
 
@@ -227,13 +236,14 @@ func (c *Easee) Enable(enable bool) error {
 			Enabled: &enable,
 		}
 
-		var req *http.Request
 		uri := fmt.Sprintf("%s/chargers/%s/settings", easee.API, c.charger)
-		if req, err = request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding); err == nil {
-			_, err = c.Do(req)
-			c.updated = time.Time{} // clear cache
+		resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
+		if err == nil {
+			resp.Body.Close()
 		}
 
+		c.updated = time.Time{} // clear cache
+
 		return err
 	}
 
@@ -243,12 +253,9 @@ func (c *Easee) Enable(enable bool) error {
 		action = "resume_charging"
 	}
 
-	var req *http.Request
 	uri := fmt.Sprintf("%s/chargers/%s/commands/%s", easee.API, c.charger, action)
-	if req, err = request.New(http.MethodPost, uri, nil, request.JSONEncoding); err == nil {
-		_, err = c.Do(req)
-		c.updated = time.Time{} // clear cache
-	}
+	_, err = c.Post(uri, request.JSONContent, nil)
+	c.updated = time.Time{} // clear cache
 
 	return err
 }
@@ -270,12 +277,13 @@ func (c *Easee) MaxCurrentMillis(current float64) error {
 	}
 
 	uri := fmt.Sprintf("%s/sites/%d/circuits/%d/settings", easee.API, c.site, c.circuit)
-	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
+	resp, err := c.Post(uri, request.JSONContent, request.MarshalJSON(data))
 	if err == nil {
-		_, err = c.Do(req)
-		c.updated = time.Time{} // clear cache
+		resp.Body.Close()
 	}
 
+	c.updated = time.Time{} // clear cache
+
 	return err
 }
 
diff --git a/charger/go-e.go b/charger/go-e.go
index 738ecf69..f9d241f9 100644
--- a/charger/go-e.go
+++ b/charger/go-e.go
@@ -131,6 +131,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 +145,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 +189,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 +201,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
diff --git a/charger/heidelberg-ec.go b/charger/heidelberg-ec.go
new file mode 100644
index 00000000..129f8e05
--- /dev/null
+++ b/charger/heidelberg-ec.go
@@ -0,0 +1,215 @@
+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"
+)
+
+// HeidelbergEC charger implementation
+type HeidelbergEC struct {
+	log     *util.Logger
+	conn    *modbus.Connection
+	current uint16
+}
+
+const (
+	hecRegFirmware      = 1   // Input
+	hecRegVehicleStatus = 5   // Input
+	hecRegTemperature   = 9   // Input
+	hecRegPower         = 14  // Input
+	hecRegEnergy        = 17  // Input
+	hecRegStandby       = 258 // Holding
+	hecRegAmpsConfig    = 261 // Holding
+)
+
+var hecRegCurrents = []uint16{6, 7, 8}
+
+func init() {
+	registry.Add("heidelberg", NewHeidelbergECFromConfig)
+}
+
+// https://wallbox.heidelberg.com/wp-content/uploads/2021/05/EC_ModBus_register_table_20210222.pdf (newer)
+// https://cdn.shopify.com/s/files/1/0101/2409/9669/files/heidelberg-energy-control-modbus.pdf (older)
+
+// 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,
+		},
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewHeidelbergEC(cc.URI, cc.Device, cc.Comset, cc.Baudrate, true, 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)
+	if err != nil {
+		return nil, err
+	}
+
+	if !sponsor.IsAuthorized() {
+		return nil, errors.New("heidelberg requires evcc sponsorship, register at https://cloud.evcc.io")
+	}
+
+	log := util.NewLogger("hec")
+	conn.Logger(log.TRACE)
+
+	wb := &HeidelbergEC{
+		log:     log,
+		conn:    conn,
+		current: 60, // assume min current
+	}
+
+	return wb, nil
+}
+
+// Status implements the api.Charger interface
+func (wb *HeidelbergEC) Status() (api.ChargeStatus, error) {
+	b, err := wb.conn.ReadInputRegisters(hecRegVehicleStatus, 1)
+	if err != nil {
+		return api.StatusNone, err
+	}
+
+	switch sb := b[1]; sb {
+	case 2, 3:
+		return api.StatusA, nil
+	case 4, 5:
+		return api.StatusB, nil
+	case 6, 7:
+		return api.StatusC, nil
+	default:
+		return api.StatusNone, fmt.Errorf("invalid status: %0x", sb)
+	}
+}
+
+// Enabled implements the api.Charger interface
+func (wb *HeidelbergEC) Enabled() (bool, error) {
+	b, err := wb.conn.ReadHoldingRegisters(hecRegAmpsConfig, 1)
+	if err != nil {
+		return false, err
+	}
+
+	cur := binary.BigEndian.Uint16(b)
+
+	enabled := cur != 0
+	if enabled {
+		wb.current = cur
+	}
+
+	return enabled, nil
+}
+
+// Enable implements the api.Charger interface
+func (wb *HeidelbergEC) Enable(enable bool) error {
+	var cur uint16
+	if enable {
+		cur = wb.current
+	}
+
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, cur)
+
+	_, err := wb.conn.WriteMultipleRegisters(hecRegAmpsConfig, 1, b)
+
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (wb *HeidelbergEC) MaxCurrent(current int64) error {
+	if current < 6 {
+		return fmt.Errorf("invalid current %d", current)
+	}
+
+	return wb.MaxCurrentMillis(float64(current))
+}
+
+var _ api.ChargerEx = (*HeidelbergEC)(nil)
+
+// MaxCurrentMillis implements the api.ChargerEx interface
+func (wb *HeidelbergEC) MaxCurrentMillis(current float64) error {
+	if current < 6 {
+		return fmt.Errorf("invalid current %.1f", current)
+	}
+
+	cur := uint16(10 * current)
+
+	b := make([]byte, 2)
+	binary.BigEndian.PutUint16(b, cur)
+
+	_, err := wb.conn.WriteMultipleRegisters(hecRegAmpsConfig, 1, b)
+	if err == nil {
+		wb.current = cur
+	}
+
+	return err
+}
+
+var _ api.Meter = (*HeidelbergEC)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (wb *HeidelbergEC) CurrentPower() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(hecRegPower, 1)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint16(b)), nil
+}
+
+var _ api.MeterEnergy = (*HeidelbergEC)(nil)
+
+// TotalEnergy implements the api.MeterEnergy interface
+func (wb *HeidelbergEC) TotalEnergy() (float64, error) {
+	b, err := wb.conn.ReadInputRegisters(hecRegEnergy, 2)
+	if err != nil {
+		return 0, err
+	}
+
+	return float64(binary.BigEndian.Uint32(b)) / 1e3, nil
+}
+
+var _ api.MeterCurrent = (*HeidelbergEC)(nil)
+
+// Currents implements the api.MeterCurrent interface
+func (wb *HeidelbergEC) Currents() (float64, float64, float64, error) {
+	var currents []float64
+	for _, regCurrent := range hecRegCurrents {
+		b, err := wb.conn.ReadInputRegisters(regCurrent, 1)
+		if err != nil {
+			return 0, 0, 0, err
+		}
+
+		currents = append(currents, float64(binary.BigEndian.Uint16(b))/10)
+	}
+
+	return currents[0], currents[1], currents[2], nil
+}
+
+var _ api.Diagnosis = (*HeidelbergEC)(nil)
+
+// Diagnose implements the api.Diagnosis interface
+func (wb *HeidelbergEC) Diagnose() {
+	if b, err := wb.conn.ReadInputRegisters(hecRegFirmware, 2); err == nil {
+		fmt.Printf("Firmware:\t%d.%d.%d\n", b[1], b[2], b[3])
+	}
+	if b, err := wb.conn.ReadInputRegisters(hecRegTemperature, 1); err == nil {
+		fmt.Printf("Temperature:\t%.1fC\n", float64(int16(binary.BigEndian.Uint16(b)))/10)
+	}
+	if b, err := wb.conn.ReadHoldingRegisters(hecRegStandby, 1); err == nil {
+		fmt.Printf("Standby:\t%0x\n", b[1])
+	}
+}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 984624f4..f7689ef7 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -28,7 +28,9 @@ const (
 	evVehicleConnect    = "connect"    // vehicle connected
 	evVehicleDisconnect = "disconnect" // vehicle disconnected
 
-	minActiveCurrent = 1.0 // minimum current at which a phase is treated as active
+	minActiveCurrent      = 1.0 // minimum current at which a phase is treated as active
+	vehicleDetectInterval = 3 * time.Minute
+	vehicleDetectDuration = 10 * time.Minute
 )
 
 // PollConfig defines the vehicle polling mode and interval
@@ -95,21 +97,22 @@ type LoadPoint struct {
 	MaxCurrent    float64       // Max allowed current. Physically ensured by the charger
 	GuardDuration time.Duration // charger enable/disable minimum holding time
 
-	enabled       bool      // Charger enabled state
-	chargeCurrent float64   // Charger current limit
-	guardUpdated  time.Time // Charger enabled/disabled timestamp
-	socUpdated    time.Time // SoC updated timestamp (poll: connected)
+	enabled                bool      // Charger enabled state
+	chargeCurrent          float64   // Charger current limit
+	guardUpdated           time.Time // Charger enabled/disabled timestamp
+	socUpdated             time.Time // SoC updated timestamp (poll: connected)
+	vehicleConnected       time.Time // Vehicle connected timestamp
+	vehicleConnectedTicker *clock.Ticker
 
 	charger     api.Charger
 	chargeTimer api.ChargeTimer
 	chargeRater api.ChargeRater
 
-	chargeMeter    api.Meter     // Charger usage meter
-	vehicle        api.Vehicle   // Currently active vehicle
-	vehicles       []api.Vehicle // Assigned vehicles
-	socEstimator   *soc.Estimator
-	socTimer       *soc.Timer
-	vehicleIdError error // state of last vehicle identification
+	chargeMeter  api.Meter     // Charger usage meter
+	vehicle      api.Vehicle   // Currently active vehicle
+	vehicles     []api.Vehicle // Assigned vehicles
+	socEstimator *soc.Estimator
+	socTimer     *soc.Timer
 
 	// cached state
 	status         api.ChargeStatus // Charger status
@@ -327,9 +330,11 @@ func (lp *LoadPoint) evVehicleConnectHandler() {
 	}
 
 	// flush all vehicles before updating state
+	lp.log.DEBUG.Println("vehicle api refresh")
 	provider.ResetCached()
 
 	// identify active vehicle
+	lp.startVehicleDetection()
 	lp.findActiveVehicle()
 
 	// immediately allow pv mode activity
@@ -348,6 +353,11 @@ func (lp *LoadPoint) evVehicleDisconnectHandler() {
 
 	lp.pushEvent(evVehicleDisconnect)
 
+	// remove active vehicle
+	if len(lp.vehicles) > 1 {
+		lp.setActiveVehicle(nil)
+	}
+
 	// set default mode on disconnect
 	if lp.OnDisconnect.Mode != "" && lp.GetMode() != api.ModeOff {
 		lp.SetMode(lp.OnDisconnect.Mode)
@@ -421,7 +431,7 @@ func (lp *LoadPoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	// run during prepare() to ensure cache has been attached
 	if len(lp.vehicles) > 0 {
 		lp.setActiveVehicle(lp.vehicles[0])
-		lp.findActiveVehicle()
+		lp.startVehicleDetection()
 	}
 
 	// read initial charger state to prevent immediately disabling charger
@@ -591,35 +601,64 @@ func (lp *LoadPoint) remoteControlled(demand RemoteDemand) bool {
 
 // setActiveVehicle assigns currently active vehicle and configures soc estimator
 func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) {
+	if lp.vehicle == vehicle {
+		return
+	}
+
+	from := "unknown"
 	if lp.vehicle != nil {
-		lp.log.INFO.Printf("vehicle updated: %s -> %s", lp.vehicle.Title(), vehicle.Title())
+		from = lp.vehicle.Title()
+	}
+	to := "unknown"
+	if vehicle != nil {
+		to = vehicle.Title()
 	}
+	lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to)
 
-	// update successful
-	lp.vehicleIdError = nil
+	if lp.vehicle = vehicle; vehicle != nil {
+		lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate)
 
-	lp.vehicle = vehicle
-	lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate)
+		lp.publish("socTitle", lp.vehicle.Title())
+		lp.publish("socCapacity", lp.vehicle.Capacity())
+	} else {
+		lp.socEstimator = nil
+
+		lp.publish("socTitle", "unknown")
+		lp.publish("socCapacity", 0)
+	}
+}
 
-	lp.publish("socTitle", lp.vehicle.Title())
-	lp.publish("socCapacity", lp.vehicle.Capacity())
+// startVehicleDetection resets connection timer and starts api refresh timer
+func (lp *LoadPoint) startVehicleDetection() {
+	lp.vehicleConnected = lp.clock.Now()
+	lp.vehicleConnectedTicker = lp.clock.Ticker(vehicleDetectInterval)
 }
 
-// vehicleIdentificationAllowed returns true if active vehicle has not yet been identified
+// vehicleIdentificationAllowed checks if loadpoint has multiple vehicles associated and starts discovery period
 func (lp *LoadPoint) vehicleIdentificationAllowed() bool {
-	return errors.Is(lp.vehicleIdError, api.ErrMustRetry)
+	res := len(lp.vehicles) > 1 && lp.connected() && lp.clock.Since(lp.vehicleConnected) < vehicleDetectDuration
+
+	// request vehicle api refresh while waiting to identify
+	if res {
+		select {
+		case <-lp.vehicleConnectedTicker.C:
+			lp.log.DEBUG.Println("vehicle api refresh")
+			provider.ResetCached()
+		default:
+		}
+	}
+
+	return res
 }
 
-// findActiveVehicle validates if the active vehicle is still connected to the loadpoint
-func (lp *LoadPoint) findActiveVehicle() {
-	// find vehicles by id
+// 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.vehicleIdError = err
 			lp.log.ERROR.Println("charger vehicle id:", err)
-			return
+			return nil
 		}
 
 		if id != "" {
@@ -628,14 +667,13 @@ func (lp *LoadPoint) findActiveVehicle() {
 			// find exact match
 			for _, vehicle := range lp.vehicles {
 				if vid, err := vehicle.Identify(); err == nil && vid == id {
-					lp.setActiveVehicle(vehicle)
-					return
+					return vehicle
 				}
 			}
 
 			// find placeholder match
 			for _, vehicle := range lp.vehicles {
-				if vid, err := vehicle.Identify(); err == nil {
+				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)
@@ -643,64 +681,59 @@ func (lp *LoadPoint) findActiveVehicle() {
 					}
 
 					if re.MatchString(id) {
-						lp.setActiveVehicle(vehicle)
-						return
+						return vehicle
 					}
 				}
 			}
-
-			// TODO implement removing vehicle
-			// lp.setActiveVehicle(nil)
 		}
 	}
 
-	if len(lp.vehicles) <= 1 {
-		return
-	}
-
-	// find vehicles by charge state - current vehicle
-	if vs, ok := lp.vehicle.(api.ChargeState); ok {
-		status, err := vs.Status()
-
-		if err != nil {
-			lp.vehicleIdError = err
-			lp.log.ERROR.Println("vehicle charge state:", err)
-			return
-		}
-
-		lp.log.DEBUG.Printf("vehicle status: %s (%s)", status, lp.vehicle.Title())
-
-		// vehicle is plugged or charging, so it should be the right one
-		if status == api.StatusB || status == api.StatusC {
-			lp.vehicleIdError = nil
-			return
-		}
-	}
+	return nil
+}
 
-	// find vehicles by charge state
+// find active vehicle by charge state
+func (lp *LoadPoint) findActiveVehicleByStatus() api.Vehicle {
 	for _, vehicle := range lp.vehicles {
-		if vehicle == lp.vehicle {
-			continue
-		}
-
 		if vs, ok := vehicle.(api.ChargeState); ok {
 			status, err := vs.Status()
 
 			if err != nil {
-				lp.vehicleIdError = err
-				lp.log.ERROR.Println("vehicle charge state:", err)
-				return
+				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 {
-				lp.setActiveVehicle(vehicle)
-				return
+				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 {
+		return
+	}
+
+	if vehicle := lp.findActiveVehicleByID(); vehicle != nil {
+		lp.setActiveVehicle(vehicle)
+		return
+	}
+
+	if vehicle := lp.findActiveVehicleByStatus(); vehicle != nil {
+		lp.setActiveVehicle(vehicle)
+		return
+	}
+
+	// remove previous vehicle if status was not confirmed
+	if _, ok := lp.vehicle.(api.ChargeState); ok {
+		lp.setActiveVehicle(nil)
+	}
 }
 
 // updateChargerStatus updates charger status and detects car connected/disconnected events
@@ -762,7 +795,7 @@ func (lp *LoadPoint) effectiveCurrent() float64 {
 
 // pvDisableTimer puts the pv enable/disable timer into elapsed state
 func (lp *LoadPoint) pvDisableTimer() {
-	lp.pvTimer = time.Now().Add(-lp.Disable.Delay)
+	lp.pvTimer = lp.clock.Now().Add(-lp.Disable.Delay)
 }
 
 // pvMaxCurrent calculates the maximum target current for PV mode
diff --git a/mock/mock_api.go b/mock/mock_api.go
index dd1455ef..39b9cffe 100644
--- a/mock/mock_api.go
+++ b/mock/mock_api.go
@@ -1,5 +1,5 @@
 // Code generated by MockGen. DO NOT EDIT.
-// Source: github.com/andig/evcc/api (interfaces: Charger,Meter,MeterEnergy,Vehicle,ChargeRater)
+// Source: github.com/andig/evcc/api (interfaces: Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater)
 
 // Package mock is a generated GoMock package.
 package mock
@@ -92,6 +92,82 @@ func (mr *MockChargerMockRecorder) Status() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockCharger)(nil).Status))
 }
 
+// MockChargeState is a mock of ChargeState interface.
+type MockChargeState struct {
+	ctrl     *gomock.Controller
+	recorder *MockChargeStateMockRecorder
+}
+
+// MockChargeStateMockRecorder is the mock recorder for MockChargeState.
+type MockChargeStateMockRecorder struct {
+	mock *MockChargeState
+}
+
+// NewMockChargeState creates a new mock instance.
+func NewMockChargeState(ctrl *gomock.Controller) *MockChargeState {
+	mock := &MockChargeState{ctrl: ctrl}
+	mock.recorder = &MockChargeStateMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockChargeState) EXPECT() *MockChargeStateMockRecorder {
+	return m.recorder
+}
+
+// Status mocks base method.
+func (m *MockChargeState) Status() (api.ChargeStatus, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Status")
+	ret0, _ := ret[0].(api.ChargeStatus)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Status indicates an expected call of Status.
+func (mr *MockChargeStateMockRecorder) Status() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Status", reflect.TypeOf((*MockChargeState)(nil).Status))
+}
+
+// MockIdentifier is a mock of Identifier interface.
+type MockIdentifier struct {
+	ctrl     *gomock.Controller
+	recorder *MockIdentifierMockRecorder
+}
+
+// MockIdentifierMockRecorder is the mock recorder for MockIdentifier.
+type MockIdentifierMockRecorder struct {
+	mock *MockIdentifier
+}
+
+// NewMockIdentifier creates a new mock instance.
+func NewMockIdentifier(ctrl *gomock.Controller) *MockIdentifier {
+	mock := &MockIdentifier{ctrl: ctrl}
+	mock.recorder = &MockIdentifierMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockIdentifier) EXPECT() *MockIdentifierMockRecorder {
+	return m.recorder
+}
+
+// Identify mocks base method.
+func (m *MockIdentifier) Identify() (string, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Identify")
+	ret0, _ := ret[0].(string)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Identify indicates an expected call of Identify.
+func (mr *MockIdentifierMockRecorder) Identify() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Identify", reflect.TypeOf((*MockIdentifier)(nil).Identify))
+}
+
 // MockMeter is a mock of Meter interface.
 type MockMeter struct {
 	ctrl     *gomock.Controller
diff --git a/util/request/functions.go b/util/request/functions.go
index 2ac67501..84cc1f1a 100644
--- a/util/request/functions.go
+++ b/util/request/functions.go
@@ -7,18 +7,21 @@ import (
 )
 
 var (
+	FormContent = "application/x-www-form-urlencoded"
+	JSONContent = "application/json"
+
 	// URLEncoding specifies application/x-www-form-urlencoded
-	URLEncoding = map[string]string{"Content-Type": "application/x-www-form-urlencoded"}
+	URLEncoding = map[string]string{"Content-Type": FormContent}
 
 	// JSONEncoding specifies application/json
 	JSONEncoding = map[string]string{
-		"Content-Type": "application/json",
-		"Accept":       "application/json",
+		"Content-Type": JSONContent,
+		"Accept":       JSONContent,
 	}
 
 	// AcceptJSON accepting application/json
 	AcceptJSON = map[string]string{
-		"Accept": "application/json",
+		"Accept": JSONContent,
 	}
 )
 
diff --git a/vehicle/fiat/identity.go b/vehicle/fiat/identity.go
index dfc17b1f..50b4b19a 100644
--- a/vehicle/fiat/identity.go
+++ b/vehicle/fiat/identity.go
@@ -57,7 +57,11 @@ func (v *Identity) Login() error {
 	req, err := request.New(http.MethodGet, uri, nil, headers)
 	if err == nil {
 		req.URL.RawQuery = data.Encode()
-		_, err = v.Do(req)
+
+		var resp *http.Response
+		if resp, err = v.Do(req); err == nil {
+			resp.Body.Close()
+		}
 	}
 
 	var res struct {
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/nissan/api.go b/vehicle/nissan/api.go
index fc05a7c4..f4b13b1c 100644
--- a/vehicle/nissan/api.go
+++ b/vehicle/nissan/api.go
@@ -51,7 +51,7 @@ func (v *API) Vehicles() ([]string, error) {
 
 	var res Vehicles
 	if err == nil {
-		uri := fmt.Sprintf("%s/v2/users/%s/cars", UserBaseURL, user.UserID)
+		uri := fmt.Sprintf("%s/v4/users/%s/cars", UserBaseURL, user.UserID)
 		err = v.GetJSON(uri, &res)
 	}
 
diff --git a/vehicle/nissan/identity.go b/vehicle/nissan/identity.go
index 6682bb0d..0b3b8691 100644
--- a/vehicle/nissan/identity.go
+++ b/vehicle/nissan/identity.go
@@ -1,8 +1,6 @@
 package nissan
 
 import (
-	"bytes"
-	"encoding/json"
 	"fmt"
 	"net/http"
 	"net/url"
@@ -11,6 +9,7 @@ import (
 	"github.com/andig/evcc/util"
 	"github.com/andig/evcc/util/oauth"
 	"github.com/andig/evcc/util/request"
+	"github.com/avast/retry-go"
 	"golang.org/x/oauth2"
 )
 
@@ -55,23 +54,23 @@ func (v *Identity) Login(user, password string) error {
 			}
 		}
 
-		var body []byte
-		body, err = json.Marshal(res)
-
-		if err == nil {
-			req, err = request.New(http.MethodPost, uri, bytes.NewReader(body), map[string]string{
-				"Content-type":       "application/json",
-				"Accept":             "application/json",
+		// https://github.com/Tobiaswk/dartnissanconnect/commit/7d28dd5461aaed3e46b5be0c9fd58887e1e0cd0b
+		err = retry.Do(func() error {
+			req, err = request.New(http.MethodPost, uri, request.MarshalJSON(res), map[string]string{
 				"Accept-Api-Version": APIVersion,
 				"X-Username":         "anonymous",
 				"X-Password":         "anonymous",
+				"Content-type":       "application/json",
+				"Accept":             "application/json",
 			})
-		}
 
-		if err == nil {
-			err = v.DoJSON(req, &nToken)
-			realm = strings.Trim(nToken.Realm, "/")
-		}
+			if err == nil {
+				err = v.DoJSON(req, &nToken)
+				realm = strings.Trim(nToken.Realm, "/")
+			}
+
+			return err
+		}, retry.Attempts(10), retry.LastErrorOnly(true))
 	}
 
 	if err == nil {
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/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/loadpoint_test.go b/core/loadpoint_test.go
index 855bfc80..afbb24a5 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -747,3 +747,142 @@ 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)
+
+	type testcase struct {
+		string
+		id, i1, i2 string
+		res        api.Vehicle
+		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)
+			v2.EXPECT().Identify().Return(tc.i2, nil)
+		}},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+
+		lp := &LoadPoint{
+			log:      util.NewLogger("foo"),
+			charger:  c,
+			vehicles: []api.Vehicle{v1, v2},
+		}
+
+		if tc.prepare != nil {
+			tc.prepare(tc)
+		}
+
+		if res := lp.findActiveVehicleByID(); tc.res != res {
+			t.Errorf("expected %v, got %v", tc.res, res)
+		}
+	}
+}
+
+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)
+		}
+	}
+}
diff --git a/provider/cache_test.go b/provider/cache_test.go
index c0683eb8..d12fb79e 100644
--- a/provider/cache_test.go
+++ b/provider/cache_test.go
@@ -28,8 +28,8 @@ func TestCachedGetter(t *testing.T) {
 
 	duration := time.Second
 	c := NewCached(g, duration)
-	clck := clock.NewMock()
-	c.clock = clck
+	clock := clock.NewMock()
+	c.clock = clock
 	getter := c.FloatGetter()
 
 	expect := func(s struct {
@@ -45,9 +45,36 @@ func TestCachedGetter(t *testing.T) {
 	expect(cases[0])
 	expect(cases[0])
 
-	clck.Add(2 * duration)
+	clock.Add(2 * duration)
 	expect(cases[1])
 
-	clck.Add(2 * duration)
+	clock.Add(2 * duration)
 	expect(cases[2])
 }
+
+func TestCacheReset(t *testing.T) {
+	var i int64
+	g := func() (int64, error) {
+		i++
+		return i, nil
+	}
+
+	c := NewCached(g, 10*time.Minute)
+	clock := clock.NewMock()
+	c.clock = clock
+
+	test := func(exp int64) {
+		v, _ := c.IntGetter()()
+		if exp != v {
+			t.Errorf("expected %d, got %d", exp, v)
+		}
+	}
+
+	test(1)
+	test(1)
+	ResetCached()
+	test(2)
+	test(2)
+	clock.Add(10*time.Minute + 1)
+	test(3)
+}