EEBus: keep grid limits applied as the LPC/LPP spec requires (BC) (#32492)

This commit is contained in:
andig 2026-08-03 20:58:11 +02:00 • committed by GitHub
parent fed3fb731c
commit 6c11365bda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 152 additions and 52 deletions

View file

@ -48,10 +48,16 @@ type EEBus struct {
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
productionNominalMax float64
heartbeat *util.Value[struct{}]
interval time.Duration
heartbeat *util.Value[struct{}]
heartbeatReturned time.Time // heartbeat resumed while in failsafe
limitReceived time.Time // last limit written by the Energy Guard
interval time.Duration
}
// failsafeReleaseTimeout is how long the CS keeps the failsafe limit after the
// heartbeat resumed but the Energy Guard has not stated a limit yet ([LPC-921]).
const failsafeReleaseTimeout = 2 * time.Minute
type Limits struct {
ContractualConsumptionNominalMax float64
FailsafeConsumptionActivePowerLimit float64
@ -71,15 +77,6 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E
Interval time.Duration
}{
Limits: Limits{
// contractual max power at the grid connection point reported to the control box
// (EEBus LPC, EMS device type). Default: standard 3x35A x 230V house connection.
// This is the connection capacity, not the SteuVE Pmin (see failsafe limit below).
ContractualConsumptionNominalMax: 24150, // 3 * 35A * 230V
FailsafeConsumptionActivePowerLimit: 4200,
ProductionNominalMax: 0,
FailsafeProductionActivePowerLimit: nil, // 0 is a valid limit
FailsafeDurationMinimum: 2 * time.Hour,
},
Interval: 10 * time.Second,
@ -120,10 +117,6 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
productionNominalMax: limits.ProductionNominalMax,
}
// simulate a received heartbeat
// otherwise a heartbeat timeout is assumed when the state machine is called for the first time
c.heartbeat.Set(struct{}{})
if err := inst.RegisterDevice(ski, "", c); err != nil {
return nil, err
}
@ -138,8 +131,10 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
eebus.LogEntities(c.log.DEBUG, "CS LPP", c.cs.CsLPPInterface)
// set initial values
if err := c.cs.CsLPCInterface.SetConsumptionNominalMax(limits.ContractualConsumptionNominalMax); err != nil {
c.log.ERROR.Println("CS LPC SetConsumptionNominalMax:", err)
if limits.ContractualConsumptionNominalMax > 0 {
if err := c.cs.CsLPCInterface.SetConsumptionNominalMax(limits.ContractualConsumptionNominalMax); err != nil {
c.log.ERROR.Println("CS LPC SetConsumptionNominalMax:", err)
}
}
if c.failsafeConsumptionLimit > 0 {
if err := c.cs.CsLPCInterface.SetFailsafeConsumptionActivePowerLimit(c.failsafeConsumptionLimit, true); err != nil {
@ -196,17 +191,18 @@ func (c *EEBus) Connect(connected bool) {
// Run applies limits until the device context is cancelled
func (c *EEBus) Run() {
// LPC-TS-017: the first run applies the failsafe limit until the Energy Guard states one
for tick := time.Tick(c.interval); ; {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
select {
case <-tick:
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
case <-c.ctx.Done():
return
}
@ -221,6 +217,11 @@ func (c *EEBus) run() error {
_, heartbeatErr := c.heartbeat.Get()
// the LPC-921 release window only runs while the heartbeat is back
if heartbeatErr != nil {
c.heartbeatReturned = time.Time{}
}
// LPC-911 / LPP-911: heartbeat lost while operating, enter failsafe.
if heartbeatErr != nil && c.status != StatusFailsafe {
c.log.WARN.Println("missing heartbeat- entering failsafe mode")
@ -244,10 +245,19 @@ func (c *EEBus) run() error {
return nil
}
// LPC-918/919/920 / LPP-equivalent: heartbeat returned - leave failsafe
// immediately. Fall through to the LPC-914/1 block below, which will
// apply whatever fresh limit the EG sent (or release the limit if the
// EG has not sent an active limit since the failsafe entry).
if c.heartbeatReturned.IsZero() {
c.heartbeatReturned = time.Now()
}
// LPC-916/LPP-916: the failsafe state is left on a heartbeat and a *following*
// limit write. Without one, LPC-921 grants 120s before going unlimited.
if c.limitReceived.Before(c.statusUpdated) && time.Since(c.heartbeatReturned) < failsafeReleaseTimeout {
return nil
}
// LPC-918/919/920 / LPP-equivalent: leave failsafe. Fall through to the
// LPC-914/1 block below, which will apply whatever fresh limit the EG sent
// (or release the limit if the EG has not sent an active limit).
c.log.DEBUG.Println("heartbeat returned- leaving failsafe mode")
c.setStatus(StatusNormal)
@ -266,7 +276,8 @@ func (c *EEBus) run() error {
case !c.consumptionLimit.IsActive:
c.log.DEBUG.Println("consumption limit released")
c.setConsumptionLimit(0)
case time.Since(*c.consumptionLimitActivated) > c.consumptionLimit.Duration:
// a limit stated without duration does not expire
case c.consumptionLimit.Duration > 0 && time.Since(*c.consumptionLimitActivated) > c.consumptionLimit.Duration:
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
@ -288,7 +299,8 @@ func (c *EEBus) run() error {
case !c.productionLimit.IsActive:
c.log.DEBUG.Println("production limit released")
c.setProductionLimit(0, false)
case time.Since(*c.productionLimitActivated) > c.productionLimit.Duration:
// a limit stated without duration does not expire
case c.productionLimit.Duration > 0 && time.Since(*c.productionLimitActivated) > c.productionLimit.Duration:
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
@ -384,8 +396,8 @@ func (c *EEBus) MaxConsumptionPower() *float64 {
return new(c.consumptionLimit.Value)
}
// MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not
// publish a wattage-typed production cap yet.
// MaxProductionPower implements api.HEMS: nil until first connected,
// else failsafe limit in failsafe, else the active EG-supplied LPP limit, else 0.
func (c *EEBus) MaxProductionPower() *float64 {
c.mux.RLock()
defer c.mux.RUnlock()
@ -398,5 +410,6 @@ func (c *EEBus) MaxProductionPower() *float64 {
if c.status == StatusFailsafe {
return c.failsafeProductionLimit
}
return new(c.productionLimit.Value)
// production limits are negative watts, the api.HEMS cap is positive
return new(-c.productionLimit.Value)
}

View file

@ -106,7 +106,7 @@ func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
// unprotected until heartbeat returned.
func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
c := newTestEEBus(t)
c.status = StatusFailsafe
c.setStatus(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)
@ -117,7 +117,7 @@ func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
}
// TestRun_HeartbeatReturned_AppliesFreshLimit covers LPC-918/919/920: when
// heartbeat is restored and an EG limit is pending, evcc must leave failsafe
// heartbeat is restored and the EG has written a limit, 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.
@ -125,10 +125,10 @@ 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.setStatus(StatusFailsafe)
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{Value: freshLimit, IsActive: true}
c.limitReceived = time.Now()
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
@ -137,14 +137,24 @@ func TestRun_HeartbeatReturned_AppliesFreshLimit(t *testing.T) {
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) {
// TestRun_HeartbeatReturned_NoLimitWrite covers LPC-916/LPC-921: a returning heartbeat
// alone does not end failsafe - without a following limit write it is kept for 120s.
func TestRun_HeartbeatReturned_NoLimitWrite(t *testing.T) {
c := newTestEEBus(t)
c.status = StatusFailsafe
// heartbeat missing -> failsafe
require.NoError(t, c.run())
require.Equal(t, StatusFailsafe, c.status)
// heartbeat back, but the EG has not stated a limit
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{IsActive: false}
require.NoError(t, c.run())
assert.Equal(t, StatusFailsafe, c.status, "heartbeat without a following limit must not end failsafe")
assertConsumptionLimit(t, c, testFailsafeConsumption)
// LPC-921: no limit write within 120s -> unlimited
c.heartbeatReturned = time.Now().Add(-2 * failsafeReleaseTimeout)
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
@ -152,6 +162,44 @@ func TestRun_HeartbeatReturned_NoFreshLimit(t *testing.T) {
assertProductionLimit(t, c, false)
}
// TestRun_StartsInFailsafe covers LPC-TS-017: with no Energy Guard heard from yet the
// CS starts out limited to the failsafe limit and leaves once the EG states one.
func TestRun_StartsInFailsafe(t *testing.T) {
c := newTestEEBus(t) // no heartbeat seeded, like a fresh NewEEBus
require.NoError(t, c.run())
require.Equal(t, StatusFailsafe, c.status)
assertConsumptionLimit(t, c, testFailsafeConsumption)
assertProductionLimit(t, c, true)
// the EG shows up and states a limit
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true}
c.limitReceived = time.Now()
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
assertConsumptionLimit(t, c, 3000)
}
// TestRun_FailsafeReentry covers LPC-921 on a second failsafe cycle: a heartbeat
// that returned during an earlier cycle must not end the new one.
func TestRun_FailsafeReentry(t *testing.T) {
c := newTestEEBus(t)
c.heartbeatReturned = time.Now().Add(-2 * failsafeReleaseTimeout) // stale, earlier cycle
// heartbeat missing -> failsafe
require.NoError(t, c.run())
require.Equal(t, StatusFailsafe, c.status)
// heartbeat back without a limit write -> the 120s window restarts
c.heartbeat.Set(struct{}{})
require.NoError(t, c.run())
assert.Equal(t, StatusFailsafe, c.status, "stale heartbeat return must not end the new cycle")
assertConsumptionLimit(t, c, testFailsafeConsumption)
}
// 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,
@ -213,6 +261,39 @@ func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) {
assertConsumptionLimit(t, c, 0)
}
// TestRun_LimitWithoutDuration covers APCL_DUR_02: a limit stated without a duration
// does not expire - eebus-go reports Duration 0 when the EG sends no time period.
func TestRun_LimitWithoutDuration(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true}
c.productionLimit = ucapi.LoadLimit{Value: -1000, IsActive: true}
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 3000)
assertProductionLimit(t, c, true)
// still applied on the following tick
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 3000)
assertProductionLimit(t, c, true)
}
// TestMaxProductionPower verifies the api.HEMS export cap is a positive wattage,
// while LPP states its limits as negative watts.
func TestMaxProductionPower(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
c.productionLimit = ucapi.LoadLimit{Value: -1000, IsActive: true}
require.NoError(t, c.run())
power := c.MaxProductionPower()
require.NotNil(t, power)
assert.Equal(t, 1000.0, *power)
}
// TestEEBusEdgeTriggered verifies that applying a limit (passthrough) only
// happens on a genuine transition, not on every steady-state run().
func TestEEBusEdgeTriggered(t *testing.T) {

View file

@ -1,6 +1,8 @@
package eebus
import (
"time"
eebusapi "github.com/enbility/eebus-go/api"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/enbility/eebus-go/usecases/cs/lpc"
@ -128,6 +130,7 @@ func (c *EEBus) updateConsumptionLimit() {
defer c.mux.Unlock()
c.consumptionLimit = limit
c.limitReceived = time.Now()
}
func (c *EEBus) updateProductionLimit() {
@ -141,6 +144,7 @@ func (c *EEBus) updateProductionLimit() {
defer c.mux.Unlock()
c.productionLimit = limit
c.limitReceived = time.Now()
}
func (c *EEBus) consumptionWriteApprovalRequired() {
@ -155,6 +159,7 @@ func (c *EEBus) consumptionWriteApprovalRequired() {
c.mux.Lock()
c.consumptionLimit = limit
c.limitReceived = time.Now()
c.mux.Unlock()
}
}
@ -170,6 +175,7 @@ func (c *EEBus) productionWriteApprovalRequired() {
c.cs.CsLPPInterface.ApproveOrDenyProductionLimit(msg, true, "")
c.mux.Lock()
c.productionLimit = limit
c.limitReceived = time.Now()
c.mux.Unlock()
}
}

View file

@ -16,8 +16,8 @@ params:
en: Max. consumption power (grid connection)
de: Max. Bezugsleistung (Netzanschluss)
help:
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A).
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A).
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box.
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin).
- name: failsafeconsumptionpower
type: float
unit: W
@ -26,8 +26,8 @@ params:
en: Failsafe consumption limit (Pmin, LPC)
de: Failsafe-Bezugsgrenze der SteuVE (Pmin)
help:
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW)
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG)
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values.
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt.
- name: productionnominalmax
type: float
unit: Wp

View file

@ -17,8 +17,8 @@ params:
en: Max. consumption power (grid connection)
de: Max. Bezugsleistung (Netzanschluss)
help:
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box. Default 24.15 kVA (3x35A).
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin). Standard 24,15 kVA (3x35A).
en: Contractual maximum power of the grid connection point reported to the control box. This is the connection capacity agreed with the grid operator, NOT the minimum consumption power (Pmin) of the control box.
de: Vertragliche Gesamtmaximalleistung des Netzanschlusspunkts, die der Steuerbox gemeldet wird. Dies ist die mit dem Netzbetreiber vereinbarte Anschlusskapazität, NICHT die SteuVE-Mindestleistung (Pmin).
- name: failsafeconsumptionpower
type: float
unit: W
@ -27,8 +27,8 @@ params:
en: Failsafe consumption limit (Pmin, LPC)
de: Failsafe-Bezugsgrenze der SteuVE (Pmin)
help:
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values. (Default 4.2 kW)
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt. (Standard 4,2 kW nach § 14a EnWG)
en: Failsafe minimum consumption power (Pmin) for controllable loads applied when the heartbeat from the control box is lost; it is overwritten by the control box once it transmits its own failsafe values.
de: Verbleibende Mindestbezugsleistung (Pmin) für steuerbare Verbrauchseinrichtungen, die bei ausbleibendem Heartbeat der Steuerbox angewendet wird. Dies ist die per GZF berechnete Mindestbezugsleistung nach BK6-22-300; wird von der Steuerbox überschrieben, sobald diese eigene Failsafe-Werte überträgt.
- name: productionnominalmax
type: float
unit: Wp