diff --git a/api/api.go b/api/api.go index 9b8b25c0d..a8fd08d65 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,ChargeState,Identifier,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,Battery // ChargeMode are charge modes modeled after OpenWB type ChargeMode string diff --git a/core/loadpoint.go b/core/loadpoint.go index 84c505a65..389dfa67b 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -694,7 +694,7 @@ func (lp *LoadPoint) setActiveVehicle(vehicle api.Vehicle) { lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to) if lp.vehicle = vehicle; vehicle != nil { - lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate) + lp.socEstimator = soc.NewEstimator(lp.log, lp.charger, vehicle, lp.SoC.Estimate) lp.publish("hasVehicle", true) lp.publish("socTitle", lp.vehicle.Title()) @@ -975,13 +975,23 @@ func (lp *LoadPoint) socPollAllowed() bool { return lp.charging() || honourUpdateInterval && (remaining <= 0) || lp.connected() && lp.socUpdated.IsZero() } +// checks if the connected charger can provide SoC to the connected vehicle +func (lp *LoadPoint) socProvidedByCharger() bool { + if charger, ok := lp.charger.(api.Battery); ok { + if _, err := charger.SoC(); err == nil { + return true + } + } + return false +} + // publish state of charge, remaining charge duration and range func (lp *LoadPoint) publishSoCAndRange() { if lp.socEstimator == nil { return } - if lp.socPollAllowed() { + if lp.socPollAllowed() || lp.socProvidedByCharger() { lp.socUpdated = lp.clock.Now() f, err := lp.socEstimator.SoC(lp.chargedEnergy) diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go index bf54acc98..431c0ffcc 100644 --- a/core/loadpoint_test.go +++ b/core/loadpoint_test.go @@ -370,7 +370,7 @@ func TestDisableAndEnableAtTargetSoC(t *testing.T) { // wrap vehicle with estimator vehicle.EXPECT().Capacity().Return(int64(10)) - socEstimator := soc.NewEstimator(util.NewLogger("foo"), vehicle, false) + socEstimator := soc.NewEstimator(util.NewLogger("foo"), charger, vehicle, false) lp := &LoadPoint{ log: util.NewLogger("foo"), diff --git a/core/soc/estimator.go b/core/soc/estimator.go index 7f5d477d8..46a5cb3f9 100644 --- a/core/soc/estimator.go +++ b/core/soc/estimator.go @@ -15,6 +15,7 @@ const chargeEfficiency = 0.9 // assume charge 90% efficiency // Vehicle SoC can be estimated to provide more granularity type Estimator struct { log *util.Logger + charger api.Charger vehicle api.Vehicle estimate bool @@ -27,9 +28,10 @@ type Estimator struct { } // NewEstimator creates new estimator -func NewEstimator(log *util.Logger, vehicle api.Vehicle, estimate bool) *Estimator { +func NewEstimator(log *util.Logger, charger api.Charger, vehicle api.Vehicle, estimate bool) *Estimator { s := &Estimator{ log: log, + charger: charger, vehicle: vehicle, estimate: estimate, } @@ -91,23 +93,37 @@ func (s *Estimator) RemainingChargeEnergy(targetSoC int) float64 { // SoC replaces the api.Vehicle.SoC interface to take charged energy into account func (s *Estimator) SoC(chargedEnergy float64) (float64, error) { - f, err := s.vehicle.SoC() - if err != nil { - if errors.Is(err, api.ErrMustRetry) { - return 0, err + var fetchedSoC *float64 + + if charger, ok := s.charger.(api.Battery); ok { + f, err := charger.SoC() + + if err == nil { + s.socCharge = f + fetchedSoC = &f } - - s.log.WARN.Printf("updating soc failed: %v", err) - - // try to recover from temporary vehicle-api errors - if s.prevSoC == 0 { // never received a soc value - return s.socCharge, err - } - - f = s.prevSoC // recover last received soc } - s.socCharge = f + if fetchedSoC == nil { + f, err := s.vehicle.SoC() + if err != nil { + if errors.Is(err, api.ErrMustRetry) { + return 0, err + } + + s.log.WARN.Printf("updating soc failed: %v", err) + + // try to recover from temporary vehicle-api errors + if s.prevSoC == 0 { // never received a soc value + return s.socCharge, err + } + + f = s.prevSoC // recover last received soc + } + + fetchedSoC = &f + s.socCharge = f + } if s.estimate { socDelta := s.socCharge - s.prevSoC @@ -126,8 +142,8 @@ func (s *Estimator) SoC(chargedEnergy float64) (float64, error) { s.prevChargedEnergy = math.Max(chargedEnergy, 0) s.prevSoC = s.socCharge } else { - s.socCharge = math.Min(f+energyDelta/s.energyPerSocStep, 100) - s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, f) + s.socCharge = math.Min(*fetchedSoC+energyDelta/s.energyPerSocStep, 100) + s.log.DEBUG.Printf("soc estimated: %.2f%% (vehicle: %.2f%%)", s.socCharge, *fetchedSoC) } } diff --git a/core/soc/estimator_test.go b/core/soc/estimator_test.go index c94078c10..3532918d2 100644 --- a/core/soc/estimator_test.go +++ b/core/soc/estimator_test.go @@ -4,6 +4,7 @@ import ( "testing" "time" + "github.com/andig/evcc/api" "github.com/andig/evcc/mock" "github.com/andig/evcc/util" "github.com/golang/mock/gomock" @@ -11,11 +12,12 @@ import ( func TestRemainingChargeDuration(t *testing.T) { ctrl := gomock.NewController(t) + charger := mock.NewMockCharger(ctrl) vehicle := mock.NewMockVehicle(ctrl) //9 kWh userBatCap => 10 kWh virtualBatCap vehicle.EXPECT().Capacity().Return(int64(9)) - ce := NewEstimator(util.NewLogger("foo"), vehicle, false) + ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, false) ce.socCharge = 20.0 chargePower := 1000.0 @@ -27,14 +29,20 @@ func TestRemainingChargeDuration(t *testing.T) { } func TestSoCEstimation(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"), vehicle, true) + ce := NewEstimator(util.NewLogger("foo"), charger, vehicle, true) ce.socCharge = 20.0 tc := []struct { @@ -70,33 +78,44 @@ func TestSoCEstimation(t *testing.T) { {1000, 0.0, 10.0, 10000}, } - for _, tc := range tc { - t.Logf("%+v", tc) - vehicle.EXPECT().SoC().Return(tc.vehicleSoC, nil) - - soc, err := ce.SoC(tc.chargedEnergy) - if err != nil { - t.Error(err) + for i := 1; i < 3; i++ { + useVehicleSoC := true + if i == 2 { + useVehicleSoC = false } + for _, tc := range tc { + t.Logf("%+v", tc) + if useVehicleSoC { + charger.MockBattery.EXPECT().SoC().Return(tc.vehicleSoC, nil) + } else { + charger.MockBattery.EXPECT().SoC().Return(0.0, api.ErrNotAvailable) + vehicle.EXPECT().SoC().Return(tc.vehicleSoC, nil) + } - // validate soc estimate - if tc.estimatedSoC != soc { - t.Errorf("expected estimated soc: %g, got: %g", tc.estimatedSoC, soc) - } + soc, err := ce.SoC(tc.chargedEnergy) + if err != nil { + t.Error(err) + } - // validate capacity estimate - if tc.virtualCapacity != ce.virtualCapacity { - t.Errorf("expected virtual capacity: %v, got: %v", tc.virtualCapacity, ce.virtualCapacity) - } + // validate soc estimate + if tc.estimatedSoC != soc { + t.Errorf("expected estimated soc: %g, got: %g", tc.estimatedSoC, soc) + } - // 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) + // validate capacity estimate + if tc.virtualCapacity != ce.virtualCapacity { + t.Errorf("expected virtual capacity: %v, got: %v", tc.virtualCapacity, ce.virtualCapacity) + } - if rm := ce.RemainingChargeDuration(chargePower, targetSoC); rm != remainingDuration { - t.Errorf("expected estimated duration: %v, got: %v", remainingDuration, rm) + // 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) + } } } } diff --git a/mock/mock_api.go b/mock/mock_api.go index 39b9cffe9..1dde0a78b 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,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater) +// Source: github.com/andig/evcc/api (interfaces: Charger,ChargeState,Identifier,Meter,MeterEnergy,Vehicle,ChargeRater,Battery) // Package mock is a generated GoMock package. package mock @@ -362,3 +362,41 @@ func (mr *MockChargeRaterMockRecorder) ChargedEnergy() *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ChargedEnergy", reflect.TypeOf((*MockChargeRater)(nil).ChargedEnergy)) } + +// MockBattery is a mock of Battery interface. +type MockBattery struct { + ctrl *gomock.Controller + recorder *MockBatteryMockRecorder +} + +// MockBatteryMockRecorder is the mock recorder for MockBattery. +type MockBatteryMockRecorder struct { + mock *MockBattery +} + +// NewMockBattery creates a new mock instance. +func NewMockBattery(ctrl *gomock.Controller) *MockBattery { + mock := &MockBattery{ctrl: ctrl} + mock.recorder = &MockBatteryMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockBattery) EXPECT() *MockBatteryMockRecorder { + return m.recorder +} + +// SoC mocks base method. +func (m *MockBattery) SoC() (float64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "SoC") + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// SoC indicates an expected call of SoC. +func (mr *MockBatteryMockRecorder) SoC() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SoC", reflect.TypeOf((*MockBattery)(nil).SoC)) +}