Easee: reconcile session energy from charger meter at disconnect (#29350)
This commit is contained in:
parent
e3a6d96736
commit
35b033324b
6 changed files with 277 additions and 13 deletions
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
})
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue