From 35b033324b053a0cf36e8ea00878ed381c7e45fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Mon, 27 Apr 2026 17:47:40 +0200 Subject: [PATCH] Easee: reconcile session energy from charger meter at disconnect (#29350) --- charger/easee.go | 35 ++++++++++-- charger/easee/signalr.go | 19 +++++++ charger/easee_test.go | 98 ++++++++++++++++++++++++++++++++++ core/loadpoint.go | 3 ++ core/loadpoint_session.go | 50 +++++++++++++---- core/loadpoint_session_test.go | 85 +++++++++++++++++++++++++++++ 6 files changed, 277 insertions(+), 13 deletions(-) diff --git a/charger/easee.go b/charger/easee.go index 8297e9235..8ff31ff89 100644 --- a/charger/easee.go +++ b/charger/easee.go @@ -67,8 +67,9 @@ type Easee struct { phaseMode int currentPower, sessionEnergy, totalEnergy, currentL1, currentL2, currentL3 float64 - rfid string - lp loadpoint.API + currentSessionID int + rfid string + lp loadpoint.API dispatcher *easee.CommandDispatcher @@ -361,7 +362,9 @@ func (c *Easee) ProductUpdate(i json.RawMessage) { c.sessionEnergy = value.(float64) } case easee.LIFETIME_ENERGY: - c.totalEnergy = value.(float64) + if v := value.(float64); v >= c.totalEnergy { + c.totalEnergy = v + } case easee.INT_CURRENT_T3: c.currentL1 = value.(float64) case easee.INT_CURRENT_T4: @@ -380,6 +383,31 @@ func (c *Easee) ProductUpdate(i json.RawMessage) { c.maxChargerCurrent = value.(float64) case easee.DYNAMIC_CHARGER_CURRENT: c.dynamicChargerCurrent = value.(float64) + case easee.CHARGE_SESSION_START: + var data easee.ChargingSessionStartData + if err := json.Unmarshal([]byte(res.Value), &data); err != nil { + c.log.ERROR.Printf("CHARGE_SESSION_START: %v", err) + break + } + c.currentSessionID = data.ID + if data.MeterValue >= c.totalEnergy { + c.totalEnergy = data.MeterValue + } + + case easee.CHARGING_SESSION: + var data easee.ChargingSessionData + if err := json.Unmarshal([]byte(res.Value), &data); err != nil { + c.log.ERROR.Printf("CHARGING_SESSION: %v", err) + break + } + if data.MeterValueStop >= c.totalEnergy { + c.totalEnergy = data.MeterValueStop + } + if data.ID != c.currentSessionID { + break + } + c.sessionEnergy = data.EnergyKwh + case easee.CHARGER_OP_MODE: opMode := value.(int) @@ -387,6 +415,7 @@ func (c *Easee) ProductUpdate(i json.RawMessage) { // This should be done in a proper way by the api, but it's not. if c.opMode <= easee.ModeDisconnected && opMode >= easee.ModeAwaitingStart { c.sessionEnergy = 0 + c.currentSessionID = 0 c.obsTime[easee.SESSION_ENERGY] = time.Now() } diff --git a/charger/easee/signalr.go b/charger/easee/signalr.go index 8e5d741a8..d64629396 100644 --- a/charger/easee/signalr.go +++ b/charger/easee/signalr.go @@ -28,6 +28,25 @@ func (o *Observation) TypedValue() (any, error) { } } +type ChargingSessionStartData struct { + ID int `json:"Id"` + MeterValue float64 `json:"MeterValue"` + Start time.Time `json:"Start"` + Auth string `json:"Auth"` + AuthReason int `json:"AuthReason"` +} + +type ChargingSessionData struct { + ID int `json:"Id"` + Start time.Time `json:"Start"` + Stop time.Time `json:"Stop"` + EnergyKwh float64 `json:"EnergyKwh"` + MeterValueStart float64 `json:"MeterValueStart"` + MeterValueStop float64 `json:"MeterValueStop"` + Auth string `json:"Auth"` + AuthReason int `json:"AuthReason"` +} + type SignalRCommandResponse struct { SerialNumber string ID int diff --git a/charger/easee_test.go b/charger/easee_test.go index 8744ad78b..916ae1897 100644 --- a/charger/easee_test.go +++ b/charger/easee_test.go @@ -501,6 +501,104 @@ func TestLivenessCheck_freshObservations(t *testing.T) { assert.Equal(t, float64(16), l3) } +func TestChargeSessionStart_SetsFields(t *testing.T) { + e := newEasee() + e.totalEnergy = 9100.0 + + data := easee.ChargingSessionStartData{ID: 801, MeterValue: 9141.414622} + jsonData, _ := json.Marshal(data) + e.ProductUpdate(createPayload(easee.CHARGE_SESSION_START, time.Now(), easee.String, string(jsonData))) + + assert.Equal(t, 801, e.currentSessionID) + + total, err := e.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 9141.414622, total) +} + +func TestChargingSession_UpdatesBothWhenIdMatches(t *testing.T) { + e := newEasee() + e.currentSessionID = 801 + e.totalEnergy = 9100.0 + + data := easee.ChargingSessionData{ID: 801, EnergyKwh: 16.2, MeterValueStop: 9173.5} + jsonData, _ := json.Marshal(data) + e.ProductUpdate(createPayload(easee.CHARGING_SESSION, time.Now(), easee.String, string(jsonData))) + + charged, err := e.ChargedEnergy() + assert.NoError(t, err) + assert.Equal(t, 16.2, charged) + + total, err := e.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 9173.5, total) +} + +func TestChargingSession_MismatchedId_ProtectsSessionEnergy(t *testing.T) { + e := newEasee() + e.currentSessionID = 100 + e.sessionEnergy = 5.0 + e.totalEnergy = 9150.0 + + data := easee.ChargingSessionData{ID: 99, EnergyKwh: 7.5, MeterValueStop: 9173.5} + jsonData, _ := json.Marshal(data) + e.ProductUpdate(createPayload(easee.CHARGING_SESSION, time.Now(), easee.String, string(jsonData))) + + charged, err := e.ChargedEnergy() + assert.NoError(t, err) + assert.Equal(t, 5.0, charged) // sessionEnergy unchanged + + total, err := e.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 9173.5, total) // totalEnergy updated +} + +func TestChargingSession_AtStartup_ProtectsSessionEnergy(t *testing.T) { + e := newEasee() + // currentSessionId is 0 by default + e.sessionEnergy = 0 + e.totalEnergy = 9150.0 + + data := easee.ChargingSessionData{ID: 803, EnergyKwh: 19.08, MeterValueStop: 9173.5} + jsonData, _ := json.Marshal(data) + e.ProductUpdate(createPayload(easee.CHARGING_SESSION, time.Now(), easee.String, string(jsonData))) + + charged, err := e.ChargedEnergy() + assert.NoError(t, err) + assert.Equal(t, 0.0, charged) // sessionEnergy protected (Id 803 != 0) + + total, err := e.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 9173.5, total) // totalEnergy updated +} + +func TestLifetimeEnergy_DoesNotDecreaseTotalEnergy(t *testing.T) { + e := newEasee() + e.totalEnergy = 9173.5 + + e.ProductUpdate(createPayload(easee.LIFETIME_ENERGY, time.Now(), easee.Double, "9170.0")) + + total, err := e.TotalEnergy() + assert.NoError(t, err) + assert.Equal(t, 9173.5, total) +} + +func TestChargerOpMode_ConnectResetsSessionFields(t *testing.T) { + e := newEasee() + e.opMode = easee.ModeDisconnected + e.currentSessionID = 803 + e.sessionEnergy = 5.0 + + // Transition from disconnected to awaiting start + e.ProductUpdate(createPayload(easee.CHARGER_OP_MODE, time.Now(), easee.Integer, fmt.Sprintf("%d", easee.ModeAwaitingStart))) + + assert.Equal(t, 0, e.currentSessionID) + + charged, err := e.ChargedEnergy() + assert.NoError(t, err) + assert.Equal(t, 0.0, charged) +} + func TestIsTNGrid(t *testing.T) { // TN grid types must return true assert.True(t, isTNGrid(easee.PowerGridTN3Phase)) diff --git a/core/loadpoint.go b/core/loadpoint.go index 4177e6a34..a1aabd439 100644 --- a/core/loadpoint.go +++ b/core/loadpoint.go @@ -546,6 +546,9 @@ func (lp *Loadpoint) evVehicleConnectHandler() { func (lp *Loadpoint) evVehicleDisconnectHandler() { lp.log.INFO.Println("car disconnected") + // re-read energy from charger and re-persist session if values improved + lp.finalizeSessionEnergy() + // session is persisted during evChargeStopHandler which runs before lp.clearSession() diff --git a/core/loadpoint_session.go b/core/loadpoint_session.go index 6ebe6251f..d30315ab8 100644 --- a/core/loadpoint_session.go +++ b/core/loadpoint_session.go @@ -64,6 +64,21 @@ func (lp *Loadpoint) createSession() { lp.publish(keys.ChargedEnergy, lp.GetChargedEnergy()) } +// applyEnergyMetrics writes current energy metrics into the session and persists it. +func (lp *Loadpoint) applyEnergyMetrics(s *session.Session) { + if meterStop := lp.chargeMeterTotal(); meterStop > 0 { + s.MeterStop = &meterStop + } + + s.SolarPercentage = new(lp.energyMetrics.SolarPercentage()) + s.Price = lp.energyMetrics.Price() + s.PricePerKWh = lp.energyMetrics.PricePerKWh() + s.Co2PerKWh = lp.energyMetrics.Co2PerKWh() + s.ChargedEnergy = lp.energyMetrics.TotalWh() / 1e3 + + lp.db.Persist(s) +} + // stopSession ends a charging session segment and persists the session. func (lp *Loadpoint) stopSession() { s := lp.session @@ -79,18 +94,9 @@ func (lp *Loadpoint) stopSession() { } s.Finished = lp.clock.Now() - if meterStop := lp.chargeMeterTotal(); meterStop > 0 { - s.MeterStop = &meterStop - } - - s.SolarPercentage = new(lp.energyMetrics.SolarPercentage()) - s.Price = lp.energyMetrics.Price() - s.PricePerKWh = lp.energyMetrics.PricePerKWh() - s.Co2PerKWh = lp.energyMetrics.Co2PerKWh() - s.ChargedEnergy = lp.energyMetrics.TotalWh() / 1e3 s.ChargeDuration = new(lp.chargeDuration.Abs()) - lp.db.Persist(s) + lp.applyEnergyMetrics(s) } type sessionOption func(*session.Session) @@ -121,6 +127,30 @@ func (lp *Loadpoint) clearSession() { lp.session = nil } +func (lp *Loadpoint) finalizeSessionEnergy() { + s := lp.session + if lp.db == nil || s == nil || s.Created.IsZero() { + return + } + + f, err := lp.chargeRater.ChargedEnergy() + if err != nil { + lp.log.ERROR.Printf("session energy: %v", err) + return + } + + chargedKWh := f - lp.chargedAtStartup + if chargedKWh <= s.ChargedEnergy { + return + } + + lp.log.DEBUG.Printf("session energy: %.3f -> %.3fkWh", s.ChargedEnergy, chargedKWh) + + lp.energyMetrics.Update(chargedKWh) + + lp.applyEnergyMetrics(s) +} + func (lp *Loadpoint) resetHeatingSession() { if lp.session == nil || !lp.chargerHasFeature(api.Heating) || !lp.chargerHasFeature(api.IntegratedDevice) { return diff --git a/core/loadpoint_session_test.go b/core/loadpoint_session_test.go index a6f833ec1..83f6e8f13 100644 --- a/core/loadpoint_session_test.go +++ b/core/loadpoint_session_test.go @@ -258,3 +258,88 @@ func TestResetHeatingSession(t *testing.T) { assert.Equal(t, 1.0, *lp.session.MeterStart) assert.Equal(t, 3.0, *lp.session.MeterStop) } + +func TestFinalizeSessionEnergy(t *testing.T) { + setup := func(t *testing.T) (*Loadpoint, *api.MockMeterEnergy, *api.MockChargeRater) { + t.Helper() + var err error + serverdb.Instance, err = serverdb.New("sqlite", ":memory:") + require.NoError(t, err) + db, err := session.NewStore("foo", serverdb.Instance) + require.NoError(t, err) + + ctrl := gomock.NewController(t) + mm := api.NewMockMeter(ctrl) + me := api.NewMockMeterEnergy(ctrl) + rater := api.NewMockChargeRater(ctrl) + + type EnergyDecorator struct { + api.Meter + api.MeterEnergy + } + + cm := &EnergyDecorator{ + Meter: mm, + MeterEnergy: me, + } + + lp := &Loadpoint{ + log: util.NewLogger("foo"), + clock: clock.NewMock(), + db: db, + chargeRater: rater, + chargeMeter: cm, + } + return lp, me, rater + } + + t.Run("corrects session when ChargedEnergy increased", func(t *testing.T) { + lp, me, rater := setup(t) + + me.EXPECT().TotalEnergy().Return(9157.3, nil) + lp.createSession() + lp.session.Created = lp.clock.Now() + + lp.energyMetrics.Update(15.3) + me.EXPECT().TotalEnergy().Return(9164.0, nil) + lp.stopSession() + require.Equal(t, 15.3, lp.session.ChargedEnergy) + + rater.EXPECT().ChargedEnergy().Return(16.2, nil) + me.EXPECT().TotalEnergy().Return(9173.5, nil) + lp.finalizeSessionEnergy() + + assert.Equal(t, 16.2, lp.session.ChargedEnergy) + assert.Equal(t, 9173.5, *lp.session.MeterStop) + assert.Equal(t, 9157.3, *lp.session.MeterStart) + }) + + t.Run("no-op when ChargedEnergy unchanged", func(t *testing.T) { + lp, me, rater := setup(t) + + me.EXPECT().TotalEnergy().Return(9154.4, nil) + lp.createSession() + lp.session.Created = lp.clock.Now() + + lp.energyMetrics.Update(15.3) + me.EXPECT().TotalEnergy().Return(9164.0, nil) + lp.stopSession() + require.Equal(t, 15.3, lp.session.ChargedEnergy) + + rater.EXPECT().ChargedEnergy().Return(15.3, nil) + lp.finalizeSessionEnergy() + + assert.Equal(t, 15.3, lp.session.ChargedEnergy) + assert.Equal(t, 9164.0, *lp.session.MeterStop) + }) + + t.Run("no-op when session nil or uncreated", func(t *testing.T) { + lp, _, _ := setup(t) + + lp.session = nil + assert.NotPanics(t, func() { lp.finalizeSessionEnergy() }) + + lp.session = &session.Session{} + assert.NotPanics(t, func() { lp.finalizeSessionEnergy() }) + }) +}