diff --git a/api/api.go b/api/api.go index df12724f2..9b8b25c0d 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/core/loadpoint.go b/core/loadpoint.go index 984624f46..f7689ef78 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 != nil { - lp.log.INFO.Printf("vehicle updated: %s -> %s", lp.vehicle.Title(), vehicle.Title()) + if lp.vehicle == vehicle { + return } - // update successful - lp.vehicleIdError = nil + from := "unknown" + if lp.vehicle != nil { + from = lp.vehicle.Title() + } + to := "unknown" + if vehicle != nil { + to = vehicle.Title() + } + lp.log.INFO.Printf("vehicle updated: %s -> %s", from, to) - lp.vehicle = vehicle - lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate) + if lp.vehicle = vehicle; vehicle != nil { + lp.socEstimator = soc.NewEstimator(lp.log, vehicle, lp.SoC.Estimate) - lp.publish("socTitle", lp.vehicle.Title()) - lp.publish("socCapacity", lp.vehicle.Capacity()) + lp.publish("socTitle", lp.vehicle.Title()) + lp.publish("socCapacity", lp.vehicle.Capacity()) + } else { + lp.socEstimator = nil + + lp.publish("socTitle", "unknown") + lp.publish("socCapacity", 0) + } } -// vehicleIdentificationAllowed returns true if active vehicle has not yet been identified +// 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 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 - } + return nil +} - // 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 - } - } - - // 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/core/loadpoint_test.go b/core/loadpoint_test.go index 855bfc800..afbb24a51 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/mock/mock_api.go b/mock/mock_api.go index dd1455ef2..39b9cffe9 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/provider/cache_test.go b/provider/cache_test.go index c0683eb8f..d12fb79e9 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) +}