From e239af5d25edd7d79c98b947033a4a01c095dbad Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 18 Jul 2026 10:58:06 +0200 Subject: [PATCH] Simplify HEMS api (#31428) --- api/api.go | 7 ++- api/mock.go | 18 +------ cmd/root.go | 4 +- core/circuit/circuit.go | 6 +-- core/circuit/circuit_test.go | 2 +- core/helper.go | 9 ++-- core/site.go | 12 +++-- core/site_circuit_test.go | 4 +- core/site_circuits.go | 12 ++--- hems/eebus/eebus.go | 93 ++++++++++++++++++++++-------------- hems/eebus/eebus_test.go | 42 ++++++++++++++-- hems/fnn/fnn.go | 57 +++++++++++++--------- hems/fnn/fnn_test.go | 36 ++++++++++++-- hems/hems/globals.go | 22 +++++++++ hems/relay/relay.go | 22 ++++----- hems/relay/relay_test.go | 30 ++++++++++++ 16 files changed, 255 insertions(+), 121 deletions(-) create mode 100644 hems/hems/globals.go create mode 100644 hems/relay/relay_test.go diff --git a/api/api.go b/api/api.go index 0e726d747..ad485e052 100644 --- a/api/api.go +++ b/api/api.go @@ -285,10 +285,9 @@ type Circuit interface { // HEMS exposes the runtime state of the home energy management system. type HEMS interface { SetUpdated(func()) - Dimmed() *bool // nil = no statement - MaxConsumptionPower() float64 // 0 = no limit - CurtailedPercent() *int // allowed feed-in percent of nominal production power (0..100), nil = no statement - MaxProductionPower() *float64 // nil = no limit + CurtailedPercent() *int // allowed feed-in percent of nominal production power (0..100), nil = no statement + MaxProductionPower() *float64 // nil = limiting undefined, else active limit + MaxConsumptionPower() *float64 // nil = limiting undefined, else active limit (0 = none) } // Redactor is an interface to redact sensitive data diff --git a/api/mock.go b/api/mock.go index 4d774dbe4..22470c2e3 100644 --- a/api/mock.go +++ b/api/mock.go @@ -1226,25 +1226,11 @@ func (mr *MockHEMSMockRecorder) CurtailedPercent() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CurtailedPercent", reflect.TypeOf((*MockHEMS)(nil).CurtailedPercent)) } -// Dimmed mocks base method. -func (m *MockHEMS) Dimmed() *bool { - m.ctrl.T.Helper() - ret := m.ctrl.Call(m, "Dimmed") - ret0, _ := ret[0].(*bool) - return ret0 -} - -// Dimmed indicates an expected call of Dimmed. -func (mr *MockHEMSMockRecorder) Dimmed() *gomock.Call { - mr.mock.ctrl.T.Helper() - return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Dimmed", reflect.TypeOf((*MockHEMS)(nil).Dimmed)) -} - // MaxConsumptionPower mocks base method. -func (m *MockHEMS) MaxConsumptionPower() float64 { +func (m *MockHEMS) MaxConsumptionPower() *float64 { m.ctrl.T.Helper() ret := m.ctrl.Call(m, "MaxConsumptionPower") - ret0, _ := ret[0].(float64) + ret0, _ := ret[0].(*float64) return ret0 } diff --git a/cmd/root.go b/cmd/root.go index 42029c32e..54fe77e39 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -370,10 +370,10 @@ func runRoot(cmd *cobra.Command, args []string) { Status: struct { Dimmed *bool `json:"dimmed,omitempty"` Curtailed *int `json:"curtailed,omitempty"` - MaxConsumptionPower float64 `json:"maxConsumptionPower,omitempty"` + MaxConsumptionPower *float64 `json:"maxConsumptionPower,omitempty"` MaxProductionPower *float64 `json:"maxProductionPower,omitempty"` }{ - Dimmed: hemsInstance.Dimmed(), + Dimmed: hems.Dimmed(hemsInstance), Curtailed: hemsInstance.CurtailedPercent(), MaxConsumptionPower: hemsInstance.MaxConsumptionPower(), MaxProductionPower: hemsInstance.MaxProductionPower(), diff --git a/core/circuit/circuit.go b/core/circuit/circuit.go index 91bfcce0d..3ca32fde3 100644 --- a/core/circuit/circuit.go +++ b/core/circuit/circuit.go @@ -214,16 +214,16 @@ func (c *Circuit) effectiveMaxPower() float64 { } hemsLimit := c.hems.MaxConsumptionPower() - if hemsLimit <= 0 { + if hemsLimit == nil || *hemsLimit <= 0 { return maxPower } // unconfigured maxPower means unlimited, so the HEMS limit applies alone if maxPower <= 0 { - return hemsLimit + return *hemsLimit } - return min(hemsLimit, maxPower) + return min(*hemsLimit, maxPower) } // SetMaxPower sets the max power diff --git a/core/circuit/circuit_test.go b/core/circuit/circuit_test.go index b3112d2ec..3e25da7a2 100644 --- a/core/circuit/circuit_test.go +++ b/core/circuit/circuit_test.go @@ -165,7 +165,7 @@ func TestHEMSConsumptionClamp(t *testing.T) { if tc.hemsLimit > 0 { ctrl := gomock.NewController(t) hems := api.NewMockHEMS(ctrl) - hems.EXPECT().MaxConsumptionPower().Return(tc.hemsLimit).AnyTimes() + hems.EXPECT().MaxConsumptionPower().Return(&tc.hemsLimit).AnyTimes() c.hems = hems } diff --git a/core/helper.go b/core/helper.go index 80f5fbea4..56ee48786 100644 --- a/core/helper.go +++ b/core/helper.go @@ -6,6 +6,7 @@ import ( "slices" "github.com/evcc-io/evcc/api" + hemsapi "github.com/evcc-io/evcc/hems/hems" "github.com/evcc-io/evcc/util/config" ) @@ -83,16 +84,16 @@ func hemsDimmed(hems api.HEMS) *bool { return nil } - return hems.Dimmed() + return hemsapi.Dimmed(hems) } -// hemsCurtailed returns the HEMS curtail percent, nil-safe -func hemsCurtailed(hems api.HEMS) *int { +// hemsCurtailed returns whether HEMS curtailment is active, nil-safe +func hemsCurtailed(hems api.HEMS) *bool { if hems == nil { return nil } - return hems.CurtailedPercent() + return hemsapi.Curtailed(hems) } // nonZeroEnergy reports a zero lifetime energy reading as api.ErrNotAvailable. diff --git a/core/site.go b/core/site.go index f03e24cfb..27f4f025e 100644 --- a/core/site.go +++ b/core/site.go @@ -1081,14 +1081,18 @@ func (site *Site) update(lp updater) { var wg sync.WaitGroup wg.Go(func() { - if err := site.dimMeters(hemsDimmed(site.hems)); err != nil { - site.log.ERROR.Println(err) + if dim := hemsDimmed(site.hems); dim != nil { + if err := site.dimMeters(*dim); err != nil { + site.log.ERROR.Println(err) + } } }) wg.Go(func() { - if err := site.curtailPV(hemsCurtailed(site.hems)); err != nil { - site.log.ERROR.Println(err) + if hemsCurtailed(site.hems) != nil { + if err := site.curtailPV(site.hems.CurtailedPercent()); err != nil { + site.log.ERROR.Println(err) + } } }) diff --git a/core/site_circuit_test.go b/core/site_circuit_test.go index d36256417..830601da8 100644 --- a/core/site_circuit_test.go +++ b/core/site_circuit_test.go @@ -48,9 +48,9 @@ func TestDimming(t *testing.T) { if tc.has != *tc.want { dimmer.EXPECT().Dim(*tc.want).Return(nil) } - } - require.NoError(t, s.dimMeters(tc.want)) + require.NoError(t, s.dimMeters(*tc.want)) + } if !ctrl.Satisfied() { ctrl.Finish() diff --git a/core/site_circuits.go b/core/site_circuits.go index fc78b33d7..575f7fc51 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -56,11 +56,7 @@ func (site *Site) publishCircuits() { site.publish(keys.Circuits, res) } -func (site *Site) dimMeters(dim *bool) error { - if dim == nil { - return nil - } - +func (site *Site) dimMeters(dim bool) error { var errs error for _, dev := range slices.Concat(site.auxMeters, site.extMeters) { m, ok := api.Cap[api.Dimmer](dev.Instance()) @@ -69,7 +65,7 @@ func (site *Site) dimMeters(dim *bool) error { } if dimmed, err := backoff.RetryWithData(m.Dimmed, modbus.Backoff()); err == nil { - if *dim == dimmed { + if dim == dimmed { continue } } else { @@ -79,8 +75,8 @@ func (site *Site) dimMeters(dim *bool) error { continue } - if err := m.Dim(*dim); err == nil { - site.log.DEBUG.Printf("%s dim: %t", deviceTitleOrName(dev), *dim) + if err := m.Dim(dim); err == nil { + site.log.DEBUG.Printf("%s dim: %t", deviceTitleOrName(dev), dim) } else if !errors.Is(err, api.ErrNotAvailable) { errs = errors.Join(errs, fmt.Errorf("%s dim: %w", deviceTitleOrName(dev), err)) } diff --git a/hems/eebus/eebus.go b/hems/eebus/eebus.go index 09e1e3227..3f6c6f645 100644 --- a/hems/eebus/eebus.go +++ b/hems/eebus/eebus.go @@ -38,13 +38,13 @@ type EEBus struct { smartgridConsumptionId uint consumptionLimit ucapi.LoadLimit // LPC-041 - consumptionLimitActivated time.Time + consumptionLimitActivated *time.Time // nil until first connected, then always set failsafeConsumptionLimit float64 smartgridProductionId uint productionLimit ucapi.LoadLimit // feed-in limit (NOT production despite its name) - productionLimitActivated time.Time - failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name) + productionLimitActivated *time.Time // nil until first connected, then always set + failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name) productionNominalMax float64 heartbeat *util.Value[struct{}] @@ -172,6 +172,26 @@ func (c *EEBus) SetUpdated(f func()) { c.publishFunc = f } +// Connect overrides the embedded Connector: on first connect, limit state +// becomes valid (nil -> known). A later disconnect/reconnect is a no-op here. +func (c *EEBus) Connect(connected bool) { + c.Connector.Connect(connected) + + if !connected { + return + } + + c.mux.Lock() + defer c.mux.Unlock() + + if c.consumptionLimitActivated == nil { + c.consumptionLimitActivated = new(time.Time) + } + if c.productionLimitActivated == nil { + c.productionLimitActivated = new(time.Time) + } +} + func (c *EEBus) Run() { for range time.Tick(c.interval) { if err := c.run(); err != nil { @@ -227,7 +247,7 @@ func (c *EEBus) run() error { } // LPC-914/1 - if c.consumptionLimitActivated.IsZero() { + if !limitActive(c.consumptionLimitActivated) { if c.consumptionLimit.IsActive { c.log.WARN.Println("activating consumption limit") c.setConsumptionLimit(c.consumptionLimit.Value) @@ -237,7 +257,7 @@ 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: + case time.Since(*c.consumptionLimitActivated) > c.consumptionLimit.Duration: c.log.DEBUG.Println("consumption limit duration exceeded") c.setConsumptionLimit(0) c.consumptionLimit.IsActive = false @@ -245,7 +265,7 @@ func (c *EEBus) run() error { } // LPP - if c.productionLimitActivated.IsZero() { + if !limitActive(c.productionLimitActivated) { if c.productionLimit.IsActive { if c.productionNominalMax <= 0 { return errors.New("production limit received but productionNominalMax is not configured") @@ -259,7 +279,7 @@ 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: + case time.Since(*c.productionLimitActivated) > c.productionLimit.Duration: c.log.DEBUG.Println("production limit duration exceeded") c.setProductionLimit(0, false) c.productionLimit.IsActive = false @@ -269,6 +289,20 @@ func (c *EEBus) run() error { return nil } +// limitActive reports whether t denotes a currently active limit: known (non-nil) and non-zero. +func limitActive(t *time.Time) bool { + return t != nil && !t.IsZero() +} + +// activatedAt returns now if active, else a known-but-zero timestamp. +func activatedAt(active bool) *time.Time { + if active { + t := time.Now() + return &t + } + return new(time.Time) +} + func (c *EEBus) setStatus(status status) { c.status = status c.statusUpdated = time.Now() @@ -276,12 +310,7 @@ func (c *EEBus) setStatus(status status) { func (c *EEBus) setConsumptionLimit(limit float64) { active := limit > 0 - - if active { - c.consumptionLimitActivated = time.Now() - } else { - c.consumptionLimitActivated = time.Time{} - } + c.consumptionLimitActivated = activatedAt(active) if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil { c.log.ERROR.Printf("smartgrid session: %v", err) @@ -295,11 +324,7 @@ func (c *EEBus) setConsumptionLimit(limit float64) { } func (c *EEBus) setProductionLimit(limit float64, active bool) { - if active { - c.productionLimitActivated = time.Now() - } else { - c.productionLimitActivated = time.Time{} - } + c.productionLimitActivated = activatedAt(active) if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil { c.log.ERROR.Printf("smartgrid session: %v", err) @@ -308,13 +333,6 @@ func (c *EEBus) setProductionLimit(limit float64, active bool) { var _ api.HEMS = (*EEBus)(nil) -// Dimmed implements api.HEMS, derived from consumptionLimitActivated. -func (c *EEBus) Dimmed() *bool { - c.mux.RLock() - defer c.mux.RUnlock() - return new(!c.consumptionLimitActivated.IsZero()) -} - // CurtailedPercent implements api.HEMS, converting the active LPP production // limit to an allowed production percent via the configured nominal production power. func (c *EEBus) CurtailedPercent() *int { @@ -327,7 +345,7 @@ func (c *EEBus) CurtailedPercent() *int { } percent := 100 - if !c.productionLimitActivated.IsZero() { + if limitActive(c.productionLimitActivated) { // production limits are negative watts percent = int(-c.productionLimit.Value / c.productionNominalMax * 100) } @@ -335,19 +353,21 @@ func (c *EEBus) CurtailedPercent() *int { return &percent } -// MaxConsumptionPower implements api.HEMS, returning the consumption cap -// currently in effect: failsafe limit while in failsafe, otherwise the -// EG-supplied LPC limit when active, or 0 when no limit applies. -func (c *EEBus) MaxConsumptionPower() float64 { +// MaxConsumptionPower implements api.HEMS: nil until first connected, +// else failsafe limit in failsafe, else the active EG-supplied LPC limit, else 0. +func (c *EEBus) MaxConsumptionPower() *float64 { c.mux.RLock() defer c.mux.RUnlock() - if c.consumptionLimitActivated.IsZero() { - return 0 + if c.consumptionLimitActivated == nil { + return nil + } + if !limitActive(c.consumptionLimitActivated) { + return new(0.0) } if c.status == StatusFailsafe { - return c.failsafeConsumptionLimit + return new(c.failsafeConsumptionLimit) } - return c.consumptionLimit.Value + return new(c.consumptionLimit.Value) } // MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not @@ -355,9 +375,12 @@ func (c *EEBus) MaxConsumptionPower() float64 { func (c *EEBus) MaxProductionPower() *float64 { c.mux.RLock() defer c.mux.RUnlock() - if c.productionLimitActivated.IsZero() { + if c.productionLimitActivated == nil { return nil } + if !limitActive(c.productionLimitActivated) { + return new(0.0) + } if c.status == StatusFailsafe { return c.failsafeProductionLimit } diff --git a/hems/eebus/eebus_test.go b/hems/eebus/eebus_test.go index 8b8554e06..d36d2cae4 100644 --- a/hems/eebus/eebus_test.go +++ b/hems/eebus/eebus_test.go @@ -7,6 +7,7 @@ import ( ucapi "github.com/enbility/eebus-go/usecases/api" "github.com/evcc-io/evcc/core/site" "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" @@ -40,6 +41,7 @@ func newTestEEBus(t *testing.T) *EEBus { return &EEBus{ log: util.NewLogger("test"), site: &stubSite{}, + Connector: eebus.NewConnector(), heartbeat: util.NewValue[struct{}](time.Hour), failsafeConsumptionLimit: testFailsafeConsumption, failsafeProductionLimit: &failsafeProduction, @@ -48,11 +50,13 @@ func newTestEEBus(t *testing.T) *EEBus { } } -// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS surface. +// 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() - assert.Equal(t, new(limit > 0), c.Dimmed()) - assert.Equal(t, limit, c.MaxConsumptionPower()) + power := c.MaxConsumptionPower() + require.NotNil(t, power) + assert.Equal(t, limit, *power) } // assertProductionLimit checks the HEMS production state through the api.HEMS surface. @@ -63,6 +67,20 @@ func assertProductionLimit(t *testing.T, c *EEBus, active bool) { 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. @@ -178,3 +196,21 @@ func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) { 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) +} diff --git a/hems/fnn/fnn.go b/hems/fnn/fnn.go index 99000b984..dd66667a9 100644 --- a/hems/fnn/fnn.go +++ b/hems/fnn/fnn.go @@ -77,7 +77,7 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, return nil, errors.New("cannot have w4 without power limit") } - return &Fnn{ + c := &Fnn{ log: util.NewLogger("fnn"), site: site, maxDimPower: maxDimPower, @@ -88,7 +88,17 @@ func NewFnn(site site.API, maxDimPower, maxCurtailPower float64, w3G, s1G, s2G, w4: w4G, productionPercent: 100, interval: interval, - }, nil + } + + // read the relays once synchronously so limits are valid as soon as NewFnn returns + if err := c.runCurtail(); err != nil { + return nil, err + } + if err := c.runDim(); err != nil { + return nil, err + } + + return c, nil } // Fnn implements the FNN HEMS logic for curtailment and dimming. @@ -107,7 +117,7 @@ type Fnn struct { smartgridConsumptionID uint smartgridProductionID uint - consumptionLimit float64 + consumptionLimit *float64 productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed interval time.Duration @@ -119,10 +129,9 @@ func (c *Fnn) SetUpdated(f func()) { c.publishFunc = f } -// Run starts the FNN control loop. +// Run starts the FNN control loop. NewFnn already ran the first pass. func (c *Fnn) Run() { - // run immediately, then on every tick - for tick := time.Tick(c.interval); ; <-tick { + for range time.Tick(c.interval) { if err := c.runCurtail(); err != nil { c.log.ERROR.Println(err) } @@ -218,7 +227,10 @@ func (c *Fnn) setConsumptionLimit(limit float64) error { defer c.mu.Unlock() active := limit > 0 - c.consumptionLimit = limit + c.consumptionLimit = nil + if active { + c.consumptionLimit = &limit + } if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil { c.log.ERROR.Printf("smartgrid session: %v", err) @@ -229,17 +241,6 @@ func (c *Fnn) setConsumptionLimit(limit float64) error { var _ api.HEMS = (*Fnn)(nil) -// Dimmed implements api.HEMS. -func (c *Fnn) Dimmed() *bool { - if c.w4 == nil { - return nil - } - - c.mu.Lock() - defer c.mu.Unlock() - return new(c.consumptionLimit > 0) -} - // CurtailedPercent implements api.HEMS, returning the allowed production percent. func (c *Fnn) CurtailedPercent() *int { if c.w3 == nil { @@ -249,23 +250,33 @@ func (c *Fnn) CurtailedPercent() *int { c.mu.Lock() defer c.mu.Unlock() - percent := c.productionPercent - return &percent + return new(c.productionPercent) } // MaxConsumptionPower implements api.HEMS. -func (c *Fnn) MaxConsumptionPower() float64 { +func (c *Fnn) MaxConsumptionPower() *float64 { + if c.w4 == nil { + return nil + } + c.mu.Lock() defer c.mu.Unlock() - return c.consumptionLimit + if c.consumptionLimit == nil { + return new(0.0) + } + return new(*c.consumptionLimit) } // MaxProductionPower implements api.HEMS. func (c *Fnn) MaxProductionPower() *float64 { + if c.w3 == nil { + return nil + } + c.mu.Lock() defer c.mu.Unlock() if c.productionPercent >= 100 { - return nil + return new(0.0) } return new(float64(c.productionPercent) / 100 * c.maxCurtailPower) diff --git a/hems/fnn/fnn_test.go b/hems/fnn/fnn_test.go index 76f08725a..19972b3ce 100644 --- a/hems/fnn/fnn_test.go +++ b/hems/fnn/fnn_test.go @@ -3,16 +3,44 @@ package fnn import ( "testing" + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/server/db" "github.com/stretchr/testify/require" ) +// stubSite implements site.API for testing — only GetGridPower is exercised. +type stubSite struct { + site.API +} + +func (s *stubSite) GetGridPower() float64 { return 0 } + func TestFnn(t *testing.T) { - // TODO add site - fnn, err := NewFnn(nil, 1e3, 1e3, nil, nil, nil, func() (bool, error) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + fnn, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, func() (bool, error) { return true, nil }, 0) require.NoError(t, err) require.Nil(t, fnn.CurtailedPercent()) - // require.NoError(t, fnn.runDim()) - // require.Equal(t, new(true), fnn.Dimmed()) +} + +// TestFnnNilWhenNotConfigured verifies "nil = limiting undefined": nil unless +// the relay input is configured, else valid right after NewFnn returns. +func TestFnnNilWhenNotConfigured(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + unconfigured, err := NewFnn(&stubSite{}, 1e3, 1e3, nil, nil, nil, nil, 0) + require.NoError(t, err) + require.Nil(t, unconfigured.MaxConsumptionPower()) + require.Nil(t, unconfigured.MaxProductionPower()) + + off := func() (bool, error) { return false, nil } + configured, err := NewFnn(&stubSite{}, 1e3, 1e3, off, nil, nil, off, 0) + require.NoError(t, err) + + require.NotNil(t, configured.MaxConsumptionPower()) + require.Equal(t, 0.0, *configured.MaxConsumptionPower()) + require.NotNil(t, configured.MaxProductionPower()) + require.Equal(t, 0.0, *configured.MaxProductionPower()) } diff --git a/hems/hems/globals.go b/hems/hems/globals.go new file mode 100644 index 000000000..41b3b3c09 --- /dev/null +++ b/hems/hems/globals.go @@ -0,0 +1,22 @@ +package hems + +import "github.com/evcc-io/evcc/api" + +// Dimmed reports nil until MaxConsumptionPower is known (see api.HEMS). +func Dimmed(hems api.HEMS) *bool { + dimmed := hems.MaxConsumptionPower() + if dimmed == nil { + return nil + } + + return new(*dimmed > 0) +} + +func Curtailed(hems api.HEMS) *bool { + percent := hems.CurtailedPercent() + if percent == nil { + return nil + } + + return new(*percent < 100) +} diff --git a/hems/relay/relay.go b/hems/relay/relay.go index deabf25ac..3ace1e152 100644 --- a/hems/relay/relay.go +++ b/hems/relay/relay.go @@ -78,6 +78,11 @@ func NewRelay(site site.API, w1 func() (bool, error), passthrough func(bool) err return nil, errors.New("missing power limit") } + // read the relay once synchronously so the limit is valid as soon as NewRelay returns + if err := c.run(); err != nil { + return nil, err + } + return c, nil } @@ -87,9 +92,9 @@ func (c *Relay) SetUpdated(f func()) { c.publishFunc = f } +// Run starts the relay control loop. NewRelay already ran the first pass. func (c *Relay) Run() { - // run immediately, then on every tick - for tick := time.Tick(c.interval); ; <-tick { + for range time.Tick(c.interval) { if err := c.run(); err != nil { c.log.ERROR.Println(err) } @@ -142,13 +147,6 @@ func (c *Relay) setConsumptionLimit(limit float64) error { var _ api.HEMS = (*Relay)(nil) -// Dimmed implements api.HEMS, derived from the active consumption limit. -func (c *Relay) Dimmed() *bool { - c.mu.Lock() - defer c.mu.Unlock() - return new(c.limit != nil) -} - // CurtailedPercent implements api.HEMS. Relay does not curtail production and // hence makes no statement. func (c *Relay) CurtailedPercent() *int { @@ -156,13 +154,13 @@ func (c *Relay) CurtailedPercent() *int { } // MaxConsumptionPower implements api.HEMS, returning the active wattage cap. -func (c *Relay) MaxConsumptionPower() float64 { +func (c *Relay) MaxConsumptionPower() *float64 { c.mu.Lock() defer c.mu.Unlock() if c.limit == nil { - return 0 + return new(0.0) } - return *c.limit + return new(*c.limit) } // MaxProductionPower implements api.HEMS. Scaffolding only. diff --git a/hems/relay/relay_test.go b/hems/relay/relay_test.go new file mode 100644 index 000000000..16c995671 --- /dev/null +++ b/hems/relay/relay_test.go @@ -0,0 +1,30 @@ +package relay + +import ( + "testing" + + "github.com/evcc-io/evcc/core/site" + "github.com/evcc-io/evcc/server/db" + "github.com/stretchr/testify/require" +) + +// stubSite implements site.API for testing — only GetGridPower is exercised. +type stubSite struct { + site.API +} + +func (s *stubSite) GetGridPower() float64 { return 0 } + +// TestRelayNoNilState verifies MaxConsumptionPower is always determinable (w1 +// is mandatory) — NewRelay reads it once so the state is valid immediately. +func TestRelayNoNilState(t *testing.T) { + require.NoError(t, db.NewInstance("sqlite", ":memory:")) + + off := func() (bool, error) { return false, nil } + c, err := NewRelay(&stubSite{}, off, nil, 1000, 0) + require.NoError(t, err) + + require.NotNil(t, c.MaxConsumptionPower()) + require.Equal(t, 0.0, *c.MaxConsumptionPower()) + require.Nil(t, c.MaxProductionPower()) // scaffolding only, always nil +}