From 6c11365bdac3eb7aba68e54342c2459787c0a569 Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 3 Aug 2026 20:58:11 +0200 Subject: [PATCH] EEBus: keep grid limits applied as the LPC/LPP spec requires (BC) (#32492) --- hems/eebus/eebus.go | 81 ++++++++------- hems/eebus/eebus_test.go | 101 +++++++++++++++++-- hems/eebus/events.go | 6 ++ templates/definition/hems/fnn-eebus.yaml | 8 +- templates/definition/hems/hemspro-eebus.yaml | 8 +- 5 files changed, 152 insertions(+), 52 deletions(-) diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 4aa43592f..4c3f1bdc3 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -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) } diff --git a/hems/eebus/eebus_test.go b/hems/eebus/eebus_test.go index da1f44a93..070b1dbd7 100644 --- a/hems/eebus/eebus_test.go +++ b/hems/eebus/eebus_test.go @@ -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) { diff --git a/hems/eebus/events.go b/hems/eebus/events.go index 0cd4f60ab..a0fc3b0ac 100644 --- a/hems/eebus/events.go +++ b/hems/eebus/events.go @@ -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() } } diff --git a/templates/definition/hems/fnn-eebus.yaml b/templates/definition/hems/fnn-eebus.yaml index b3236b406..bb2cd2fc3 100644 --- a/templates/definition/hems/fnn-eebus.yaml +++ b/templates/definition/hems/fnn-eebus.yaml @@ -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 diff --git a/templates/definition/hems/hemspro-eebus.yaml b/templates/definition/hems/hemspro-eebus.yaml index 51691873d..490c44e30 100644 --- a/templates/definition/hems/hemspro-eebus.yaml +++ b/templates/definition/hems/hemspro-eebus.yaml @@ -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