Easee: reconcile session energy from charger meter at disconnect (#29350)

This commit is contained in:
Michael Heß 2026-04-27 17:47:40 +02:00 • committed by GitHub
parent e3a6d96736
commit 35b033324b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 277 additions and 13 deletions

View file

@ -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()
}

View file

@ -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

View file

@ -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))

View file

@ -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()

View file

@ -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

View file

@ -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() })
})
}