Simplify HEMS api (#31428)
This commit is contained in:
parent
199365288a
commit
e239af5d25
16 changed files with 255 additions and 121 deletions
|
|
@ -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
|
||||
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
|
||||
|
|
|
|||
18
api/mock.go
18
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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -1081,15 +1081,19 @@ func (site *Site) update(lp updater) {
|
|||
var wg sync.WaitGroup
|
||||
|
||||
wg.Go(func() {
|
||||
if err := site.dimMeters(hemsDimmed(site.hems)); err != nil {
|
||||
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 {
|
||||
if hemsCurtailed(site.hems) != nil {
|
||||
if err := site.curtailPV(site.hems.CurtailedPercent()); err != nil {
|
||||
site.log.ERROR.Println(err)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
wg.Wait()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,12 +38,12 @@ 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
|
||||
productionLimitActivated *time.Time // nil until first connected, then always set
|
||||
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
|
||||
productionNominalMax float64
|
||||
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
22
hems/hems/globals.go
Normal file
22
hems/hems/globals.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
30
hems/relay/relay_test.go
Normal file
30
hems/relay/relay_test.go
Normal file
|
|
@ -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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue