Refactor dim/curtail handling- split hems and circuit (BC) (#30284)

This commit is contained in:
andig 2026-06-07 13:22:03 +02:00 • committed by GitHub
parent 3758ce1957
commit 8106dc8b76
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
70 changed files with 960 additions and 671 deletions

View file

@ -22,8 +22,9 @@ type EEBus struct {
*eebus.Connector
cs *eebus.ControllableSystem
root api.Circuit
site site.API
passthrough func(bool) error
publishFunc func()
status status
statusUpdated time.Time
@ -83,26 +84,18 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*E
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, gridcontrol, cc.Interval)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, site, cc.Interval)
}
// NewEEBus creates EEBus HEMS
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, root api.Circuit, interval time.Duration) (*EEBus, error) {
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, site site.API, interval time.Duration) (*EEBus, error) {
if eebus.Instance == nil {
return nil, errors.New("eebus not configured")
}
c := &EEBus{
log: util.NewLogger("eebus"),
root: root,
site: site,
passthrough: passthrough,
cs: eebus.Instance.ControllableSystem(),
Connector: eebus.NewConnector(),
@ -162,11 +155,21 @@ func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(b
return c, nil
}
func (c *EEBus) SetUpdated(f func()) {
c.mux.Lock()
defer c.mux.Unlock()
c.publishFunc = f
}
func (c *EEBus) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -219,7 +222,11 @@ func (c *EEBus) run() error {
c.setConsumptionLimit(c.consumptionLimit.Value)
}
} else {
if time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration {
switch {
case !c.consumptionLimit.IsActive:
c.log.DEBUG.Println("consumption limit released")
c.setConsumptionLimit(0)
case time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration:
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
@ -233,7 +240,11 @@ func (c *EEBus) run() error {
c.setProductionLimit(c.productionLimit.Value, true)
}
} else {
if time.Since(c.productionLimitActivated) > c.productionLimit.Duration {
switch {
case !c.productionLimit.IsActive:
c.log.DEBUG.Println("production limit released")
c.setProductionLimit(0, false)
case time.Since(c.productionLimitActivated) > c.productionLimit.Duration:
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
@ -257,10 +268,7 @@ func (c *EEBus) setConsumptionLimit(limit float64) {
c.consumptionLimitActivated = time.Time{}
}
c.root.Dim(active)
c.root.SetMaxPower(limit)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
@ -278,11 +286,52 @@ func (c *EEBus) setProductionLimit(limit float64, active bool) {
c.productionLimitActivated = time.Time{}
}
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxProduction(limit)
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}
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 !c.consumptionLimitActivated.IsZero()
}
// Curtailed implements api.HEMS, derived from productionLimitActivated.
func (c *EEBus) Curtailed() bool {
c.mux.RLock()
defer c.mux.RUnlock()
return !c.productionLimitActivated.IsZero()
}
// 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 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.consumptionLimitActivated.IsZero() {
return 0
}
if c.status == StatusFailsafe {
return c.failsafeConsumptionLimit
}
return c.consumptionLimit.Value
}
// MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not
// publish a wattage-typed production cap yet.
func (c *EEBus) MaxProductionPower() *float64 {
c.mux.RLock()
defer c.mux.RUnlock()
if c.productionLimitActivated.IsZero() {
return nil
}
if c.status == StatusFailsafe {
return c.failsafeProductionLimit
}
return new(c.productionLimit.Value)
}

View file

@ -5,12 +5,11 @@ import (
"time"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/server/db"
"github.com/evcc-io/evcc/util"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
)
const (
@ -19,10 +18,19 @@ const (
testFailsafeDuration = 2 * time.Hour
)
// stubSite implements site.API for testing — only GetGridPower is exercised;
// any other call would dereference the nil embedded interface and panic.
type stubSite struct {
site.API
gridPower float64
}
func (s *stubSite) GetGridPower() float64 { return s.gridPower }
// newTestEEBus builds a minimally-wired EEBus suitable for exercising run().
// The CS interfaces are nil — the failsafe-exit path under test does not call
// them — and smartgrid persistence is backed by an in-memory SQLite database.
func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
func newTestEEBus(t *testing.T) *EEBus {
t.Helper()
require.NoError(t, db.NewInstance("sqlite", ":memory:"))
@ -30,7 +38,7 @@ func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
failsafeProduction := testFailsafeProduction
return &EEBus{
log: util.NewLogger("test"),
root: root,
site: &stubSite{},
heartbeat: util.NewValue[struct{}](time.Hour),
failsafeConsumptionLimit: testFailsafeConsumption,
failsafeProductionLimit: &failsafeProduction,
@ -38,35 +46,30 @@ func newTestEEBus(t *testing.T, root api.Circuit) *EEBus {
}
}
// expectConsumptionLimit programs the mock circuit to receive a consumption
// limit. limit==0 means "release" (Dim(false), SetMaxPower(0)); >0 means "apply".
func expectConsumptionLimit(c *api.MockCircuit, limit float64) {
c.EXPECT().Dim(limit > 0)
c.EXPECT().SetMaxPower(limit)
c.EXPECT().GetChargePower().Return(0.0)
// assertConsumptionLimit checks the HEMS consumption state through the api.HEMS surface.
func assertConsumptionLimit(t *testing.T, c *EEBus, limit float64) {
t.Helper()
assert.Equal(t, limit > 0, c.Dimmed())
assert.Equal(t, limit, c.MaxConsumptionPower())
}
// expectProductionLimit programs the mock circuit for a production-limit
// transition. active=true on a non-zero EG limit; false on release.
func expectProductionLimit(c *api.MockCircuit, active bool) {
c.EXPECT().Curtail(active)
c.EXPECT().GetChargePower().Return(0.0)
// assertProductionLimit checks the HEMS production state through the api.HEMS surface.
func assertProductionLimit(t *testing.T, c *EEBus, active bool) {
t.Helper()
assert.Equal(t, active, c.Curtailed())
}
// 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.
func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
// heartbeat never Set -> Get() returns ErrTimeout
expectConsumptionLimit(circuit, testFailsafeConsumption)
expectProductionLimit(circuit, true)
require.NoError(t, c.run())
assert.Equal(t, StatusFailsafe, c.status)
assertConsumptionLimit(t, c, testFailsafeConsumption)
assertProductionLimit(t, c, true)
}
// TestRun_FailsafeStaysOnMissingHeartbeat is the LPC-921/LPP-921 fix: when the
@ -76,9 +79,7 @@ func TestRun_HeartbeatLost_EntersFailsafe(t *testing.T) {
// StatusNormal with limit=0 once failsafeDuration elapsed, leaving the system
// unprotected until heartbeat returned.
func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
// statusUpdated set in the past beyond failsafeDuration to verify we do not
// exit failsafe based on the duration alone.
@ -97,40 +98,67 @@ func TestRun_FailsafeStaysOnMissingHeartbeat(t *testing.T) {
func TestRun_HeartbeatReturned_AppliesFreshLimit(t *testing.T) {
const freshLimit = 3000.0
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
c.statusUpdated = time.Now() // well within failsafeDuration
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{Value: freshLimit, IsActive: true}
// Exit clears the consumption limit, then the LPC-914/1 block re-applies
// the fresh value. Production was never active, so it stays at zero.
expectConsumptionLimit(circuit, 0)
expectProductionLimit(circuit, false)
expectConsumptionLimit(circuit, freshLimit)
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
// Final state is the fresh limit (the LPC-914/1 block re-applies after the release).
assertConsumptionLimit(t, c, freshLimit)
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) {
ctrl := gomock.NewController(t)
circuit := api.NewMockCircuit(ctrl)
c := newTestEEBus(t, circuit)
c := newTestEEBus(t)
c.status = StatusFailsafe
c.heartbeat.Set(struct{}{})
c.consumptionLimit = ucapi.LoadLimit{IsActive: false}
// Only the failsafe-exit release runs; the LPC-914/1 block sees no
// active limit.
expectConsumptionLimit(circuit, 0)
expectProductionLimit(circuit, false)
require.NoError(t, c.run())
assert.Equal(t, StatusNormal, c.status)
assertConsumptionLimit(t, c, 0)
assertProductionLimit(t, c, false)
}
// 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,
// so unchecking "Activate" in the control box had no effect until the timer ran
// out (see PR #30284 report).
func TestRun_ProductionLimitReleasedEarly(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
// EG activates a production limit with a long duration.
c.productionLimit = ucapi.LoadLimit{IsActive: true, Duration: time.Hour}
require.NoError(t, c.run())
assertProductionLimit(t, c, true)
// EG deactivates well within the duration -> must release immediately.
c.productionLimit.IsActive = false
require.NoError(t, c.run())
assertProductionLimit(t, c, false)
}
// TestRun_ConsumptionLimitReleasedEarly is the LPC mirror of the LPP early-release
// case: an active consumption limit must drop as soon as the EG deactivates it.
func TestRun_ConsumptionLimitReleasedEarly(t *testing.T) {
c := newTestEEBus(t)
c.heartbeat.Set(struct{}{})
// EG activates a consumption limit with a long duration.
c.consumptionLimit = ucapi.LoadLimit{Value: 3000, IsActive: true, Duration: time.Hour}
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 3000)
// EG deactivates well within the duration -> must release immediately.
c.consumptionLimit.IsActive = false
require.NoError(t, c.run())
assertConsumptionLimit(t, c, 0)
}

View file

@ -19,7 +19,7 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
cc := struct {
MaxPower float64 // TODO deprecated
MaxDimPower float64
MaxCurtailPower *float64
MaxCurtailPower float64
W3 *plugin.Config
S1 *plugin.Config
S2 *plugin.Config
@ -33,14 +33,6 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
w3G, err := cc.W3.BoolGetter(ctx)
if err != nil {
return nil, err
@ -67,19 +59,18 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*F
}
maxDimPower := math.Abs(cc.MaxDimPower)
if w4G != nil && maxDimPower == 0 {
return nil, errors.New("cannot have w4 without power limit")
}
var maxCurtailPower *float64
switch {
case cc.MaxCurtailPower != nil:
maxCurtailPower = new(math.Abs(*cc.MaxCurtailPower))
case cc.MaxPower > 0:
// fnn-3 backwards compatibility: legacy MaxPower was the PV/curtail cap
maxCurtailPower = new(math.Abs(cc.MaxPower))
maxCurtailPower := math.Abs(cc.MaxCurtailPower)
if cc.MaxPower > 0 {
maxCurtailPower = cc.MaxPower
}
return &Fnn{
log: util.NewLogger("fnn"),
root: gridcontrol,
site: site,
maxDimPower: maxDimPower,
maxCurtailPower: maxCurtailPower,
s1: s1G,
@ -95,24 +86,32 @@ type Fnn struct {
mu sync.Mutex
log *util.Logger
root api.Circuit
s1, s2, w3 func() (bool, error)
w4 func() (bool, error)
site site.API
s1, s2, w3 func() (bool, error)
w4 func() (bool, error)
publishFunc func()
maxDimPower float64
maxCurtailPower float64
smartgridConsumptionID uint
smartgridProductionID uint
maxDimPower float64
maxCurtailPower *float64
interval time.Duration
consumptionLimit float64
productionLimit *float64
interval time.Duration
}
func (c *Fnn) SetUpdated(f func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.publishFunc = f
}
// Run starts the FNN control loop.
func (c *Fnn) Run() {
ticker := time.NewTicker(c.interval)
defer ticker.Stop()
for range ticker.C {
for range time.Tick(c.interval) {
if err := c.runCurtail(); err != nil {
c.log.ERROR.Println(err)
}
@ -120,6 +119,10 @@ func (c *Fnn) Run() {
if err := c.runDim(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -172,60 +175,89 @@ func (c *Fnn) runDim() error {
limit := 0.0
if active {
if c.maxDimPower <= 0 {
return errors.New("dim active but no limit configured")
}
limit = c.maxDimPower
}
return c.setConsumptionLimit(limit)
}
func (c *Fnn) applyMode(id *uint, typ smartgrid.Type, active bool, limit float64, applyRoot func()) {
applyRoot()
if err := smartgrid.UpdateSession(id, typ, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}
// setProductionLimit applies the curtailment limit to the circuit.
// setProductionLimit applies the curtailment limit.
func (c *Fnn) setProductionLimit(frac float64) error {
c.mu.Lock()
defer c.mu.Unlock()
limit := 0.0
active := frac < 1.0
c.productionLimit = nil
if active {
if c.maxCurtailPower == nil {
return errors.New("curtail active but no limit configured")
}
limit = *c.maxCurtailPower * frac
c.productionLimit = new(c.maxCurtailPower * frac)
}
c.applyMode(&c.smartgridProductionID, smartgrid.Curtail, active, limit, func() {
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxPower(c.maxPower*frac)
})
limit := 0.0
if c.productionLimit != nil {
limit = *c.productionLimit
}
if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
return nil
}
// setConsumptionLimit applies the dimming limit to the circuit.
// setConsumptionLimit applies the dimming limit.
func (c *Fnn) setConsumptionLimit(limit float64) error {
c.mu.Lock()
defer c.mu.Unlock()
active := limit > 0
c.consumptionLimit = limit
c.applyMode(&c.smartgridConsumptionID, smartgrid.Dim, active, limit, func() {
c.root.Dim(active)
c.root.SetMaxPower(limit)
})
if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
return nil
}
var _ api.HEMS = (*Fnn)(nil)
// Dimmed implements api.HEMS.
func (c *Fnn) Dimmed() bool {
if c.w4 == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.consumptionLimit > 0
}
// Curtailed implements api.HEMS.
func (c *Fnn) Curtailed() bool {
if c.w3 == nil {
return false
}
c.mu.Lock()
defer c.mu.Unlock()
return c.productionLimit != nil
}
// MaxConsumptionPower implements api.HEMS.
func (c *Fnn) MaxConsumptionPower() float64 {
c.mu.Lock()
defer c.mu.Unlock()
return c.consumptionLimit
}
// MaxProductionPower implements api.HEMS.
func (c *Fnn) MaxProductionPower() *float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.productionLimit == nil {
return nil
}
return new(*c.productionLimit)
}

View file

@ -1,6 +1,10 @@
package hems
// API describes the HEMS system interface
import "github.com/evcc-io/evcc/api"
// API describes the HEMS system interface combining the runtime loop
// with the api.HEMS state-query surface consumed by Circuit, Site and Loadpoint.
type API interface {
api.HEMS
Run()
}

View file

@ -2,6 +2,7 @@ package relay
import (
"context"
"errors"
"fmt"
"sync"
"time"
@ -17,9 +18,10 @@ type Relay struct {
mu sync.Mutex
log *util.Logger
root api.Circuit
site site.API
w1 func() (bool, error)
passthrough func(bool) error
publishFunc func()
smartgridID uint
limit *float64
@ -42,14 +44,6 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
// limit getter
limitG, err := cc.Limit.BoolGetter(ctx)
if err != nil {
@ -61,28 +55,42 @@ func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*R
return nil, err
}
return NewRelay(gridcontrol, limitG, passthroughS, cc.MaxPower, cc.Interval)
return NewRelay(site, limitG, passthroughS, cc.MaxPower, cc.Interval)
}
// NewRelay creates Relay HEMS
func NewRelay(root api.Circuit, 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) (*Relay, error) {
c := &Relay{
log: util.NewLogger("relay"),
root: root,
site: site,
passthrough: passthrough,
maxPower: maxPower,
w1: w1,
interval: interval,
}
if maxPower == 0 {
return nil, errors.New("missing power limit")
}
return c, nil
}
func (c *Relay) SetUpdated(f func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.publishFunc = f
}
func (c *Relay) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
if c.publishFunc != nil {
c.publishFunc()
}
}
}
@ -97,18 +105,18 @@ func (c *Relay) run() error {
limit = c.maxPower
}
if err := c.setLimited(limit); err != nil {
if err := c.setConsumptionLimit(limit); err != nil {
return err
}
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
if err := smartgrid.UpdateSession(&c.smartgridID, smartgrid.Dim, c.site.GetGridPower(), limit, active); err != nil {
return fmt.Errorf("smartgrid session: %v", err)
}
return nil
}
func (c *Relay) setLimited(limit float64) error {
func (c *Relay) setConsumptionLimit(limit float64) error {
c.mu.Lock()
defer c.mu.Unlock()
@ -117,9 +125,6 @@ func (c *Relay) setLimited(limit float64) error {
c.limit = new(limit)
}
c.root.Dim(limit > 0)
c.root.SetMaxPower(limit)
if c.passthrough != nil {
if err := c.passthrough(limit > 0); err != nil {
return fmt.Errorf("passthrough failed: %w", err)
@ -128,3 +133,32 @@ func (c *Relay) setLimited(limit float64) error {
return nil
}
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 c.limit != nil
}
// Curtailed implements api.HEMS. Relay does not curtail production.
func (c *Relay) Curtailed() bool {
return false
}
// MaxConsumptionPower implements api.HEMS, returning the active wattage cap.
func (c *Relay) MaxConsumptionPower() float64 {
c.mu.Lock()
defer c.mu.Unlock()
if c.limit == nil {
return 0
}
return *c.limit
}
// MaxProductionPower implements api.HEMS. Scaffolding only.
func (c *Relay) MaxProductionPower() *float64 {
return nil
}

View file

@ -1,42 +0,0 @@
package smartgrid
import (
"errors"
"time"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/circuit"
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/config"
)
const GridControl = "gridcontrol"
// SetupCircuit returns or registers the grid control circuit
func SetupCircuit() (api.Circuit, error) {
if _, err := config.Circuits().ByName(GridControl); err == nil {
return nil, errors.New("gridcontrol is a reserved name and will be auto-created as root circuit when hems is configured")
}
root := circuit.Root()
// create new circuit
circuit, err := circuit.New(util.NewLogger(GridControl), "", 0, 0, nil, time.Minute)
if err != nil {
return nil, err
}
dev := config.NewStaticDevice[api.Circuit](config.Named{Name: GridControl}, circuit)
if err := config.Circuits().Add(dev); err != nil {
return nil, err
}
// wrap old root with new grid control parent
if root != nil {
if err := root.Wrap(circuit); err != nil {
return nil, err
}
}
return circuit, nil
}