diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 4c3f1bdc3..ec94decfc 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -232,6 +232,9 @@ func (c *EEBus) run() error { if c.failsafeProductionLimit != nil { // production limit is negative, failsafe limits are always positive c.setProductionLimit(-*c.failsafeProductionLimit, true) + } else { + // no failsafe limit configured, release any stale Energy Guard limit + c.setProductionLimit(0, false) } return nil @@ -352,6 +355,24 @@ func (c *EEBus) setProductionLimit(limit float64, active bool) { } } +// effectiveConsumptionLimit returns the LPC limit in force: the configured failsafe +// limit while in failsafe, else the Energy Guard's. Caller holds the mutex. +func (c *EEBus) effectiveConsumptionLimit() float64 { + if c.status == StatusFailsafe { + return c.failsafeConsumptionLimit + } + return c.consumptionLimit.Value +} + +// effectiveProductionLimit returns the LPP limit in force as positive watts, see +// effectiveConsumptionLimit. LPP states its limits as negative watts. +func (c *EEBus) effectiveProductionLimit() float64 { + if c.status == StatusFailsafe && c.failsafeProductionLimit != nil { + return *c.failsafeProductionLimit + } + return -c.productionLimit.Value +} + var _ api.HEMS = (*EEBus)(nil) // CurtailedPercent implements api.HEMS, converting the active LPP production @@ -372,8 +393,7 @@ func (c *EEBus) CurtailedPercent() *int { percent := 100 if limitActive(c.productionLimitActivated) { - // production limits are negative watts - percent = int(-c.productionLimit.Value / c.productionNominalMax * 100) + percent = int(c.effectiveProductionLimit() / c.productionNominalMax * 100) } return &percent @@ -390,10 +410,7 @@ func (c *EEBus) MaxConsumptionPower() *float64 { if !limitActive(c.consumptionLimitActivated) { return new(0.0) } - if c.status == StatusFailsafe { - return new(c.failsafeConsumptionLimit) - } - return new(c.consumptionLimit.Value) + return new(c.effectiveConsumptionLimit()) } // MaxProductionPower implements api.HEMS: nil until first connected, @@ -407,9 +424,5 @@ func (c *EEBus) MaxProductionPower() *float64 { if !limitActive(c.productionLimitActivated) { return new(0.0) } - if c.status == StatusFailsafe { - return c.failsafeProductionLimit - } - // production limits are negative watts, the api.HEMS cap is positive - return new(-c.productionLimit.Value) + return new(c.effectiveProductionLimit()) } diff --git a/hems/eebus/eebus_test.go b/hems/eebus/eebus_test.go index 070b1dbd7..7d8274b50 100644 --- a/hems/eebus/eebus_test.go +++ b/hems/eebus/eebus_test.go @@ -280,6 +280,51 @@ func TestRun_LimitWithoutDuration(t *testing.T) { assertProductionLimit(t, c, true) } +// TestProductionLimit_Failsafe verifies both api.HEMS production getters report the +// configured failsafe limit while in failsafe, not the stale EG-supplied limit +// (#33286): the EG limit had already been released (Value=0) before the heartbeat +// was lost, which must not curtail feed-in to 0%. +func TestProductionLimit_Failsafe(t *testing.T) { + c := newTestEEBus(t) + // EG limit already released before the heartbeat is lost. + c.productionLimit = ucapi.LoadLimit{Value: 0, IsActive: false} + + require.NoError(t, c.run()) + require.Equal(t, StatusFailsafe, c.status) + + percent := c.CurtailedPercent() + require.NotNil(t, percent) + assert.Equal(t, int(testFailsafeProduction/testProductionNominal*100), *percent) + + power := c.MaxProductionPower() + require.NotNil(t, power) + assert.Equal(t, testFailsafeProduction, *power) +} + +// TestProductionLimit_FailsafeUnconfigured verifies that entering failsafe without a +// configured failsafe production limit releases a stale EG limit. run() returns early +// while in failsafe, so a limit left applied here would stay applied indefinitely. +func TestProductionLimit_FailsafeUnconfigured(t *testing.T) { + c := newTestEEBus(t) + c.failsafeProductionLimit = nil + c.heartbeat.Set(struct{}{}) + + c.productionLimit = ucapi.LoadLimit{Value: -500, IsActive: true} + require.NoError(t, c.run()) + assertProductionLimit(t, c, true) + + // heartbeat lost + c.heartbeat = util.NewValue[struct{}](time.Hour) + + require.NoError(t, c.run()) + require.Equal(t, StatusFailsafe, c.status) + assertProductionLimit(t, c, false) + + power := c.MaxProductionPower() + require.NotNil(t, power) + assert.Equal(t, 0.0, *power) +} + // TestMaxProductionPower verifies the api.HEMS export cap is a positive wattage, // while LPP states its limits as negative watts. func TestMaxProductionPower(t *testing.T) {