Repository: evcc-io/evcc (go)

Issue:
Anzeige guardtimer, obwohl er nicht aktiv ist
Im UI wurde "Schaltschutz aktiv 1:53" angezeigt. Nach Ablauf der Zeit wurde aber richtigerweise weitergeladen.
Nach Aktualisierung der Webansicht (F5), war die Anzeige wieder da und zählte wieder runter. Ließ sich beliebig wiederholen.

Laienhafte Vermutung:
Im Log ist der letzte Guard Eintrag um 10:46:39.
Offensichtlich wird der nicht richtig beendet.
Erst als um 11:19 die Ladung gestoppt wurde, war auch die Anzeige im UI weg.

Auch in /api/state war der Eintrag in der Zeit vorhanden und wurde nicht aktualisiert.

[guard_log.txt](https://github.com/evcc-io/evcc/files/13695792/guard_log.txt)


Pull request description:
Loadpoint: publish guardTimer independently from pvTimer
fix #11123, contains #11204 adjusted for new master

Patch:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index ee70d4b1..276fc212 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -740,14 +740,14 @@ func (lp *Loadpoint) setLimit(chargeCurrent float64, force bool) error {
 		lp.bus.Publish(evChargeCurrent, chargeCurrent)
 	}
 
+	if lp.clock.Since(lp.guardUpdated).Truncate(time.Second) < lp.GuardDuration && !force {
+		lp.publishTimer(guardTimer, lp.GuardDuration, guardEnable)
+		return nil
+	}
+	lp.elapseGuard()
+
 	// set enabled/disabled
 	if enabled := chargeCurrent >= lp.effectiveMinCurrent(); enabled != lp.enabled {
-		if remaining := (lp.GuardDuration - lp.clock.Since(lp.guardUpdated)).Truncate(time.Second); remaining > 0 && !force {
-			lp.publishTimer(guardTimer, lp.GuardDuration, guardEnable)
-			return nil
-		}
-		lp.elapseGuard()
-
 		if err := lp.charger.Enable(enabled); err != nil {
 			v := lp.GetVehicle()
 			if vv, ok := v.(api.Resurrector); enabled && ok && errors.Is(err, api.ErrAsleep) {


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index aee70d13..548920ab 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -10,6 +10,7 @@ import (
 	"github.com/evcc-io/evcc/core/soc"
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/util"
+	"github.com/stretchr/testify/assert"
 	"go.uber.org/mock/gomock"
 )
 
@@ -524,13 +525,13 @@ func cacheExpecter(t *testing.T, lp *Loadpoint) (*util.Cache, func(key string, v
 
 	expect := func(key string, val interface{}) {
 		time.Sleep(100 * time.Millisecond) // wait for cache to catch up
+
 		p := cache.Get(key)
-		t.Logf("%s: %.f", key, p.Val) // REMOVE
+		t.Logf("%s: %v", key, p.Val) // REMOVE
 		if p.Val != val {
-			t.Errorf("%s wanted: %.0f, got %v", key, val, p.Val)
+			t.Errorf("%s wanted: %v, got %v", key, val, p.Val)
 		}
 	}
-
 	return cache, expect
 }
 
@@ -791,3 +792,128 @@ func TestSocPoll(t *testing.T) {
 		}
 	}
 }
+
+// test guard timer is properly published during disable cycle
+func TestGuardPublish(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	clock := clock.NewMock()
+	charger := api.NewMockCharger(ctrl)
+	rater := api.NewMockChargeRater(ctrl)
+	startPointInTime := clock.Now()
+
+	lp := &Loadpoint{
+		log:           util.NewLogger("foo"),
+		bus:           evbus.New(),
+		clock:         clock,
+		charger:       charger,
+		chargeMeter:   &Null{}, // silence nil panics
+		chargeRater:   rater,
+		chargeTimer:   &Null{}, // silence nil panics
+		guardUpdated:  startPointInTime,
+		pvTimer:       time.Time{},
+		wakeUpTimer:   NewTimer(),
+		sessionEnergy: NewEnergyMetrics(),
+		minCurrent:    minA,
+		maxCurrent:    maxA,
+		chargeCurrent: maxA,
+		status:        api.StatusC,
+		mode:          api.ModePV,
+		enabled:       true,
+		Disable:       ThresholdConfig{Delay: 2 * time.Minute, Threshold: 0}, // t, W
+		GuardDuration: 3 * time.Minute,
+	}
+
+	util.LogLevel("DEBUG", nil)
+
+	attachListeners(t, lp)
+	// attach cache for verifying values
+	_, expectCache := cacheExpecter(t, lp)
+
+	rater.EXPECT().ChargedEnergy().AnyTimes()
+	charger.EXPECT().Enabled().DoAndReturn(func() (bool, error) { return lp.enabled, nil }).AnyTimes() // just follow the loadpoint
+	charger.EXPECT().Status().Return(api.StatusC, nil).Times(4)
+	charger.EXPECT().Enable(false).Return(nil)
+
+	tc := []struct {
+		step                string
+		guardUpdates        time.Time
+		guardTimerRemaining time.Duration
+	}{
+		{"kick off pv disable timer", startPointInTime, 3 * time.Minute},
+		{"continue pv disable timer", startPointInTime, 2 * time.Minute},
+		{"disable charger prevented by guard", startPointInTime, 1 * time.Minute},
+		{"guard elapse, publish new guard time", startPointInTime.Add(3 * time.Minute), time.Duration(0)},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+		lp.Update(15000, false, false, false, 0, nil, nil)
+		assert.Equal(t, tc.guardUpdates, lp.guardUpdated)
+		expectCache(guardTimer+"Remaining", tc.guardTimerRemaining)
+		clock.Add(time.Minute)
+	}
+}
+
+// test guard timer is properly published if charger disable sequence is aborted
+func TestGuardPublishOnDisableAbort(t *testing.T) {
+	ctrl := gomock.NewController(t)
+	defer ctrl.Finish()
+	clock := clock.NewMock()
+	charger := api.NewMockCharger(ctrl)
+	rater := api.NewMockChargeRater(ctrl)
+	startPointInTime := clock.Now()
+
+	lp := &Loadpoint{
+		log:           util.NewLogger("foo"),
+		bus:           evbus.New(),
+		clock:         clock,
+		charger:       charger,
+		chargeMeter:   &Null{}, // silence nil panics
+		chargeRater:   rater,
+		chargeTimer:   &Null{}, // silence nil panics
+		guardUpdated:  startPointInTime,
+		pvTimer:       time.Time{},
+		wakeUpTimer:   NewTimer(),
+		sessionEnergy: NewEnergyMetrics(),
+		minCurrent:    minA,
+		maxCurrent:    maxA,
+		chargeCurrent: maxA,
+		status:        api.StatusC,
+		mode:          api.ModePV,
+		enabled:       true,
+		Disable:       ThresholdConfig{Delay: 2 * time.Minute, Threshold: 0}, // t, W
+		GuardDuration: 4 * time.Minute,
+	}
+
+	util.LogLevel("DEBUG", nil)
+
+	attachListeners(t, lp)
+	// attach cache for verifying values
+	_, expectCache := cacheExpecter(t, lp)
+
+	rater.EXPECT().ChargedEnergy().AnyTimes()
+	charger.EXPECT().Enabled().DoAndReturn(func() (bool, error) { return lp.enabled, nil }).AnyTimes() // just follow the loadpoint
+	charger.EXPECT().Status().Return(api.StatusC, nil).Times(5)
+
+	tc := []struct {
+		step                string
+		sitePower           float64
+		guardUpdated        time.Time
+		guardTimerRemaining time.Duration
+	}{
+		{"kick off pv disable timer", 15000, startPointInTime, 4 * time.Minute},
+		{"continue pv disable timer", 15000, startPointInTime, 3 * time.Minute},
+		{"disable charger prevented by guard", 15000, startPointInTime, 2 * time.Minute},
+		{"disable charger reset, pv power is back", -1, startPointInTime, 1 * time.Minute},
+		{"guard elapsed, publish new guard time", -1, elapsed, time.Duration(0)},
+	}
+
+	for _, tc := range tc {
+		t.Logf("%+v", tc)
+		lp.Update(tc.sitePower, false, false, false, 0, nil, nil)
+		assert.Equal(t, tc.guardUpdated, lp.guardUpdated)
+		expectCache(guardTimer+"Remaining", tc.guardTimerRemaining)
+		clock.Add(time.Minute)
+	}
+}