Repository: evcc-io/evcc (go)

Issue:
Sessions: close last charging session on restart if open
### Describe the bug

Wenn die Wallbox während der Ladung abstürzt, weil die Sicherung rausfliegt, an der auch der evcc hängt, dann führt das dazu, dass in der Datenbank evcc.db nur der Stromzählerstand vom Anfang der Ladung eingetragen wird. Es fehlt dann meter_end_kwh, charged_kwh und solar_percentage usw.

Ich stelle mir vor, dass evcc solche fehlerhaften Einträge im Log (nach dem Start von evcc) sucht und automatisch repariert, indem er die vorherige Ladung in der Datenbank beendet, indem es den aktuellen Zählerstand nachträgt (wenn der Fehler bei der letzten Ladung aufgetreten ist, höchste id in der sessions-Tabelle) als meter_end_kwh und dann auch charged_kwh ergänzt.

Im Prinzip kann es auch ältere Ladungen in der Datenbank ohne meter_end_kwh usw. geben. Ich hatte davon noch ein zweien zweiten unvollständigen Datensatz bei mir. Da müsste man dann für meter_end_kwh den Wert von meter_start_kwh der Folge-id nehmen.

Natürlich gibt es dabei eine sehr geringe Abweichung, aber wenn das zum Neustart nachgetragen wird, sollte man damit sehr gut leben können.

Egal wie es umgesetzt wird, Hauptsache das Ladelog wird nicht unvollständig gelassen.

### Steps to reproduce

1.
2.
3.
...


### Configuration details

```yaml
/
```


### Log details

```text
# Ein alter fehlerhafter Eintrag im Log aus Dezember:
> select * from sessions where id=19 or id=20;
19|2022-12-21 22:52:46.407466926+01:00|0001-01-01 00:00:00+00:00|Garage||ZOE|1182.40100097656|0.0|0.0|0.0||||
20|2022-12-22 08:50:39.002141127+01:00|2022-12-22 09:02:38.592301774+01:00|Garage||ZOE|1208.96496582031|1211.64501953125|2.64697265625|19188.83||||

# Ein aktueller fehlerhafter Eintrag von Version 0.118.1
# Die Sicherung ist während der Ladung rausgeflogen!
> select * from sessions where id>=170;
170|2023-07-02 14:00:28.432215708+02:00|0001-01-01 00:00:00+00:00|Garage||ZOE|2806.86889648437||0.0|28725.75||||
171|2023-07-02 15:22:24.982809287+02:00|2023-07-02 16:25:24.969106882+02:00|Garage||ZOE|2833.166015625|2854.46997070312|21.302001953125|28725.75|0.065456354489728|||
```


### What type of operating system are you running?

Linux

### Version

0.118.1

Pull request:
Session log: close unfinished sessions on startup
fix #8788

WIP. Marking as draft.


Patch:
diff --git a/core/loadpoint.go b/core/loadpoint.go
index bd4ad667..4e6b58e7 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -174,7 +174,7 @@ type Loadpoint struct {
 	progress                *Progress      // Step-wise progress indicator
 
 	// session log
-	db      session.Database
+	db      *session.DB
 	session *session.Session
 
 	tasks *util.Queue[Task] // tasks to be executed
diff --git a/core/session/db.go b/core/session/db.go
index 9c3e70a8..6c910471 100644
--- a/core/session/db.go
+++ b/core/session/db.go
@@ -12,11 +12,6 @@ type DB struct {
 	name string
 }
 
-type Database interface {
-	New(startEnergy float64) *Session
-	Persist(session interface{})
-}
-
 // NewStore creates a session store
 func NewStore(name string, db *gorm.DB) (*DB, error) {
 	err := db.AutoMigrate(new(Session))
@@ -57,3 +52,31 @@ func (s *DB) Sessions() (Sessions, error) {
 	tx := s.db.Find(&res)
 	return res, tx.Error
 }
+
+func (s *DB) ClosePendingSessionsInHistory(chargeMeterTotal float64) error {
+	var res Sessions
+	if tx := s.db.Find(&res, map[string]interface{}{"finished": "0001-01-01 00:00:00+00:00", "Loadpoint": s.name}); tx.Error != nil {
+		return tx.Error
+	}
+
+	for _, session := range res {
+		var nextSession Session
+
+		var tx *gorm.DB
+		if tx = s.db.Limit(1).Order("ID").Find(&nextSession, "ID > ? AND Loadpoint = ?", session.ID, s.name); tx.Error != nil {
+			return tx.Error
+		}
+
+		if tx.RowsAffected == 0 {
+			// no successor, this is the most recent session and it is open
+			session.MeterStop = &chargeMeterTotal
+		} else {
+			session.MeterStop = nextSession.MeterStart
+		}
+
+		session.ChargedEnergy = *session.MeterStop - *session.MeterStart
+		s.Persist(session)
+	}
+
+	return nil
+}
diff --git a/core/site.go b/core/site.go
index f59f7349..125748f8 100644
--- a/core/site.go
+++ b/core/site.go
@@ -142,6 +142,10 @@ func NewSiteFromConfig(
 			if lp.db, err = session.NewStore(lp.Title(), db.Instance); err != nil {
 				return nil, err
 			}
+			// Fix any dangling history
+			if err := lp.db.ClosePendingSessionsInHistory(lp.chargeMeterTotal()); err != nil {
+				return nil, err
+			}
 
 			// NOTE: this requires stopSession to respect async access
 			shutdown.Register(lp.stopSession)
diff --git a/go.mod b/go.mod
index 39c53323..1593d781 100644
--- a/go.mod
+++ b/go.mod
@@ -36,7 +36,6 @@ require (
 	github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1
 	github.com/godbus/dbus/v5 v5.1.0
 	github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2
-	github.com/golang-module/carbon/v2 v2.2.8
 	github.com/golang/mock v1.6.0
 	github.com/google/go-github/v32 v32.1.0
 	github.com/google/uuid v1.3.1
@@ -51,6 +50,7 @@ require (
 	github.com/itchyny/gojq v0.12.13
 	github.com/jeremywohl/flatten v1.0.1
 	github.com/jinzhu/copier v0.4.0
+	github.com/jinzhu/now v1.1.5
 	github.com/joeshaw/carwings v0.0.0-20230425210048-195f7e10e966
 	github.com/joho/godotenv v1.5.1
 	github.com/jpfielding/go-http-digest v0.0.0-20211006141426-fbc93758452e
@@ -145,7 +145,6 @@ require (
 	github.com/influxdata/line-protocol v0.0.0-20210922203350-b1ad95c89adf // indirect
 	github.com/itchyny/timefmt-go v0.1.5 // indirect
 	github.com/jinzhu/inflection v1.0.0 // indirect
-	github.com/jinzhu/now v1.1.5 // indirect
 	github.com/jmespath/go-jmespath v0.4.0 // indirect
 	github.com/json-iterator/go v1.1.12 // indirect
 	github.com/klauspost/compress v1.17.0 // indirect
diff --git a/go.sum b/go.sum
index 33dd7c68..cf2e0853 100644
--- a/go.sum
+++ b/go.sum
@@ -297,8 +297,6 @@ github.com/gogo/protobuf v1.2.0/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7a
 github.com/gogo/protobuf v1.2.1/go.mod h1:hp+jE20tsWTFYpLwKvXlhS1hjn+gTNwPg2I6zVXpSg4=
 github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2 h1:kBY5R1tSf+EYZ+QaSrofLaVJtBqYsVNVBWkdMq3Smcg=
 github.com/gokrazy/updater v0.0.0-20230215172637-813ccc7f21e2/go.mod h1:PYOvzGOL4nlBmuxu7IyKQTFLaxr61+WPRNRzVtuYOHw=
-github.com/golang-module/carbon/v2 v2.2.8 h1:a1VxHHKAR7fc1ho7sYXhS1s5S4x7+oqAf2EY5p8C46A=
-github.com/golang-module/carbon/v2 v2.2.8/go.mod h1:XDALX7KgqmHk95xyLeaqX9/LJGbfLATyruTziq68SZ8=
 github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
 github.com/golang/groupcache v0.0.0-20160516000752-02826c3e7903/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
 github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
diff --git a/tariff/fixed.go b/tariff/fixed.go
index 4c97328e..11f6a36a 100644
--- a/tariff/fixed.go
+++ b/tariff/fixed.go
@@ -3,12 +3,13 @@ package tariff
 import (
 	"fmt"
 	"sort"
+	"time"
 
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/tariff/fixed"
 	"github.com/evcc-io/evcc/util"
-	"github.com/golang-module/carbon/v2"
+	"github.com/jinzhu/now"
 )
 
 type Fixed struct {
@@ -83,20 +84,20 @@ func NewFixedFromConfig(other map[string]interface{}) (api.Tariff, error) {
 func (t *Fixed) Rates() (api.Rates, error) {
 	var res api.Rates
 
-	start := carbon.CreateFromStdTime(t.clock.Now().Local()).StartOfDay()
+	start := now.With(t.clock.Now().Local()).BeginningOfDay()
 	for i := 0; i < 7; i++ {
-		dow := fixed.Day((start.DayOfWeek() + i) % 7)
+		dow := fixed.Day((int(start.Weekday()) + i) % 7)
 
 		zones := t.zones.ForDay(dow)
 		if len(zones) == 0 {
 			return nil, fmt.Errorf("no zones for weekday %d", dow)
 		}
 
-		dayStart := start.AddDays(i)
+		dayStart := start.AddDate(0, 0, i)
 		markers := zones.TimeTableMarkers()
 
 		for i, m := range markers {
-			ts := dayStart.AddMinutes(m.Minutes())
+			ts := dayStart.Add(time.Minute * time.Duration(m.Minutes()))
 
 			var zone *fixed.Zone
 			for j := len(zones) - 1; j >= 0; j-- {
@@ -111,15 +112,15 @@ func (t *Fixed) Rates() (api.Rates, error) {
 			}
 
 			// end rate at end of day or next marker
-			end := dayStart.AddDay()
+			end := dayStart.AddDate(0, 0, 1)
 			if i+1 < len(markers) {
-				end = dayStart.AddMinutes(markers[i+1].Minutes())
+				end = dayStart.Add(time.Minute * time.Duration(markers[i+1].Minutes()))
 			}
 
 			rate := api.Rate{
 				Price: zone.Price,
-				Start: ts.ToStdTime(),
-				End:   end.ToStdTime(),
+				Start: ts,
+				End:   end,
 			}
 
 			res = append(res, rate)


Test patch:
diff --git a/core/loadpoint_session_test.go b/core/loadpoint_session_test.go
index aeb63749..fdc9a1e7 100644
--- a/core/loadpoint_session_test.go
+++ b/core/loadpoint_session_test.go
@@ -87,3 +87,99 @@ func TestSession(t *testing.T) {
 	assert.Len(t, s, 1)
 	t.Logf("session: %+v", s)
 }
+
+func TestCloseSessionsOnStartup_emptyDb(t *testing.T) {
+	var err error
+	serverdb.Instance, err = serverdb.New("sqlite", ":memory:")
+	assert.NoError(t, err)
+
+	db, err := session.NewStore("foo", serverdb.Instance)
+	assert.NoError(t, err)
+
+	// assert empty DB is no problem
+	err = db.ClosePendingSessionsInHistory(1000)
+	assert.NoError(t, err)
+}
+
+func TestCloseSessionsOnStartup(t *testing.T) {
+	var err error
+	serverdb.Instance, err = serverdb.New("sqlite", ":memory:")
+	assert.NoError(t, err)
+
+	db1, err := session.NewStore("foo", serverdb.Instance)
+	assert.NoError(t, err)
+
+	db2, err := session.NewStore("bar", serverdb.Instance)
+	assert.NoError(t, err)
+
+	clock := clock.NewMock()
+
+	//test data, creates 6 sessions for each loadpoint, 3rd and 6th are "unfinished"
+	var sessions1 []*session.Session = createMockSessions(db1, clock)
+	var sessions2 []*session.Session = createMockSessions(db2, clock)
+
+	//write interleaved for two loadpoints
+	for index, session := range sessions1 {
+		db1.Persist(session)
+		db2.Persist(sessions2[index])
+	}
+
+	err = db1.ClosePendingSessionsInHistory(1000)
+	assert.NoError(t, err)
+
+	//check fixed sessions for db1
+	var db1Sessions session.Sessions
+	err = serverdb.Instance.Where("Loadpoint = ?", "foo").Order("ID").Find(&db1Sessions).Error
+	assert.NoError(t, err)
+	assert.Len(t, db1Sessions, 6)
+
+	//check fixed history
+	for _, s := range db1Sessions[:5] {
+		assert.NotEmpty(t, s.MeterStop)
+		assert.Equal(t, float64(10), s.ChargedEnergy)
+		t.Logf("session: %+v", s)
+	}
+
+	//check fixed most recent record
+	assert.NotEmpty(t, db1Sessions[5].MeterStop)
+	assert.Equal(t, float64(940), db1Sessions[5].ChargedEnergy)
+
+	//ensure no side effects on loadpoint 2 data, i.e. data left unfixed
+	var db2Sessions session.Sessions
+	err = serverdb.Instance.Where("Loadpoint = ?", "bar").Order("ID").Find(&db2Sessions).Error
+	assert.NoError(t, err)
+	assert.Len(t, db2Sessions, 6)
+
+	for i, s := range db2Sessions {
+		if (i+1)%3 == 0 {
+			assert.Empty(t, s.MeterStop)
+			assert.Empty(t, s.ChargedEnergy)
+			continue
+		}
+		assert.NotEmpty(t, s.MeterStop)
+		assert.Equal(t, float64(10), s.ChargedEnergy)
+	}
+}
+
+func createMockSessions(db *session.DB, clock *clock.Mock) []*session.Session {
+	var sessions []*session.Session
+	for i := 1; i <= 6; i++ {
+
+		var meter1Start float64 = float64(i * 10)
+		session := db.New(meter1Start)
+		session.Created = clock.Now().Add(1 * time.Minute)
+
+		//create every third session as incomplete
+		if i%3 == 0 {
+			sessions = append(sessions, session)
+			continue
+		}
+
+		session.Finished = clock.Now().Add(2 * time.Minute)
+		meterStop := float64(meter1Start + 10)
+		session.MeterStop = &meterStop
+		session.ChargedEnergy = 10
+		sessions = append(sessions, session)
+	}
+	return sessions
+}
diff --git a/tariff/fixed_test.go b/tariff/fixed_test.go
index 62c964eb..15656af2 100644
--- a/tariff/fixed_test.go
+++ b/tariff/fixed_test.go
@@ -2,11 +2,12 @@ package tariff
 
 import (
 	"testing"
+	"time"
 
 	"github.com/benbjohnson/clock"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/tariff/fixed"
-	"github.com/golang-module/carbon/v2"
+	"github.com/jinzhu/now"
 	"github.com/stretchr/testify/assert"
 )
 
@@ -20,13 +21,13 @@ func TestFixed(t *testing.T) {
 
 	var expect api.Rates
 	for dow := 0; dow < 7; dow++ {
-		dayStart := carbon.CreateFromStdTime(tf.clock.Now()).StartOfDay().AddDays(dow)
+		dayStart := now.With(tf.clock.Now()).BeginningOfDay().AddDate(0, 0, dow)
 
 		for hour := 0; hour < 24; hour++ {
 			expect = append(expect, api.Rate{
 				Price: 0.3,
-				Start: dayStart.AddHours(hour).ToStdTime(),
-				End:   dayStart.AddHours(hour + 1).ToStdTime(),
+				Start: dayStart.Add(time.Hour * time.Duration(hour)),
+				End:   dayStart.Add(time.Hour * time.Duration(hour+1)),
 			})
 		}
 	}
@@ -53,37 +54,37 @@ func TestFixedSplitZones(t *testing.T) {
 
 	var expect api.Rates
 	for i := 0; i < 7; i++ {
-		dayStart := carbon.CreateFromStdTime(tf.clock.Now()).StartOfDay().AddDays(i)
+		dayStart := now.With(tf.clock.Now()).BeginningOfDay().AddDate(0, 0, i)
 
 		// 00:00-05:00 0.1
 		for hour := 0; hour < 5; hour++ {
 			expect = append(expect, api.Rate{
 				Price: 0.1,
-				Start: dayStart.AddHours(hour).ToStdTime(),
-				End:   dayStart.AddHours(hour + 1).ToStdTime(),
+				Start: dayStart.Add(time.Hour * time.Duration(hour)),
+				End:   dayStart.Add(time.Hour * time.Duration(hour+1)),
 			})
 		}
 
 		// 05:00-05:30 0.1
 		expect = append(expect, api.Rate{
 			Price: 0.1,
-			Start: dayStart.AddHours(5).ToStdTime(),
-			End:   dayStart.AddHours(5).AddMinutes(30).ToStdTime(),
+			Start: dayStart.Add(5 * time.Hour),
+			End:   dayStart.Add(5*time.Hour + 30*time.Minute),
 		})
 
 		// 05:30-06:00 0.5
 		expect = append(expect, api.Rate{
 			Price: 0.5,
-			Start: dayStart.AddHours(5).AddMinutes(30).ToStdTime(),
-			End:   dayStart.AddHours(6).ToStdTime(),
+			Start: dayStart.Add(5*time.Hour + 30*time.Minute),
+			End:   dayStart.Add(6 * time.Hour),
 		})
 
 		// 06:00-21:00 0.5
 		for hour := 6; hour < 21; hour++ {
 			expect = append(expect, api.Rate{
 				Price: 0.5,
-				Start: dayStart.AddHours(hour).ToStdTime(),
-				End:   dayStart.AddHours(hour + 1).ToStdTime(),
+				Start: dayStart.Add(time.Hour * time.Duration(hour)),
+				End:   dayStart.Add(time.Hour * time.Duration(hour+1)),
 			})
 		}
 
@@ -91,8 +92,8 @@ func TestFixedSplitZones(t *testing.T) {
 		for hour := 21; hour < 24; hour++ {
 			expect = append(expect, api.Rate{
 				Price: 0.1,
-				Start: dayStart.AddHours(hour).ToStdTime(),
-				End:   dayStart.AddHours(hour + 1).ToStdTime(),
+				Start: dayStart.Add(time.Hour * time.Duration(hour)),
+				End:   dayStart.Add(time.Hour * time.Duration(hour+1)),
 			})
 		}
 	}