Refactor dim/curtail handling- split hems and circuit (BC) (#30284)
This commit is contained in:
parent
3758ce1957
commit
8106dc8b76
70 changed files with 960 additions and 671 deletions
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
144
hems/fnn/fnn.go
144
hems/fnn/fnn.go
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue