From 12c6310b344f9f87edcbd0778743e8b6444101c8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:27:12 +0000 Subject: [PATCH 1/3] HEMS: extend failsafe coverage --- .../Config/defaultYaml/customHems.yaml | 2 + hems/fnn/fnn.go | 160 ++++++++++++++---- hems/fnn/fnn_test.go | 124 ++++++++++++-- hems/relay/relay.go | 73 ++++++-- hems/relay/relay_test.go | 85 ++++++++++ templates/definition/hems/fnn-gpio.yaml | 38 +++++ templates/definition/hems/hemspro-gpio.yaml | 38 +++++ 7 files changed, 452 insertions(+), 68 deletions(-) diff --git a/assets/js/components/Config/defaultYaml/customHems.yaml b/assets/js/components/Config/defaultYaml/customHems.yaml index 9c361cfedee..86c26f4eab2 100644 --- a/assets/js/components/Config/defaultYaml/customHems.yaml +++ b/assets/js/components/Config/defaultYaml/customHems.yaml @@ -1,6 +1,8 @@ ## external limit via relay contact (binary on/off) type: relay maxpower: 4200 # total load limit while signal is active (W) +failsafeconsumptionactivepowerlimit: 4200 # fallback limit if reading the input fails (W) +#failsafedurationminimum: 30m # keep the failsafe limit active for at least this long limit: # input signal, plugin source: const value: false # 0/false = normal, 1/true = limit active diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index acfdf824538..e8c71d94ab9 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -32,6 +32,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F W4 *plugin.Config Interval time.Duration FailsafeConsumptionActivePowerLimit float64 + FailsafeProductionActivePowerLimit float64 FailsafeDurationMinimum time.Duration }{ Interval: 10 * time.Second, @@ -71,27 +72,40 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F maxCurtailPower = cc.MaxPower } - return NewFnn(site, math.Abs(cc.MaxDimPower), maxCurtailPower, w3G, s1G, s2G, w4G, cc.Interval, math.Abs(cc.FailsafeConsumptionActivePowerLimit), cc.FailsafeDurationMinimum) + return NewFnn( + site, + math.Abs(cc.MaxDimPower), + maxCurtailPower, + w3G, s1G, s2G, w4G, + cc.Interval, + math.Abs(cc.FailsafeConsumptionActivePowerLimit), + math.Abs(cc.FailsafeProductionActivePowerLimit), + cc.FailsafeDurationMinimum, + ) } -func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, w4G func() (bool, error), interval time.Duration, failsafeConsumptionLimit float64, failsafeDurationMinimum time.Duration) (*Fnn, error) { +func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, w4G func() (bool, error), interval time.Duration, failsafeConsumptionLimit, failsafeProductionLimit float64, failsafeDurationMinimum time.Duration) (*Fnn, error) { if w4G != nil && maxDimPower == 0 { return nil, errors.New("cannot have w4 without power limit") } + if failsafeDurationMinimum < 0 { + return nil, errors.New("failsafe duration cannot be negative") + } c := &Fnn{ - log: util.NewLogger("fnn"), - site: site, - maxDimPower: maxDimPower, - maxCurtailPower: maxCurtailPower, - s1: s1G, - s2: s2G, - w3: w3G, - w4: w4G, - productionPercent: 100, - interval: interval, - failsafeConsumptionLimit: failsafeConsumptionLimit, - failsafeDurationMinimum: failsafeDurationMinimum, + log: util.NewLogger("fnn"), + site: site, + maxDimPower: maxDimPower, + maxCurtailPower: maxCurtailPower, + s1: s1G, + s2: s2G, + w3: w3G, + w4: w4G, + productionPercent: 100, + interval: interval, + failsafeConsumptionLimit: failsafeConsumptionLimit, + failsafeProductionLimit: failsafeProductionLimit, + failsafeDurationMinimum: failsafeDurationMinimum, } // read the relays once synchronously so limits are valid as soon as NewFnn returns @@ -122,14 +136,18 @@ type Fnn struct { smartgridProductionID uint consumptionLimit *float64 + productionLimit *float64 productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed interval time.Duration - failsafeConsumptionLimit float64 - failsafeDurationMinimum time.Duration - failsafeActive bool - failsafeEnteredAt time.Time + failsafeConsumptionLimit float64 + failsafeProductionLimit float64 + failsafeDurationMinimum time.Duration + consumptionFailsafeActive bool + productionFailsafeActive bool + consumptionFailsafeSince time.Time + productionFailsafeSince time.Time } func (c *Fnn) SetUpdated(f func()) { @@ -171,6 +189,11 @@ func (c *Fnn) runCurtail() error { {get: c.s1, percent: 60}, } + states := make([]struct { + percent int + active bool + }, 0, len(rules)) + for _, rule := range rules { if rule.get == nil { continue @@ -178,16 +201,52 @@ func (c *Fnn) runCurtail() error { active, err := rule.get() if err != nil { - return err + if c.failsafeProductionLimit <= 0 { + return err + } + c.mu.Lock() + if !c.productionFailsafeActive { + c.log.WARN.Printf("curtail read error, entering failsafe mode: %v", err) + c.productionFailsafeActive = true + c.productionFailsafeSince = time.Now() + } + c.mu.Unlock() + return c.setProductionPowerLimit(c.failsafeProductionLimit) } - if active { - return c.setProductionLimit(rule.percent) + states = append(states, struct { + percent int + active bool + }{ + percent: rule.percent, + active: active, + }) + } + + c.mu.Lock() + inFailsafe := false + if c.productionFailsafeActive { + if time.Since(c.productionFailsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving production failsafe mode") + c.productionFailsafeActive = false + } else { + inFailsafe = true + } + } + c.mu.Unlock() + + if inFailsafe { + return nil + } + + for _, state := range states { + if state.active { + return c.setProductionPercent(state.percent) } } // 100% - return c.setProductionLimit(100) + return c.setProductionPercent(100) } // runDim evaluates the dimming rule and applies the dim limit. @@ -205,10 +264,10 @@ func (c *Fnn) runDim() error { return err } c.mu.Lock() - if !c.failsafeActive { - c.log.WARN.Println("w4 read error, entering failsafe mode") - c.failsafeActive = true - c.failsafeEnteredAt = time.Now() + if !c.consumptionFailsafeActive { + c.log.WARN.Printf("w4 read error, entering failsafe mode: %v", err) + c.consumptionFailsafeActive = true + c.consumptionFailsafeSince = time.Now() } c.mu.Unlock() return c.setConsumptionLimit(c.failsafeConsumptionLimit) @@ -216,10 +275,10 @@ func (c *Fnn) runDim() error { c.mu.Lock() inFailsafe := false - if c.failsafeActive { - if time.Since(c.failsafeEnteredAt) >= c.failsafeDurationMinimum { - c.log.DEBUG.Println("leaving failsafe mode") - c.failsafeActive = false + if c.consumptionFailsafeActive { + if time.Since(c.consumptionFailsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving consumption failsafe mode") + c.consumptionFailsafeActive = false } else { inFailsafe = true } @@ -238,17 +297,40 @@ func (c *Fnn) runDim() error { return c.setConsumptionLimit(limit) } -// setProductionLimit applies the curtailment limit. -func (c *Fnn) setProductionLimit(percent int) error { +// setProductionPercent applies the curtailment limit. +func (c *Fnn) setProductionPercent(percent int) error { + active := percent < 100 + limit := 0.0 + if active { + limit = float64(percent) / 100 * c.maxCurtailPower + } + + return c.setProductionState(percent, limit, active) +} + +func (c *Fnn) setProductionPowerLimit(limit float64) error { + percent := 100 + active := limit > 0 + if active { + if c.maxCurtailPower > 0 { + percent = int(math.Round(limit / c.maxCurtailPower * 100)) + percent = max(0, min(100, percent)) + } else { + percent = 0 + } + } + + return c.setProductionState(percent, limit, active) +} + +func (c *Fnn) setProductionState(percent int, limit float64, active bool) error { c.mu.Lock() defer c.mu.Unlock() - active := percent < 100 c.productionPercent = percent - - limit := 0.0 + c.productionLimit = nil if active { - limit = float64(percent) / 100 * c.maxCurtailPower + c.productionLimit = new(limit) } if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil { @@ -316,5 +398,9 @@ func (c *Fnn) MaxProductionPower() *float64 { return new(0.0) } - return new(float64(c.productionPercent) / 100 * c.maxCurtailPower) + if c.productionLimit == nil { + return new(0.0) + } + + return new(*c.productionLimit) } diff --git a/hems/fnn/fnn_test.go b/hems/fnn/fnn_test.go index 96605b5b4de..c4ee6538bfc 100644 --- a/hems/fnn/fnn_test.go +++ b/hems/fnn/fnn_test.go @@ -33,7 +33,7 @@ func errG() func() (bool, error) { func TestCurtailmentNotConfigured(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) - fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(true), 0, 0, 0) + fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(true), 0, 0, 0, 0) require.NoError(t, err) assert.Nil(t, fnn.CurtailedPercent()) @@ -47,7 +47,7 @@ func TestCurtailmentNotConfigured(t *testing.T) { // TestDimmingNotConfigured verifies that without W4 no dimming statement is // made, while curtailment via W3 remains available. func TestDimmingNotConfigured(t *testing.T) { - fnn, err := NewFnn(&stubSite{}, 0, 1e3, boolG(false), nil, nil, nil, 0, 0, 0) + fnn, err := NewFnn(&stubSite{}, 0, 1e3, boolG(false), nil, nil, nil, 0, 0, 0, 0) require.NoError(t, err) assert.Nil(t, fnn.MaxConsumptionPower()) @@ -64,8 +64,14 @@ func TestDecodeConfig(t *testing.T) { other := map[string]any{ "maxDimPower": 4200, + "maxCurtailPower": 10000, "failsafeConsumptionActivePowerLimit": 4200, + "failsafeProductionActivePowerLimit": 2500, "failsafeDurationMinimum": "30m", + "w3": map[string]any{ + "source": "const", + "value": false, + }, "w4": map[string]any{ "source": "const", "value": false, @@ -75,6 +81,7 @@ func TestDecodeConfig(t *testing.T) { f, err := NewFromConfig(t.Context(), other, &stubSite{}) require.NoError(t, err) assert.Equal(t, 4200.0, f.failsafeConsumptionLimit) + assert.Equal(t, 2500.0, f.failsafeProductionLimit) assert.Equal(t, 30*time.Minute, f.failsafeDurationMinimum) } @@ -84,11 +91,12 @@ func TestFailsafeActivatesOnReadError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, 0) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, 0) require.NoError(t, err) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) - // construction calls runDim once — failsafe should already be active - assert.True(t, fnn.failsafeActive) + assert.True(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, failsafeLimit, *fnn.MaxConsumptionPower()) } @@ -99,16 +107,16 @@ func TestFailsafeExitsAfterDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - // duration of 0 means failsafe exits on next successful read - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, 0) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, 0) require.NoError(t, err) - assert.True(t, fnn.failsafeActive) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) + assert.True(t, fnn.consumptionFailsafeActive) - // switch to successful read fnn.w4 = boolG(false) require.NoError(t, fnn.runDim()) - assert.False(t, fnn.failsafeActive) + assert.False(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, 0.0, *fnn.MaxConsumptionPower()) } @@ -119,25 +127,105 @@ func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, errG(), 0, failsafeLimit, time.Hour) + fnn, err := NewFnn(&stubSite{}, failsafeLimit, 0, nil, nil, nil, boolG(false), 0, failsafeLimit, 0, time.Hour) require.NoError(t, err) - assert.True(t, fnn.failsafeActive) + fnn.w4 = errG() + require.NoError(t, fnn.runDim()) + assert.True(t, fnn.consumptionFailsafeActive) - // switch to successful read, but duration not yet elapsed fnn.w4 = boolG(false) require.NoError(t, fnn.runDim()) - assert.True(t, fnn.failsafeActive) + assert.True(t, fnn.consumptionFailsafeActive) require.NotNil(t, fnn.MaxConsumptionPower()) assert.Equal(t, failsafeLimit, *fnn.MaxConsumptionPower()) } // TestFailsafeNotConfiguredPropagatesError verifies that without a configured -// failsafe limit, a W4 read error is returned to the caller unchanged. +// failsafe limit, runDim returns the original W4 read error. func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) - // no failsafe limit: construction error because runDim is called in NewFnn - _, err := NewFnn(&stubSite{}, 1e3, 0, nil, nil, nil, errG(), 0, 0, 0) - assert.Error(t, err) + fnn, err := NewFnn(&stubSite{}, 1e3, 0, nil, nil, nil, boolG(false), 0, 0, 0, 0) + require.NoError(t, err) + + want := errors.New("w4 read error") + fnn.w4 = func() (bool, error) { return false, want } + + assert.ErrorIs(t, fnn.runDim(), want) +} + +// TestProductionFailsafeActivatesOnReadError verifies that a curtailment input +// read error triggers the production failsafe and applies the configured limit. +func TestProductionFailsafeActivatesOnReadError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, 0) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + + assert.True(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, failsafeLimit, *fnn.MaxProductionPower()) + assert.Equal(t, 25, *fnn.CurtailedPercent()) +} + +// TestProductionFailsafeExitsAfterDuration verifies that the production +// failsafe clears once the minimum duration has elapsed and reads succeed. +func TestProductionFailsafeExitsAfterDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, 0) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + assert.True(t, fnn.productionFailsafeActive) + + fnn.w3 = boolG(false) + require.NoError(t, fnn.runCurtail()) + + assert.False(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, 0.0, *fnn.MaxProductionPower()) + assert.Equal(t, 100, *fnn.CurtailedPercent()) +} + +// TestProductionFailsafeRemainsActiveDuringDuration verifies that production +// failsafe stays active until the minimum duration has elapsed. +func TestProductionFailsafeRemainsActiveDuringDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const ( + maxCurtailPower = 10000.0 + failsafeLimit = 2500.0 + ) + + fnn, err := NewFnn(&stubSite{}, 0, maxCurtailPower, boolG(false), nil, nil, nil, 0, 0, failsafeLimit, time.Hour) + require.NoError(t, err) + fnn.w3 = errG() + require.NoError(t, fnn.runCurtail()) + assert.True(t, fnn.productionFailsafeActive) + + fnn.w3 = boolG(false) + require.NoError(t, fnn.runCurtail()) + + assert.True(t, fnn.productionFailsafeActive) + require.NotNil(t, fnn.MaxProductionPower()) + assert.Equal(t, failsafeLimit, *fnn.MaxProductionPower()) +} + +func TestFailsafeNegativeDurationRejected(t *testing.T) { + _, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(false), 0, 0, 0, -time.Second) + assert.ErrorContains(t, err, "failsafe duration cannot be negative") } diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 3ace1e15234..7573ae30dd3 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "math" "sync" "time" @@ -32,15 +33,22 @@ type Relay struct { limit *float64 maxPower float64 interval time.Duration + + failsafeConsumptionLimit float64 + failsafeDurationMinimum time.Duration + failsafeActive bool + failsafeSince time.Time } // NewFromConfig creates an Relay HEMS from generic config func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Relay, error) { cc := struct { - MaxPower float64 - Limit plugin.Config - Passthrough *plugin.Config - Interval time.Duration + MaxPower float64 + Limit plugin.Config + Passthrough *plugin.Config + Interval time.Duration + FailsafeConsumptionActivePowerLimit float64 + FailsafeDurationMinimum time.Duration }{ Interval: 10 * time.Second, } @@ -60,23 +68,36 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R return nil, err } - return NewRelay(site, limitG, passthroughS, cc.MaxPower, cc.Interval) + return NewRelay( + site, + limitG, + passthroughS, + cc.MaxPower, + cc.Interval, + math.Abs(cc.FailsafeConsumptionActivePowerLimit), + cc.FailsafeDurationMinimum, + ) } // NewRelay creates Relay HEMS -func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration) (*Relay, error) { +func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) error, maxPower float64, interval time.Duration, failsafeConsumptionLimit float64, failsafeDurationMinimum time.Duration) (*Relay, error) { c := &Relay{ - log: util.NewLogger("relay"), - site: site, - passthrough: passthrough, - maxPower: maxPower, - w1: w1, - interval: interval, + log: util.NewLogger("relay"), + site: site, + passthrough: passthrough, + maxPower: maxPower, + w1: w1, + interval: interval, + failsafeConsumptionLimit: failsafeConsumptionLimit, + failsafeDurationMinimum: failsafeDurationMinimum, } if maxPower == 0 { return nil, errors.New("missing power limit") } + if failsafeDurationMinimum < 0 { + return nil, errors.New("failsafe duration cannot be negative") + } // read the relay once synchronously so the limit is valid as soon as NewRelay returns if err := c.run(); err != nil { @@ -108,7 +129,33 @@ func (c *Relay) Run() { func (c *Relay) run() error { active, err := c.w1() if err != nil { - return err + if c.failsafeConsumptionLimit <= 0 { + return err + } + c.mu.Lock() + if !c.failsafeActive { + c.log.WARN.Printf("limit read error, entering failsafe mode: %v", err) + c.failsafeActive = true + c.failsafeSince = time.Now() + } + c.mu.Unlock() + return c.setConsumptionLimit(c.failsafeConsumptionLimit) + } + + c.mu.Lock() + inFailsafe := false + if c.failsafeActive { + if time.Since(c.failsafeSince) >= c.failsafeDurationMinimum { + c.log.DEBUG.Println("leaving failsafe mode") + c.failsafeActive = false + } else { + inFailsafe = true + } + } + c.mu.Unlock() + + if inFailsafe { + return nil } var limit float64 diff --git a/hems/relay/relay_test.go b/hems/relay/relay_test.go index dad5c53aecc..aaa40da9a5d 100644 --- a/hems/relay/relay_test.go +++ b/hems/relay/relay_test.go @@ -1,13 +1,31 @@ package relay import ( + "errors" "testing" + "time" + "github.com/evcc-io/evcc/core/site" "github.com/evcc-io/evcc/hems/hems" + "github.com/evcc-io/evcc/server/db" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +type stubSite struct { + site.API +} + +func (s *stubSite) GetGridPower() float64 { return 0 } + +func boolG(v bool) func() (bool, error) { + return func() (bool, error) { return v, nil } +} + +func errG() func() (bool, error) { + return func() (bool, error) { return false, errors.New("read error") } +} + // TestCurtailmentNotConfigured verifies that relay never makes a curtailment // statement, and that an active relay dims to maxPower. func TestCurtailmentNotConfigured(t *testing.T) { @@ -28,3 +46,70 @@ func TestCurtailmentNotConfigured(t *testing.T) { assert.Nil(t, c.CurtailedPercent()) assert.Nil(t, hems.Curtailed(c)) } + +func TestFailsafeActivatesOnReadError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + + assert.True(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, failsafeLimit, *c.MaxConsumptionPower()) +} + +func TestFailsafeExitsAfterDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + assert.True(t, c.failsafeActive) + + c.w1 = boolG(false) + require.NoError(t, c.run()) + + assert.False(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, 0.0, *c.MaxConsumptionPower()) +} + +func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + const failsafeLimit = 4200.0 + c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, time.Hour) + require.NoError(t, err) + c.w1 = errG() + require.NoError(t, c.run()) + assert.True(t, c.failsafeActive) + + c.w1 = boolG(false) + require.NoError(t, c.run()) + + assert.True(t, c.failsafeActive) + require.NotNil(t, c.MaxConsumptionPower()) + assert.Equal(t, failsafeLimit, *c.MaxConsumptionPower()) +} + +func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, 0, 0) + require.NoError(t, err) + + want := errors.New("limit read error") + c.w1 = func() (bool, error) { return false, want } + + assert.ErrorIs(t, c.run(), want) +} + +func TestFailsafeNegativeDurationRejected(t *testing.T) { + _, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, 0, -time.Second) + assert.ErrorContains(t, err, "failsafe duration cannot be negative") +} diff --git a/templates/definition/hems/fnn-gpio.yaml b/templates/definition/hems/fnn-gpio.yaml index a92448de31a..5315d3a2536 100644 --- a/templates/definition/hems/fnn-gpio.yaml +++ b/templates/definition/hems/fnn-gpio.yaml @@ -22,6 +22,16 @@ params: help: en: Input pin for the W4 signal (Dimmer for controllable loads) de: Eingangspin für das W4-Signal (Dimmen der steuerbaren Verbrauchseinrichtungen) + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit + de: Failsafe-Bezugsgrenze + help: + en: Consumption limit applied if reading W4 fails. + de: Bezugsgrenze, die angewendet wird, wenn W4 nicht gelesen werden kann. - name: maxproductionpower type: float unit: W @@ -57,14 +67,42 @@ params: help: en: Input pin for the S2 signal (30% export power limit) de: Eingangspin für das S2-Signal (Reduzierung der Einspeiseleistung auf 30%) + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit + de: Failsafe-Einspeisebegrenzung + help: + en: Feed-in limit applied if reading the curtailment inputs fails. + de: Einspeisebegrenzung, die angewendet wird, wenn die Curtailment-Eingänge nicht gelesen werden können. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. render: | type: fnn maxdimpower: {{ .maxconsumptionpower }} + {{- if .failsafeconsumptionpower }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + {{- end }} w4: source: gpio pin: {{ .w4pin }} function: read maxcurtailpower: {{ .maxproductionpower }} + {{- if .failsafeproductionpower }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + {{- end }} + {{- if .failsafeduration }} + failsafedurationminimum: {{ .failsafeduration }} + {{- end }} w3: source: gpio pin: {{ .w3pin }} diff --git a/templates/definition/hems/hemspro-gpio.yaml b/templates/definition/hems/hemspro-gpio.yaml index 93897afda82..67aaac02c56 100644 --- a/templates/definition/hems/hemspro-gpio.yaml +++ b/templates/definition/hems/hemspro-gpio.yaml @@ -24,14 +24,52 @@ params: help: en: Rated generator/module power (Wp) of the installation. de: Gesamtnennleistung bzw. Modul- oder Generatorleistung der Anlage (Bezugsgröße nach § 9 EEG). + - name: failsafeconsumptionpower + type: float + unit: W + advanced: true + description: + en: Failsafe consumption limit + de: Failsafe-Bezugsgrenze + help: + en: Consumption limit applied if reading W4 fails. + de: Bezugsgrenze, die angewendet wird, wenn W4 nicht gelesen werden kann. + - name: failsafeproductionpower + type: float + unit: W + advanced: true + description: + en: Failsafe production limit + de: Failsafe-Einspeisebegrenzung + help: + en: Feed-in limit applied if reading the curtailment inputs fails. + de: Einspeisebegrenzung, die angewendet wird, wenn die Curtailment-Eingänge nicht gelesen werden können. + - name: failsafeduration + type: duration + advanced: true + description: + en: Failsafe minimum duration + de: Failsafe-Mindestdauer + help: + en: Minimum time the failsafe limit stays active once entered. + de: Mindestdauer, für die der Failsafe-Wert nach Aktivierung gehalten wird. render: | type: fnn maxdimpower: {{ .maxconsumptionpower }} + {{- if .failsafeconsumptionpower }} + failsafeconsumptionactivepowerlimit: {{ .failsafeconsumptionpower }} + {{- end }} w4: source: gpio pin: 13 function: read maxcurtailpower: {{ .maxproductionpower }} + {{- if .failsafeproductionpower }} + failsafeproductionactivepowerlimit: {{ .failsafeproductionpower }} + {{- end }} + {{- if .failsafeduration }} + failsafedurationminimum: {{ .failsafeduration }} + {{- end }} w3: source: gpio pin: 5 From 25ecbf89a4b19858acff98535b55bffb3f19b786 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:30:02 +0000 Subject: [PATCH 2/3] HEMS: refine failsafe helpers --- hems/fnn/fnn.go | 58 +++++++++++++++++++++++++--------------- hems/relay/relay.go | 6 ++++- hems/relay/relay_test.go | 14 +++++----- 3 files changed, 48 insertions(+), 30 deletions(-) diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index e8c71d94ab9..f1ae13688fb 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -150,6 +150,11 @@ type Fnn struct { productionFailsafeSince time.Time } +type curtailmentState struct { + percent int + active bool +} + func (c *Fnn) SetUpdated(f func()) { c.mu.Lock() defer c.mu.Unlock() @@ -189,10 +194,7 @@ func (c *Fnn) runCurtail() error { {get: c.s1, percent: 60}, } - states := make([]struct { - percent int - active bool - }, 0, len(rules)) + states := make([]curtailmentState, 0, len(rules)) for _, rule := range rules { if rule.get == nil { @@ -201,7 +203,7 @@ func (c *Fnn) runCurtail() error { active, err := rule.get() if err != nil { - if c.failsafeProductionLimit <= 0 { + if !c.hasProductionFailsafe() { return err } c.mu.Lock() @@ -214,10 +216,7 @@ func (c *Fnn) runCurtail() error { return c.setProductionPowerLimit(c.failsafeProductionLimit) } - states = append(states, struct { - percent int - active bool - }{ + states = append(states, curtailmentState{ percent: rule.percent, active: active, }) @@ -260,7 +259,7 @@ func (c *Fnn) runDim() error { active, err := c.w4() if err != nil { - if c.failsafeConsumptionLimit <= 0 { + if !c.hasConsumptionFailsafe() { return err } c.mu.Lock() @@ -300,24 +299,14 @@ func (c *Fnn) runDim() error { // setProductionPercent applies the curtailment limit. func (c *Fnn) setProductionPercent(percent int) error { active := percent < 100 - limit := 0.0 - if active { - limit = float64(percent) / 100 * c.maxCurtailPower - } - - return c.setProductionState(percent, limit, active) + return c.setProductionState(percent, c.percentToProductionLimit(percent), active) } func (c *Fnn) setProductionPowerLimit(limit float64) error { percent := 100 active := limit > 0 if active { - if c.maxCurtailPower > 0 { - percent = int(math.Round(limit / c.maxCurtailPower * 100)) - percent = max(0, min(100, percent)) - } else { - percent = 0 - } + percent = c.productionLimitToPercent(limit) } return c.setProductionState(percent, limit, active) @@ -340,6 +329,31 @@ func (c *Fnn) setProductionState(percent int, limit float64, active bool) error return nil } +func (c *Fnn) hasConsumptionFailsafe() bool { + return c.failsafeConsumptionLimit > 0 +} + +func (c *Fnn) hasProductionFailsafe() bool { + return c.failsafeProductionLimit > 0 +} + +func (c *Fnn) percentToProductionLimit(percent int) float64 { + if percent >= 100 { + return 0 + } + + return float64(percent) / 100 * c.maxCurtailPower +} + +func (c *Fnn) productionLimitToPercent(limit float64) int { + if c.maxCurtailPower <= 0 { + return 0 + } + + percent := int(math.Round(limit / c.maxCurtailPower * 100)) + return max(0, min(100, percent)) +} + // setConsumptionLimit applies the dimming limit. func (c *Fnn) setConsumptionLimit(limit float64) error { c.mu.Lock() diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 7573ae30dd3..54af0da74d8 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -129,7 +129,7 @@ func (c *Relay) Run() { func (c *Relay) run() error { active, err := c.w1() if err != nil { - if c.failsafeConsumptionLimit <= 0 { + if !c.hasFailsafe() { return err } c.mu.Lock() @@ -174,6 +174,10 @@ func (c *Relay) run() error { return nil } +func (c *Relay) hasFailsafe() bool { + return c.failsafeConsumptionLimit > 0 +} + func (c *Relay) setConsumptionLimit(limit float64) error { c.mu.Lock() defer c.mu.Unlock() diff --git a/hems/relay/relay_test.go b/hems/relay/relay_test.go index aaa40da9a5d..b4486b6df18 100644 --- a/hems/relay/relay_test.go +++ b/hems/relay/relay_test.go @@ -12,11 +12,11 @@ import ( "github.com/stretchr/testify/require" ) -type stubSite struct { +type testSiteStub struct { site.API } -func (s *stubSite) GetGridPower() float64 { return 0 } +func (s *testSiteStub) GetGridPower() float64 { return 0 } func boolG(v bool) func() (bool, error) { return func() (bool, error) { return v, nil } @@ -51,7 +51,7 @@ func TestFailsafeActivatesOnReadError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) require.NoError(t, err) c.w1 = errG() require.NoError(t, c.run()) @@ -65,7 +65,7 @@ func TestFailsafeExitsAfterDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, 0) require.NoError(t, err) c.w1 = errG() require.NoError(t, c.run()) @@ -83,7 +83,7 @@ func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) const failsafeLimit = 4200.0 - c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, failsafeLimit, time.Hour) + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, failsafeLimit, time.Hour) require.NoError(t, err) c.w1 = errG() require.NoError(t, c.run()) @@ -100,7 +100,7 @@ func TestFailsafeRemainsActiveDuringDuration(t *testing.T) { func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { require.NoError(t, db.NewInstance("sqlite", ":memory:")) - c, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, 0, 0) + c, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, 0, 0) require.NoError(t, err) want := errors.New("limit read error") @@ -110,6 +110,6 @@ func TestFailsafeNotConfiguredPropagatesError(t *testing.T) { } func TestFailsafeNegativeDurationRejected(t *testing.T) { - _, err := NewRelay(&stubSite{}, boolG(false), nil, 1e3, 0, 0, -time.Second) + _, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, 0, -time.Second) assert.ErrorContains(t, err, "failsafe duration cannot be negative") } From 59a56039f678cafb4aa75fa70d1675b5e620d733 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 19 Jul 2026 09:31:15 +0000 Subject: [PATCH 3/3] HEMS: validate failsafe limits --- hems/fnn/fnn.go | 12 +++++++++--- hems/fnn/fnn_test.go | 14 ++++++++++++++ hems/relay/relay.go | 6 ++++-- hems/relay/relay_test.go | 14 ++++++++++++++ 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index f1ae13688fb..9c67ef7a628 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -41,6 +41,12 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F if err := util.DecodeOther(other, &cc); err != nil { return nil, err } + if cc.FailsafeConsumptionActivePowerLimit < 0 { + return nil, errors.New("failsafe consumption limit cannot be negative") + } + if cc.FailsafeProductionActivePowerLimit < 0 { + return nil, errors.New("failsafe production limit cannot be negative") + } w3G, err := cc.W3.BoolGetter(ctx) if err != nil { @@ -78,8 +84,8 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F maxCurtailPower, w3G, s1G, s2G, w4G, cc.Interval, - math.Abs(cc.FailsafeConsumptionActivePowerLimit), - math.Abs(cc.FailsafeProductionActivePowerLimit), + cc.FailsafeConsumptionActivePowerLimit, + cc.FailsafeProductionActivePowerLimit, cc.FailsafeDurationMinimum, ) } @@ -319,7 +325,7 @@ func (c *Fnn) setProductionState(percent int, limit float64, active bool) error c.productionPercent = percent c.productionLimit = nil if active { - c.productionLimit = new(limit) + c.productionLimit = &limit } if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil { diff --git a/hems/fnn/fnn_test.go b/hems/fnn/fnn_test.go index c4ee6538bfc..1cc934b4c01 100644 --- a/hems/fnn/fnn_test.go +++ b/hems/fnn/fnn_test.go @@ -229,3 +229,17 @@ func TestFailsafeNegativeDurationRejected(t *testing.T) { _, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, boolG(false), 0, 0, 0, -time.Second) assert.ErrorContains(t, err, "failsafe duration cannot be negative") } + +func TestDecodeRejectsNegativeFailsafeLimits(t *testing.T) { + other := map[string]any{ + "maxDimPower": 4200, + "failsafeConsumptionActivePowerLimit": -1, + "w4": map[string]any{ + "source": "const", + "value": false, + }, + } + + _, err := NewFromConfig(t.Context(), other, &stubSite{}) + assert.ErrorContains(t, err, "failsafe consumption limit cannot be negative") +} diff --git a/hems/relay/relay.go b/hems/relay/relay.go index 54af0da74d8..5f844a52a1e 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "math" "sync" "time" @@ -56,6 +55,9 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R if err := util.DecodeOther(other, &cc); err != nil { return nil, err } + if cc.FailsafeConsumptionActivePowerLimit < 0 { + return nil, errors.New("failsafe consumption limit cannot be negative") + } // limit getter limitG, err := cc.Limit.BoolGetter(ctx) @@ -74,7 +76,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R passthroughS, cc.MaxPower, cc.Interval, - math.Abs(cc.FailsafeConsumptionActivePowerLimit), + cc.FailsafeConsumptionActivePowerLimit, cc.FailsafeDurationMinimum, ) } diff --git a/hems/relay/relay_test.go b/hems/relay/relay_test.go index b4486b6df18..cb37c955107 100644 --- a/hems/relay/relay_test.go +++ b/hems/relay/relay_test.go @@ -113,3 +113,17 @@ func TestFailsafeNegativeDurationRejected(t *testing.T) { _, err := NewRelay(&testSiteStub{}, boolG(false), nil, 1e3, 0, 0, -time.Second) assert.ErrorContains(t, err, "failsafe duration cannot be negative") } + +func TestDecodeRejectsNegativeFailsafeLimit(t *testing.T) { + other := map[string]any{ + "maxPower": 4200, + "failsafeConsumptionActivePowerLimit": -1, + "limit": map[string]any{ + "source": "const", + "value": false, + }, + } + + _, err := NewFromConfig(t.Context(), other, &testSiteStub{}) + assert.ErrorContains(t, err, "failsafe consumption limit cannot be negative") +}