Repository: evcc-io/evcc
Pull request #1415 (2021-08-22 20:09:33)

Improved SoC handling provided by chargers
- If the charger can not provide SoC with the current car connection, it has to return `api.ErrNotAvailable`
- If the charger does not know yet if it can provide SoC with the current car connection, it has to return `api.ErrMustRetry`
- If the charger does or could provide an SoC, the vehicle API will never be called, even in case of an error

DerAndereAndi: Das sollte die Implementierung von https://github.com/evcc-io/evcc/pull/1408#issuecomment-903280791 sein
andig: Ich glaub ein Testcase wäre gut der auch den Fallback aufs Fahrzeug zeigt
DerAndereAndi: Yep. Hab ich noch vor. Morgen dann :) 
DerAndereAndi: Morgen ist doch schon heute...
andig: /rebase
DerAndereAndi: rebase done
andig: /rebase

Patch:
diff --git a/charger/eebus.go b/charger/eebus.go
index d8b1f166..8d54eb32 100644
--- a/charger/eebus.go
+++ b/charger/eebus.go
@@ -72,7 +72,7 @@ var eebusDevice spine.Device
 var once sync.Once
 
 func (c *EEBus) onConnect(ski string, conn ship.Conn) error {
-	c.log.TRACE.Println("onCconnect invoked on ski ", ski)
+	c.log.TRACE.Println("!! onCconnect invoked on ski ", ski)
 
 	once.Do(func() {
 		eebusDevice = app.HEMS(server.EEBusInstance.DeviceInfo())
@@ -89,7 +89,7 @@ func (c *EEBus) onConnect(ski string, conn ship.Conn) error {
 }
 
 func (c *EEBus) onDisconnect(ski string) {
-	c.log.TRACE.Println("onDisconnect invoked on ski ", ski)
+	c.log.TRACE.Println("!! onDisconnect invoked on ski ", ski)
 
 	c.connected = false
 	c.setDefaultValues()
@@ -135,19 +135,19 @@ func (c *EEBus) showCurrentChargingSetup() {
 	if prevComStandard != data.EVData.CommunicationStandard {
 		c.communicationStandard = data.EVData.CommunicationStandard
 		timestamp := time.Now()
-		c.log.WARN.Println(timestamp.Format("2006-01-02 15:04:05"), " ev-charger-communication changed from ", prevComStandard, " to ", data.EVData.CommunicationStandard)
+		c.log.WARN.Println("!! ", timestamp.Format("2006-01-02 15:04:05"), " ev-charger-communication changed from ", prevComStandard, " to ", data.EVData.CommunicationStandard)
 	}
 
 	if prevSoCSupport != data.EVData.UCSoCAvailable {
 		c.socSupportAvailable = data.EVData.UCSoCAvailable
 		timestamp := time.Now()
-		c.log.WARN.Println(timestamp.Format("2006-01-02 15:04:05"), " ev-charger-soc support changed from ", prevSoCSupport, " to ", data.EVData.UCSoCAvailable)
+		c.log.WARN.Println("!! ", timestamp.Format("2006-01-02 15:04:05"), " ev-charger-soc support changed from ", prevSoCSupport, " to ", data.EVData.UCSoCAvailable)
 	}
 
 	if prevSelfConsumptionSupport != data.EVData.UCSelfConsumptionAvailable {
 		c.selfConsumptionSupportAvailable = data.EVData.UCSelfConsumptionAvailable
 		timestamp := time.Now()
-		c.log.WARN.Println(timestamp.Format("2006-01-02 15:04:05"), " ev-charger-self-consumption-support support changed from ", prevSelfConsumptionSupport, " to ", data.EVData.UCSelfConsumptionAvailable)
+		c.log.WARN.Println("!! ", timestamp.Format("2006-01-02 15:04:05"), " ev-charger-self-consumption-support support changed from ", prevSelfConsumptionSupport, " to ", data.EVData.UCSelfConsumptionAvailable)
 	}
 
 }
@@ -191,14 +191,14 @@ func (c *EEBus) dataUpdateHandler(dataType communication.EVDataElementUpdateType
 func (c *EEBus) Status() (api.ChargeStatus, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("status: no eebus data available yet")
+		c.log.TRACE.Printf("!! status: no eebus data available yet")
 		return api.StatusNone, err
 	}
 
 	currentState := data.EVData.ChargeState
 
 	if !c.connected {
-		c.log.TRACE.Printf("status: charger reported as disconnected")
+		c.log.TRACE.Printf("!! status: charger reported as disconnected")
 		return api.StatusNone, fmt.Errorf("charger reported as disconnected")
 	}
 
@@ -252,12 +252,12 @@ func (c *EEBus) Enabled() (bool, error) {
 func (c *EEBus) Enable(enable bool) error {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("enable: no eebus data available yet")
+		c.log.TRACE.Printf("!! enable: no eebus data available yet")
 		return err
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged {
-		c.log.TRACE.Printf("currents: ev reported as unplugged")
+		c.log.TRACE.Printf("!! currents: ev reported as unplugged")
 		// if the ev is unplugged, we do not need to disable charging by setting a current of 0 as it already is
 		if !enable {
 			return nil
@@ -269,7 +269,7 @@ func (c *EEBus) Enable(enable bool) error {
 	// if we disable charging with a potential but not yet known communication standard ISO15118
 	// this would set allowed A value to be 0. And this would trigger ISO connections to switch to IEC!
 	if data.EVData.CommunicationStandard == communication.EVCommunicationStandardEnumTypeUnknown {
-		c.log.TRACE.Printf("enable: cannot enable or disable as communication standard is not yet known")
+		c.log.TRACE.Printf("!! enable: cannot enable or disable as communication standard is not yet known")
 		return api.ErrMustRetry
 	}
 
@@ -356,26 +356,26 @@ var _ api.ChargerEx = (*EEBus)(nil)
 func (c *EEBus) MaxCurrentMillis(current float64) error {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("currents: no eebus data available yet")
+		c.log.TRACE.Printf("!! currents: no eebus data available yet")
 		return err
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged {
-		c.log.TRACE.Printf("currents: ev reported as unplugged")
+		c.log.TRACE.Printf("!! currents: ev reported as unplugged")
 		return errors.New("can't set new current as ev is unplugged")
 	}
 
 	if data.EVData.LimitsL1.Min == 0 {
-		c.log.TRACE.Println("we did not yet receive min and max currents to validate the call of MaxCurrent, use it as is")
+		c.log.TRACE.Println("!! we did not yet receive min and max currents to validate the call of MaxCurrent, use it as is")
 	}
 
 	if current < data.EVData.LimitsL1.Min {
-		c.log.TRACE.Printf("current value %f is lower than the allowed minimum value %f", current, data.EVData.LimitsL1.Min)
+		c.log.TRACE.Printf("!! current value %f is lower than the allowed minimum value %f", current, data.EVData.LimitsL1.Min)
 		current = data.EVData.LimitsL1.Min
 	}
 
 	if current > data.EVData.LimitsL1.Max {
-		c.log.TRACE.Printf("current value %f is higher than the allowed maximum value %f", current, data.EVData.LimitsL1.Max)
+		c.log.TRACE.Printf("!! current value %f is higher than the allowed maximum value %f", current, data.EVData.LimitsL1.Max)
 		current = data.EVData.LimitsL1.Max
 	}
 
@@ -383,7 +383,7 @@ func (c *EEBus) MaxCurrentMillis(current float64) error {
 
 	// TODO error handling
 
-	c.log.TRACE.Printf("currents: returning %f", current)
+	c.log.TRACE.Printf("!! currents: returning %f", current)
 
 	currents := []float64{current, current, current}
 	return c.writeCurrentLimitData(currents)
@@ -395,17 +395,17 @@ var _ api.Meter = (*EEBus)(nil)
 func (c *EEBus) CurrentPower() (float64, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("current power: no eebus data available yet")
+		c.log.TRACE.Printf("!! current power: no eebus data available yet")
 		return 0, err
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged {
-		c.log.TRACE.Printf("current power: ev reported as unplugged")
+		c.log.TRACE.Printf("!! current power: ev reported as unplugged")
 		return 0, nil
 	}
 
 	power := data.EVData.Measurements.PowerL1 + data.EVData.Measurements.PowerL2 + data.EVData.Measurements.PowerL3
-	c.log.TRACE.Printf("current power: returning %f", power)
+	c.log.TRACE.Printf("!! current power: returning %f", power)
 
 	return power, nil
 }
@@ -416,17 +416,17 @@ var _ api.ChargeRater = (*EEBus)(nil)
 func (c *EEBus) ChargedEnergy() (float64, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("charged energy: no eebus data available yet")
+		c.log.TRACE.Printf("!! charged energy: no eebus data available yet")
 		return 0, err
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged {
-		c.log.TRACE.Printf("charged energy: ev reported as unplugged")
+		c.log.TRACE.Printf("!! charged energy: ev reported as unplugged")
 		return 0, nil
 	}
 
 	energy := data.EVData.Measurements.ChargedEnergy / 1000
-	c.log.TRACE.Printf("charged energy: returning %f", energy)
+	c.log.TRACE.Printf("!! charged energy: returning %f", energy)
 
 	return energy, nil
 }
@@ -450,16 +450,16 @@ var _ api.MeterCurrent = (*EEBus)(nil)
 func (c *EEBus) Currents() (float64, float64, float64, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("currents: no eebus data available yet")
+		c.log.TRACE.Printf("!! currents: no eebus data available yet")
 		return 0, 0, 0, err
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged {
-		c.log.TRACE.Printf("currents: ev reported as unplugged")
+		c.log.TRACE.Printf("!! currents: ev reported as unplugged")
 		return 0, 0, 0, nil
 	}
 
-	c.log.TRACE.Printf("currents: returning %f, %f, %f, ", data.EVData.Measurements.CurrentL1, data.EVData.Measurements.CurrentL2, data.EVData.Measurements.CurrentL3)
+	c.log.TRACE.Printf("!! currents: returning %f, %f, %f, ", data.EVData.Measurements.CurrentL1, data.EVData.Measurements.CurrentL2, data.EVData.Measurements.CurrentL3)
 
 	return data.EVData.Measurements.CurrentL1, data.EVData.Measurements.CurrentL2, data.EVData.Measurements.CurrentL3, nil
 }
@@ -470,31 +470,31 @@ var _ api.Identifier = (*EEBus)(nil)
 func (c *EEBus) Identify() (string, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("identify: no eebus data available yet")
+		c.log.TRACE.Printf("!! identify: no eebus data available yet")
 		return "", err
 	}
 
 	if !c.connected {
-		c.log.TRACE.Printf("identify: charger reported as disconnected")
+		c.log.TRACE.Printf("!! identify: charger reported as disconnected")
 		return "", nil
 	}
 
 	if data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnplugged || data.EVData.ChargeState == communication.EVChargeStateEnumTypeUnknown {
-		c.log.TRACE.Printf("identify: ev reported as unplugged or unknown")
+		c.log.TRACE.Printf("!! identify: ev reported as unplugged or unknown")
 		return "", nil
 	}
 
 	if len(data.EVData.Identification) > 0 {
-		c.log.TRACE.Printf("identify: returning %s", data.EVData.Identification)
+		c.log.TRACE.Printf("!! identify: returning %s", data.EVData.Identification)
 		return data.EVData.Identification, nil
 	}
 
 	if data.EVData.CommunicationStandard == communication.EVCommunicationStandardEnumTypeIEC61851 {
-		c.log.TRACE.Printf("identify: ev communication is IEC61851 which does not support any identification")
+		c.log.TRACE.Printf("!! identify: ev communication is IEC61851 which does not support any identification")
 		return "", nil
 	}
 
-	c.log.TRACE.Printf("identify: returning nothing")
+	c.log.TRACE.Printf("!! identify: returning nothing")
 	return "", api.ErrMustRetry
 }
 
@@ -504,16 +504,16 @@ var _ api.Battery = (*EEBus)(nil)
 func (c *EEBus) SoC() (float64, error) {
 	data, err := c.cc.GetData()
 	if err != nil {
-		c.log.TRACE.Printf("soc: no eebus data available yet")
-		return 0, api.ErrNotAvailable
+		c.log.TRACE.Printf("!! soc: no eebus data available yet")
+		return 0, api.ErrMustRetry
 	}
 
 	if !data.EVData.UCSoCAvailable || !data.EVData.SoCDataAvailable {
-		c.log.TRACE.Printf("soc: feature not available")
+		c.log.TRACE.Printf("!! soc: feature not available")
 		return 0, api.ErrNotAvailable
 	}
 
-	c.log.TRACE.Printf("soc: returning %f", data.EVData.Measurements.SoC)
+	c.log.TRACE.Printf("!! soc: returning %f", data.EVData.Measurements.SoC)
 	return data.EVData.Measurements.SoC, nil
 }
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index e2664f65..5bd8a090 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -797,11 +797,11 @@ func (lp *LoadPoint) updateChargerStatus() error {
 }
 
 // effectiveCurrent returns the currently effective charging current
-// it does not take measured currents into account
 func (lp *LoadPoint) effectiveCurrent() float64 {
-	// use measured L1 current
+	// adjust actual current for vehicles like Zoe where it remains below target
 	if lp.chargeCurrents != nil {
-		return lp.chargeCurrents[0]
+		cur := lp.chargeCurrents[0]
+		return math.Min(cur+2.0, lp.chargeCurrent)
 	}
 
 	if lp.GetStatus() != api.StatusC {
diff --git a/core/soc/estimator.go b/core/soc/estimator.go
index ec022e07..31c1023c 100644
--- a/core/soc/estimator.go
+++ b/core/soc/estimator.go
@@ -98,7 +98,19 @@ func (s *Estimator) SoC(chargedEnergy float64) (float64, error) {
 	if charger, ok := s.charger.(api.Battery); ok {
 		f, err := charger.SoC()
 
-		if err == nil {
+		// if the charger does or could provide SoC, we always use it instead of using the vehicle API
+		if err == nil || !errors.Is(err, api.ErrNotAvailable) {
+			if err != nil {
+				// never received a soc value
+				if s.prevSoC == 0 {
+					return 0, err
+				}
+
+				// recover from temporary api errors
+				f = s.prevSoC
+				s.log.WARN.Printf("vehicle soc (charger): %v (ignored by estimator)", err)
+			}
+
 			s.vehicleSoc = f
 			fetchedSoC = &f
 		}
@@ -107,6 +119,7 @@ func (s *Estimator) SoC(chargedEnergy float64) (float64, error) {
 	if fetchedSoC == nil {
 		f, err := s.vehicle.SoC()
 		if err != nil {
+			// required for online APIs with refreshkey
 			if errors.Is(err, api.ErrMustRetry) {
 				return 0, err
 			}
diff --git a/meter/sma.go b/meter/sma.go
index 2fad4870..2a43ce87 100644
--- a/meter/sma.go
+++ b/meter/sma.go
@@ -17,7 +17,6 @@ import (
 type SMA struct {
 	log    *util.Logger
 	uri    string
-	iface  string
 	scale  float64
 	device *sma.Device
 }
@@ -51,7 +50,6 @@ func NewSMA(uri, password, iface string, serial uint32, scale float64) (api.Mete
 	sm := &SMA{
 		log:   util.NewLogger("sma"),
 		uri:   uri,
-		iface: iface,
 		scale: scale,
 	}
 
diff --git a/vehicle/vehicle.go b/vehicle/vehicle.go
index 02bd2eb1..f59750f6 100644
--- a/vehicle/vehicle.go
+++ b/vehicle/vehicle.go
@@ -30,7 +30,7 @@ func (v *embed) Identify() (string, error) {
 	return v.Identifier_, nil
 }
 
-//go:generate go run ../cmd/tools/decorate.go -f decorateVehicle -b api.Vehicle -t "api.ChargeState,Status,func() (api.ChargeStatus, error)" -t "api.VehicleRange,Range,func() (int64, error)"
+//go:generate go run ../cmd/tools/decorate.go -f decorateVehicle -b api.Vehicle -t "api.ChargeState,Status,func() (api.ChargeStatus, error)" -t "api.VehicleRange,Range,func() (int64, error)" -t "api.VehicleOdometer,Odometer,func() (float64, error)"
 
 // Vehicle is an api.Vehicle implementation with configurable getters and setters.
 type Vehicle struct {
@@ -47,11 +47,12 @@ func init() {
 // NewConfigurableFromConfig creates a new Vehicle
 func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	cc := struct {
-		embed  `mapstructure:",squash"`
-		Charge provider.Config
-		Status *provider.Config
-		Range  *provider.Config
-		Cache  time.Duration
+		embed    `mapstructure:",squash"`
+		Charge   provider.Config
+		Status   *provider.Config
+		Range    *provider.Config
+		Odometer *provider.Config
+		Cache    time.Duration
 	}{
 		Cache: interval,
 	}
@@ -94,7 +95,17 @@ func NewConfigurableFromConfig(other map[string]interface{}) (api.Vehicle, error
 		rng = rangeG
 	}
 
-	res := decorateVehicle(v, status, rng)
+	// decorate vehicle with Range
+	var odo func() (float64, error)
+	if cc.Odometer != nil {
+		odoG, err := provider.NewFloatGetterFromConfig(*cc.Odometer)
+		if err != nil {
+			return nil, fmt.Errorf("odometer: %w", err)
+		}
+		odo = odoG
+	}
+
+	res := decorateVehicle(v, status, rng, odo)
 
 	return res, nil
 }
@@ -104,7 +115,7 @@ func (v *Vehicle) SoC() (float64, error) {
 	return v.chargeG()
 }
 
-// SoC implements the api.Vehicle interface
+// SoC implements the api.ChargeState interface
 func (v *Vehicle) status() (api.ChargeStatus, error) {
 	status := api.StatusF
 
diff --git a/vehicle/vehicle_decorators.go b/vehicle/vehicle_decorators.go
index c33803f9..20f56ee8 100644
--- a/vehicle/vehicle_decorators.go
+++ b/vehicle/vehicle_decorators.go
@@ -6,12 +6,12 @@ import (
 	"github.com/evcc-io/evcc/api"
 )
 
-func decorateVehicle(base api.Vehicle, chargeState func() (api.ChargeStatus, error), vehicleRange func() (int64, error)) api.Vehicle {
+func decorateVehicle(base api.Vehicle, chargeState func() (api.ChargeStatus, error), vehicleRange func() (int64, error), vehicleOdometer func() (float64, error)) api.Vehicle {
 	switch {
-	case chargeState == nil && vehicleRange == nil:
+	case chargeState == nil && vehicleOdometer == nil && vehicleRange == nil:
 		return base
 
-	case chargeState != nil && vehicleRange == nil:
+	case chargeState != nil && vehicleOdometer == nil && vehicleRange == nil:
 		return &struct {
 			api.Vehicle
 			api.ChargeState
@@ -22,7 +22,7 @@ func decorateVehicle(base api.Vehicle, chargeState func() (api.ChargeStatus, err
 			},
 		}
 
-	case chargeState == nil && vehicleRange != nil:
+	case chargeState == nil && vehicleOdometer == nil && vehicleRange != nil:
 		return &struct {
 			api.Vehicle
 			api.VehicleRange
@@ -33,7 +33,7 @@ func decorateVehicle(base api.Vehicle, chargeState func() (api.ChargeStatus, err
 			},
 		}
 
-	case chargeState != nil && vehicleRange != nil:
+	case chargeState != nil && vehicleOdometer == nil && vehicleRange != nil:
 		return &struct {
 			api.Vehicle
 			api.ChargeState
@@ -47,6 +47,66 @@ func decorateVehicle(base api.Vehicle, chargeState func() (api.ChargeStatus, err
 				vehicleRange: vehicleRange,
 			},
 		}
+
+	case chargeState == nil && vehicleOdometer != nil && vehicleRange == nil:
+		return &struct {
+			api.Vehicle
+			api.VehicleOdometer
+		}{
+			Vehicle: base,
+			VehicleOdometer: &decorateVehicleVehicleOdometerImpl{
+				vehicleOdometer: vehicleOdometer,
+			},
+		}
+
+	case chargeState != nil && vehicleOdometer != nil && vehicleRange == nil:
+		return &struct {
+			api.Vehicle
+			api.ChargeState
+			api.VehicleOdometer
+		}{
+			Vehicle: base,
+			ChargeState: &decorateVehicleChargeStateImpl{
+				chargeState: chargeState,
+			},
+			VehicleOdometer: &decorateVehicleVehicleOdometerImpl{
+				vehicleOdometer: vehicleOdometer,
+			},
+		}
+
+	case chargeState == nil && vehicleOdometer != nil && vehicleRange != nil:
+		return &struct {
+			api.Vehicle
+			api.VehicleOdometer
+			api.VehicleRange
+		}{
+			Vehicle: base,
+			VehicleOdometer: &decorateVehicleVehicleOdometerImpl{
+				vehicleOdometer: vehicleOdometer,
+			},
+			VehicleRange: &decorateVehicleVehicleRangeImpl{
+				vehicleRange: vehicleRange,
+			},
+		}
+
+	case chargeState != nil && vehicleOdometer != nil && vehicleRange != nil:
+		return &struct {
+			api.Vehicle
+			api.ChargeState
+			api.VehicleOdometer
+			api.VehicleRange
+		}{
+			Vehicle: base,
+			ChargeState: &decorateVehicleChargeStateImpl{
+				chargeState: chargeState,
+			},
+			VehicleOdometer: &decorateVehicleVehicleOdometerImpl{
+				vehicleOdometer: vehicleOdometer,
+			},
+			VehicleRange: &decorateVehicleVehicleRangeImpl{
+				vehicleRange: vehicleRange,
+			},
+		}
 	}
 
 	return nil
@@ -60,6 +120,14 @@ func (impl *decorateVehicleChargeStateImpl) Status() (api.ChargeStatus, error) {
 	return impl.chargeState()
 }
 
+type decorateVehicleVehicleOdometerImpl struct {
+	vehicleOdometer func() (float64, error)
+}
+
+func (impl *decorateVehicleVehicleOdometerImpl) Odometer() (float64, error) {
+	return impl.vehicleOdometer()
+}
+
 type decorateVehicleVehicleRangeImpl struct {
 	vehicleRange func() (int64, error)
 }


Test patch:
diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go
index 46d6cd05..593a606e 100644
--- a/core/soc/estimator_test.go
+++ b/core/soc/estimator_test.go
@@ -1,6 +1,7 @@
 package soc
 
 import (
+	"errors"
 	"testing"
 	"time"
 
@@ -119,3 +120,103 @@ func TestSoCEstimation(t *testing.T) {
 		}
 	}
 }
+
+func TestSoCFromChargerAndVehicleWithErrors(t *testing.T) {
+	type chargerStruct struct {
+		*mock.MockCharger
+		*mock.MockBattery
+	}
+
+	ctrl := gomock.NewController(t)
+	vehicle := mock.NewMockVehicle(ctrl)
+	charger := &chargerStruct{mock.NewMockCharger(ctrl), mock.NewMockBattery(ctrl)}
+
+	// 9 kWh user battery capacity is converted to initial value of 10 kWh virtual capacity
+	var capacity int64 = 9
+	vehicle.EXPECT().Capacity().Return(capacity)
+
+	ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, true)
+	ce.vehicleSoc = 20.0
+
+	tc := []struct {
+		chargedEnergy   float64
+		vehicleSoC      float64
+		estimatedSoC    float64
+		virtualCapacity float64
+		expectVehicle   bool
+		chargerError    error
+		vehicleError    error
+	}{
+		// start with SoC from charger and errors
+		{0, 0.0, 0.0, 10000, false, errors.New("some error"), nil},
+		{10, 0.0, 20.0, 10000, false, api.ErrMustRetry, nil},
+		{10, 20.0, 20.0, 10000, false, nil, nil},
+		{0, 20.0, 20.0, 10000, false, nil, nil},
+		{123, 20.0, 21.23, 10000, false, nil, nil},
+		{123, 0.0, 21.23, 10000, false, errors.New("another error"), nil},
+		{1000, 20.0, 30.0, 10000, false, nil, nil},
+		{1100, 31.0, 31.0, 10000, false, nil, nil},
+		{1200, 32.0, 32.0, 10000, false, nil, nil},
+		{1900, 39.0, 39.0, 10000, false, nil, nil},
+		{2000, 40.0, 40.0, 10000, false, nil, nil},
+		{4000, 50.0, 50.0, 20000, false, nil, nil}, // 2kWh add 10% -> 20kWh battery
+		{6000, 60.0, 60.0, 20000, false, nil, nil}, // 2kWh add 10% -> 20kWh battery
+		{6500, 65.0, 65.0, 10000, false, nil, nil},
+		{7000, 65.0, 70.0, 10000, false, nil, nil},
+		// move to SoC from vehicle
+		{7000, 0.0, 70.0, 10000, true, api.ErrNotAvailable, errors.New("some error")},
+		{7100, 0.0, 71.0, 10000, true, api.ErrNotAvailable, api.ErrMustRetry},
+		{7100, 71.0, 71.0, 10000, true, api.ErrNotAvailable, nil},
+		{7300, 72.0, 72.0, 10000, true, api.ErrNotAvailable, nil},
+		{7400, 0.0, 73.0, 10000, true, api.ErrNotAvailable, errors.New("another error")},
+		{7400, 73.0, 73.0, 10000, true, api.ErrNotAvailable, nil},
+		{7700, 75.0, 75.0, 10000, true, api.ErrNotAvailable, nil},
+		{8200, 80.0, 80.0, 10000, true, api.ErrNotAvailable, nil},
+		{0, 25.0, 25.0, 10000, true, api.ErrNotAvailable, nil},
+		{2500, 25.0, 50.0, 10000, true, api.ErrNotAvailable, nil},
+		{0, 50.0, 50.0, 10000, true, api.ErrNotAvailable, nil}, // -10000
+		{4990, 50.0, 99.9, 10000, true, api.ErrNotAvailable, nil},
+		{5000, 50.0, 100.0, 10000, true, api.ErrNotAvailable, nil},
+		// back to SoC from charger
+		{5001, 50.0, 100.0, 10000, false, nil, nil},
+		{0, 0.0, 0.0, 10000, false, nil, nil},
+		{1000, 0.0, 10.0, 10000, false, nil, nil},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+		charger.MockBattery.EXPECT().SoC().Return(tc.vehicleSoC, tc.chargerError)
+		if tc.expectVehicle {
+			vehicle.EXPECT().SoC().Return(tc.vehicleSoC, tc.vehicleError)
+		}
+
+		soc, err := ce.SoC(tc.chargedEnergy)
+		if err != nil {
+			if (!tc.expectVehicle && err != tc.chargerError) || (tc.expectVehicle && err != tc.vehicleError) {
+				t.Error(err)
+			} else {
+				continue
+			}
+		}
+
+		// validate soc estimate
+		if tc.estimatedSoC != soc {
+			t.Errorf("expected estimated soc: %g, got: %g", tc.estimatedSoC, soc)
+		}
+
+		// validate capacity estimate
+		if tc.virtualCapacity != ce.virtualCapacity {
+			t.Errorf("expected virtual capacity: %v, got: %v", tc.virtualCapacity, ce.virtualCapacity)
+		}
+
+		// validate duration estimate
+		chargePower := 1e3
+		targetSoC := 100
+		remainingHours := (float64(targetSoC) - soc) / 100 * tc.virtualCapacity / chargePower
+		remainingDuration := time.Duration(float64(time.Hour) * remainingHours).Round(time.Second)
+
+		if rm := ce.RemainingChargeDuration(chargePower, targetSoC); rm != remainingDuration {
+			t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm)
+		}
+	}
+}