EEBus: fix stated limit duration expiring at half its length (#32856)

This commit is contained in:
andig 2026-08-15 11:43:28 +02:00 • committed by GitHub
parent eaa49fceb3
commit 4f169c5812
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 152 additions and 8 deletions

View file

@ -0,0 +1,124 @@
package eebus
import (
"testing"
"time"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/stretchr/testify/require"
)
// activeEEBus returns a connected EEBus in StatusNormal with a live heartbeat,
// which is the state an Energy Guard limit write arrives in.
func activeEEBus(t *testing.T) *EEBus {
t.Helper()
c := newTestEEBus(t)
c.Connect(true)
c.heartbeat.Set(struct{}{})
c.setStatus(StatusNormal)
c.heartbeatReturned = time.Now()
return c
}
// TestRun_ConsumptionLimit_DurationSurvivesRefresh verifies that an Energy Guard
// re-notifying a running limit does not cut its lifetime short.
//
// spine stores a stated TimePeriod as an absolute EndTime, so eebus-go reports
// Duration as the time remaining and every refresh hands evcc a smaller value.
// Measuring that against the original activation would let the two clocks meet
// at half the stated duration.
func TestRun_ConsumptionLimit_DurationSurvivesRefresh(t *testing.T) {
const (
limit = 4200.0
total = 2 * time.Hour
spent = 61 * time.Minute
)
c := activeEEBus(t)
c.consumptionLimit = ucapi.LoadLimit{Duration: total, IsActive: true, Value: limit}
c.limitReceived = time.Now()
require.NoError(t, c.run())
assertConsumptionLimit(t, c, limit)
// 61 minutes under limit
*c.consumptionLimitActivated = time.Now().Add(-spent)
// the EG re-notifies; updateConsumptionLimit() overwrites Duration with the
// remaining time reported by eebus-go
c.setConsumptionLimitData(ucapi.LoadLimit{Duration: total - spent, IsActive: true, Value: limit})
require.NoError(t, c.run())
assertConsumptionLimit(t, c, limit) // 59 minutes still to go
}
// TestRun_ProductionLimit_DurationSurvivesRefresh is the LPP mirror.
func TestRun_ProductionLimit_DurationSurvivesRefresh(t *testing.T) {
const (
total = 2 * time.Hour
spent = 61 * time.Minute
)
c := activeEEBus(t)
c.productionLimit = ucapi.LoadLimit{Duration: total, IsActive: true, Value: -500}
c.limitReceived = time.Now()
require.NoError(t, c.run())
assertProductionLimit(t, c, true)
*c.productionLimitActivated = time.Now().Add(-spent)
c.setProductionLimitData(ucapi.LoadLimit{Duration: total - spent, IsActive: true, Value: -500})
require.NoError(t, c.run())
assertProductionLimit(t, c, true)
}
// TestRun_ConsumptionLimit_RefreshBeforeActivation covers the other branch of the
// restart: before run() has applied the limit there is no clock to restart, so the
// duration is measured from the activation that follows.
func TestRun_ConsumptionLimit_RefreshBeforeActivation(t *testing.T) {
const (
limit = 4200.0
total = time.Hour
)
c := activeEEBus(t)
// two notifications arrive before the next tick
c.setConsumptionLimitData(ucapi.LoadLimit{Duration: total, IsActive: true, Value: limit})
c.setConsumptionLimitData(ucapi.LoadLimit{Duration: total, IsActive: true, Value: limit})
require.False(t, limitActive(c.consumptionLimitActivated), "limit is not applied yet")
require.NoError(t, c.run())
assertConsumptionLimit(t, c, limit)
// the clock starts at activation, not at the first notification
*c.consumptionLimitActivated = time.Now().Add(-30 * time.Minute)
require.NoError(t, c.run())
assertConsumptionLimit(t, c, limit)
}
// TestRun_ConsumptionLimit_RefreshWhileReleased verifies a released limit is not
// resurrected by a refresh that keeps it inactive.
func TestRun_ConsumptionLimit_RefreshWhileReleased(t *testing.T) {
c := activeEEBus(t)
c.setConsumptionLimitData(ucapi.LoadLimit{Duration: time.Hour, IsActive: true, Value: 4200})
require.NoError(t, c.run())
require.True(t, limitActive(c.consumptionLimitActivated))
// EG withdraws the limit
c.setConsumptionLimitData(ucapi.LoadLimit{IsActive: false})
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 0)
// a further inactive refresh keeps it released
c.setConsumptionLimitData(ucapi.LoadLimit{IsActive: false})
require.False(t, limitActive(c.consumptionLimitActivated))
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 0)
}

View file

@ -119,6 +119,30 @@ func (c *EEBus) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.E
}
}
// setConsumptionLimitData stores a freshly received LPC limit. eebus-go reports a
// stated duration as the time *remaining* (spine stores an absolute end time), so an
// already-active limit must restart the clock run() measures the duration against.
// Caller holds the mutex.
func (c *EEBus) setConsumptionLimitData(limit ucapi.LoadLimit) {
c.consumptionLimit = limit
c.limitReceived = time.Now()
if limitActive(c.consumptionLimitActivated) {
*c.consumptionLimitActivated = c.limitReceived
}
}
// setProductionLimitData stores a freshly received LPP limit, see setConsumptionLimitData.
// Caller holds the mutex.
func (c *EEBus) setProductionLimitData(limit ucapi.LoadLimit) {
c.productionLimit = limit
c.limitReceived = time.Now()
if limitActive(c.productionLimitActivated) {
*c.productionLimitActivated = c.limitReceived
}
}
func (c *EEBus) updateConsumptionLimit() {
limit, err := c.cs.CsLPCInterface.ConsumptionLimit()
if err != nil {
@ -129,8 +153,7 @@ func (c *EEBus) updateConsumptionLimit() {
c.mux.Lock()
defer c.mux.Unlock()
c.consumptionLimit = limit
c.limitReceived = time.Now()
c.setConsumptionLimitData(limit)
}
func (c *EEBus) updateProductionLimit() {
@ -143,8 +166,7 @@ func (c *EEBus) updateProductionLimit() {
c.mux.Lock()
defer c.mux.Unlock()
c.productionLimit = limit
c.limitReceived = time.Now()
c.setProductionLimitData(limit)
}
func (c *EEBus) consumptionWriteApprovalRequired() {
@ -158,8 +180,7 @@ func (c *EEBus) consumptionWriteApprovalRequired() {
c.cs.CsLPCInterface.ApproveOrDenyConsumptionLimit(msg, true, "")
c.mux.Lock()
c.consumptionLimit = limit
c.limitReceived = time.Now()
c.setConsumptionLimitData(limit)
c.mux.Unlock()
}
}
@ -174,8 +195,7 @@ func (c *EEBus) productionWriteApprovalRequired() {
c.cs.CsLPPInterface.ApproveOrDenyProductionLimit(msg, true, "")
c.mux.Lock()
c.productionLimit = limit
c.limitReceived = time.Now()
c.setProductionLimitData(limit)
c.mux.Unlock()
}
}