256 lines
8.8 KiB
Go
256 lines
8.8 KiB
Go
package eebus
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
ucapi "github.com/enbility/eebus-go/usecases/api"
|
|
"github.com/evcc-io/evcc/core/site"
|
|
"github.com/evcc-io/evcc/hems/hems"
|
|
"github.com/evcc-io/evcc/server/db"
|
|
"github.com/evcc-io/evcc/server/eebus"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
const (
|
|
testFailsafeConsumption = 4200.0
|
|
testFailsafeProduction = 1000.0
|
|
testFailsafeDuration = 2 * time.Hour
|
|
testProductionNominal = 2000.0
|
|
)
|
|
|
|
// stubSite implements site.API for testing — only GetGridPower is exercised;
|
|
// any other call would dereference the nil embedded interface and panic.
|
|
type stubSite struct {
|
|
site.API
|
|
gridPower float64
|
|
}
|
|
|
|
func (s *stubSite) GetGridPower() float64 { return s.gridPower }
|
|
|
|
// newTestEEBus builds a minimally-wired EEBus suitable for exercising run().
|
|
// The CS interfaces are nil — the failsafe-exit path under test does not call
|
|
// them — and smartgrid persistence is backed by an in-memory SQLite database.
|
|
func newTestEEBus(t *testing.T) *EEBus {
|
|
t.Helper()
|
|
|
|
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
|
|
|
|
failsafeProduction := testFailsafeProduction
|
|
return &EEBus{
|
|
ctx: t.Context(),
|
|
log: util.NewLogger("test"),
|
|
interval: time.Millisecond,
|
|
site: &stubSite{},
|
|
Connector: eebus.NewConnector(),
|
|
heartbeat: util.NewValue[struct{}](time.Hour),
|
|
failsafeConsumptionLimit: testFailsafeConsumption,
|
|
failsafeProductionLimit: &failsafeProduction,
|
|
failsafeDuration: testFailsafeDuration,
|
|
productionNominalMax: testProductionNominal,
|
|
}
|
|
}
|
|
|
|
// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS
|
|
// surface. Once run() has executed, MaxConsumptionPower is always known (non-nil).
|
|
func assertConsumptionLimit(t *testing.T, c *EEBus, limit float64) {
|
|
t.Helper()
|
|
power := c.MaxConsumptionPower()
|
|
require.NotNil(t, power)
|
|
assert.Equal(t, limit, *power)
|
|
}
|
|
|
|
// assertProductionLimit checks the HEMS production state through the api.HEMS surface.
|
|
func assertProductionLimit(t *testing.T, c *EEBus, active bool) {
|
|
t.Helper()
|
|
percent := c.CurtailedPercent()
|
|
require.NotNil(t, percent)
|
|
assert.Equal(t, active, *percent < 100)
|
|
}
|
|
|
|
// TestEEBusNoLimitContract verifies api.HEMS's "nil = limiting undefined" contract:
|
|
// nil until the controlbox/EnergyGuard first connects, then 0 = no limit.
|
|
func TestEEBusNoLimitContract(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
|
|
require.Nil(t, c.MaxConsumptionPower())
|
|
require.Nil(t, c.MaxProductionPower())
|
|
|
|
c.Connect(true)
|
|
|
|
assertConsumptionLimit(t, c, 0)
|
|
assertProductionLimit(t, c, false)
|
|
}
|
|
|
|
// TestRun_HeartbeatLost_EntersFailsafe verifies the LPC-911/LPP-911 transition:
|
|
// a missing heartbeat in the normal state must apply the configured failsafe
|
|
// consumption and production limits.
|
|
func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
// heartbeat never Set -> Get() returns ErrTimeout
|
|
|
|
require.NoError(t, c.run())
|
|
assert.Equal(t, StatusFailsafe, c.status)
|
|
assertConsumptionLimit(t, c, testFailsafeConsumption)
|
|
assertProductionLimit(t, c, true)
|
|
}
|
|
|
|
// TestRun_FailsafeStaysOnMissingHeartbeat is the LPC-921/LPP-921 fix: when the
|
|
// heartbeat is still missing the CS keeps applying the failsafe limit (the
|
|
// self-determined protective default for Unlimited-autonomous) and does not
|
|
// transition to a no-limit state. The previous implementation transitioned to
|
|
// StatusNormal with limit=0 once failsafeDuration elapsed, leaving the system
|
|
// unprotected until heartbeat returned.
|
|
func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.status = StatusFailsafe
|
|
// statusUpdated set in the past beyond failsafeDuration to verify we do not
|
|
// exit failsafe based on the duration alone.
|
|
c.statusUpdated = time.Now().Add(-2 * testFailsafeDuration)
|
|
// heartbeat missing.
|
|
|
|
require.NoError(t, c.run())
|
|
assert.Equal(t, StatusFailsafe, c.status, "must stay in failsafe when heartbeat is still missing")
|
|
}
|
|
|
|
// TestRun_HeartbeatReturned_AppliesFreshLimit covers LPC-918/919/920: when
|
|
// heartbeat is restored and an EG limit is pending, evcc must leave failsafe
|
|
// immediately and apply the freshly received limit. The previous code waited
|
|
// for failsafeDuration to elapse and then dropped to a zero limit, ignoring
|
|
// the fresh value.
|
|
func TestRun_HeartbeatReturned_AppliesFreshLimit(t *testing.T) {
|
|
const freshLimit = 3000.0
|
|
|
|
c := newTestEEBus(t)
|
|
c.status = StatusFailsafe
|
|
c.statusUpdated = time.Now() // well within failsafeDuration
|
|
c.heartbeat.Set(struct{}{})
|
|
c.consumptionLimit = ucapi.LoadLimit{Value: freshLimit, IsActive: true}
|
|
|
|
require.NoError(t, c.run())
|
|
assert.Equal(t, StatusNormal, c.status)
|
|
// Final state is the fresh limit (the LPC-914/1 block re-applies after the release).
|
|
assertConsumptionLimit(t, c, freshLimit)
|
|
assertProductionLimit(t, c, false)
|
|
}
|
|
|
|
// TestRun_HeartbeatReturned_NoFreshLimit covers the LPC-918 release case:
|
|
// heartbeat restored but EG has no active limit pending -> exit to normal,
|
|
// no limit applied.
|
|
func TestRun_HeartbeatReturned_NoFreshLimit(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.status = StatusFailsafe
|
|
c.heartbeat.Set(struct{}{})
|
|
c.consumptionLimit = ucapi.LoadLimit{IsActive: false}
|
|
|
|
require.NoError(t, c.run())
|
|
assert.Equal(t, StatusNormal, c.status)
|
|
assertConsumptionLimit(t, c, 0)
|
|
assertProductionLimit(t, c, false)
|
|
}
|
|
|
|
// TestRun_ProductionLimitReleasedEarly verifies that an active production limit
|
|
// is released as soon as the EG deactivates it (IsActive=false), without waiting
|
|
// for its duration to elapse. The previous code only released on duration expiry,
|
|
// so unchecking "Activate" in the control box had no effect until the timer ran
|
|
// out (see PR #30284 report).
|
|
func TestRun_ProductionLimitReleasedEarly(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.heartbeat.Set(struct{}{})
|
|
|
|
// EG activates a production limit with a long duration.
|
|
c.productionLimit = ucapi.LoadLimit{IsActive: true, Duration: time.Hour}
|
|
require.NoError(t, c.run())
|
|
assertProductionLimit(t, c, true)
|
|
|
|
// EG deactivates well within the duration -> must release immediately.
|
|
c.productionLimit.IsActive = false
|
|
require.NoError(t, c.run())
|
|
assertProductionLimit(t, c, false)
|
|
}
|
|
|
|
// TestNotConnected verifies that all api.HEMS getters make no statement while
|
|
// no upstream controlbox has connected yet.
|
|
func TestNotConnected(t *testing.T) {
|
|
c := newTestEEBus(t) // Connector never connected
|
|
|
|
assert.Nil(t, c.MaxConsumptionPower())
|
|
assert.Nil(t, c.MaxProductionPower())
|
|
assert.Nil(t, c.CurtailedPercent())
|
|
assert.Nil(t, hems.Dimmed(c))
|
|
assert.Nil(t, hems.Curtailed(c))
|
|
}
|
|
|
|
// TestRun_ProductionLimitWithoutNominalMax verifies an incoming LPP limit
|
|
// errors instead of being silently ignored when productionNominalMax is unset (#31469).
|
|
func TestRun_ProductionLimitWithoutNominalMax(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.heartbeat.Set(struct{}{})
|
|
c.productionNominalMax = 0
|
|
|
|
c.productionLimit = ucapi.LoadLimit{Value: -2200, IsActive: true, Duration: time.Hour}
|
|
require.Error(t, c.run())
|
|
assert.Nil(t, c.CurtailedPercent())
|
|
}
|
|
|
|
// TestRun_ConsumptionLimitReleasedEarly is the LPC mirror of the LPP early-release
|
|
// case: an active consumption limit must drop as soon as the EG deactivates it.
|
|
func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.heartbeat.Set(struct{}{})
|
|
|
|
// EG activates a consumption limit with a long duration.
|
|
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true, Duration: time.Hour}
|
|
require.NoError(t, c.run())
|
|
assertConsumptionLimit(t, c, 3000)
|
|
|
|
// EG deactivates well within the duration -> must release immediately.
|
|
c.consumptionLimit.IsActive = false
|
|
require.NoError(t, c.run())
|
|
assertConsumptionLimit(t, c, 0)
|
|
}
|
|
|
|
// TestEEBusEdgeTriggered verifies that applying a limit (passthrough) only
|
|
// happens on a genuine transition, not on every steady-state run().
|
|
func TestEEBusEdgeTriggered(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.heartbeat.Set(struct{}{})
|
|
|
|
calls := 0
|
|
c.passthrough = func(bool) error { calls++; return nil }
|
|
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true, Duration: time.Hour}
|
|
|
|
require.NoError(t, c.run())
|
|
require.NoError(t, c.run())
|
|
require.NoError(t, c.run())
|
|
|
|
require.Equal(t, 1, calls, "passthrough must fire once on the edge, not every tick")
|
|
assertConsumptionLimit(t, c, 3000)
|
|
}
|
|
|
|
// TestRunAborts verifies the run loop terminates when the device context is
|
|
// cancelled instead of ticking for the lifetime of the process.
|
|
func TestRunAborts(t *testing.T) {
|
|
c := newTestEEBus(t)
|
|
c.interval = time.Hour // only the context can end the loop
|
|
|
|
ctx, cancel := context.WithCancel(t.Context())
|
|
c.ctx = ctx
|
|
|
|
done := make(chan struct{})
|
|
go func() {
|
|
c.Run()
|
|
close(done)
|
|
}()
|
|
|
|
cancel()
|
|
|
|
select {
|
|
case <-done:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("Run did not return on cancelled context")
|
|
}
|
|
}
|