diff --git a/core/site.go b/core/site.go index 9acb8f216..84d2ed773 100644 --- a/core/site.go +++ b/core/site.go @@ -75,6 +75,10 @@ type Site struct { auxMeters []config.Device[api.Meter] // Auxiliary meters consumerMeters []config.Device[api.Meter] // Consumer meters + // last applied HEMS state, nil until applied or after a failed attempt + dimmed *bool + curtailPercent *int + // battery settings prioritySoc float64 // prefer battery up to this Soc bufferSoc float64 // continue charging on battery above this Soc diff --git a/core/site_circuits.go b/core/site_circuits.go index da0fbcee5..6aceb79fe 100644 --- a/core/site_circuits.go +++ b/core/site_circuits.go @@ -56,7 +56,16 @@ func (site *Site) publishCircuits() { site.publish(keys.Circuits, res) } +// dimMeters applies the HEMS dim state to all dimmable aux and ext meters. +// Devices are only queried when the state changes or after a failed attempt. func (site *Site) dimMeters(dim bool) error { + if site.dimmed != nil && *site.dimmed == dim { + return nil + } + + // invalidate until successfully applied + site.dimmed = nil + var errs error for _, dev := range slices.Concat(site.auxMeters, site.extMeters) { m, ok := api.Cap[api.Dimmer](dev.Instance()) @@ -82,14 +91,23 @@ func (site *Site) dimMeters(dim bool) error { } } + if errs == nil { + site.dimmed = &dim + } + return errs } +// curtailPV applies the HEMS curtailment percent to all curtailable pv meters. +// Devices are only queried when the percent changes or after a failed attempt. func (site *Site) curtailPV(percent *int) error { - if percent == nil { + if percent == nil || site.curtailPercent != nil && *site.curtailPercent == *percent { return nil } + // invalidate until successfully applied + site.curtailPercent = nil + var errs error for _, dev := range site.pvMeters { m, ok := api.Cap[api.Curtailer](dev.Instance()) @@ -115,5 +133,9 @@ func (site *Site) curtailPV(percent *int) error { } } + if errs == nil { + site.curtailPercent = new(*percent) + } + return errs } diff --git a/core/site_circuits_test.go b/core/site_circuits_test.go new file mode 100644 index 000000000..7e7e26e60 --- /dev/null +++ b/core/site_circuits_test.go @@ -0,0 +1,144 @@ +package core + +import ( + "errors" + "testing" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/config" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// curtailableMeter counts device interactions to verify caching. +type curtailableMeter struct { + api.Meter + percent int + setErr error + gets int + sets int + setCalls []int +} + +func (m *curtailableMeter) CurtailedPercent() (int, error) { + m.gets++ + return m.percent, nil +} + +func (m *curtailableMeter) SetCurtailPercent(percent int) error { + m.sets++ + m.setCalls = append(m.setCalls, percent) + if m.setErr != nil { + return m.setErr + } + m.percent = percent + return nil +} + +func curtailSite(m api.Meter) *Site { + return &Site{ + log: util.NewLogger("foo"), + pvMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, m)}, + } +} + +// The HEMS percent is applied once and not re-evaluated while it stays unchanged. +func TestCurtailPVCache(t *testing.T) { + m := &curtailableMeter{percent: 100} + site := curtailSite(m) + + require.NoError(t, site.curtailPV(new(60))) + assert.Equal(t, []int{60}, m.setCalls) + assert.Equal(t, 1, m.gets) + + // unchanged: device is not queried again + for range 3 { + require.NoError(t, site.curtailPV(new(60))) + } + assert.Equal(t, []int{60}, m.setCalls) + assert.Equal(t, 1, m.gets) + + // changed: applied again. A bool device state could not distinguish 60 from 30 + require.NoError(t, site.curtailPV(new(30))) + assert.Equal(t, []int{60, 30}, m.setCalls) + + require.NoError(t, site.curtailPV(new(100))) + assert.Equal(t, []int{60, 30, 100}, m.setCalls) +} + +// A failed write must be retried on the next cycle instead of being cached. +func TestCurtailPVCacheRetriesAfterError(t *testing.T) { + m := &curtailableMeter{percent: 100, setErr: errors.New("nope")} + site := curtailSite(m) + + require.Error(t, site.curtailPV(new(60))) + require.Error(t, site.curtailPV(new(60))) + assert.Equal(t, []int{60, 60}, m.setCalls) + + m.setErr = nil + require.NoError(t, site.curtailPV(new(60))) + require.NoError(t, site.curtailPV(new(60))) + assert.Equal(t, []int{60, 60, 60}, m.setCalls) +} + +// nil percent means the HEMS makes no statement and must not touch the cache. +func TestCurtailPVNoStatement(t *testing.T) { + m := &curtailableMeter{percent: 100} + site := curtailSite(m) + + require.NoError(t, site.curtailPV(new(60))) + require.NoError(t, site.curtailPV(nil)) + require.NoError(t, site.curtailPV(new(60))) + assert.Equal(t, []int{60}, m.setCalls) +} + +// dimmableMeter counts device interactions to verify caching. +type dimmableMeter struct { + api.Meter + dimmed bool + dimErr error + gets int + dimCalls []bool +} + +func (m *dimmableMeter) Dimmed() (bool, error) { + m.gets++ + return m.dimmed, nil +} + +func (m *dimmableMeter) Dim(dim bool) error { + m.dimCalls = append(m.dimCalls, dim) + if m.dimErr != nil { + return m.dimErr + } + m.dimmed = dim + return nil +} + +func TestDimMetersCache(t *testing.T) { + m := &dimmableMeter{} + site := &Site{ + log: util.NewLogger("foo"), + auxMeters: []config.Device[api.Meter]{config.NewStaticDevice(config.Named{}, api.Meter(m))}, + } + + require.NoError(t, site.dimMeters(true)) + assert.Equal(t, []bool{true}, m.dimCalls) + assert.Equal(t, 1, m.gets) + + for range 3 { + require.NoError(t, site.dimMeters(true)) + } + assert.Equal(t, []bool{true}, m.dimCalls) + assert.Equal(t, 1, m.gets) + + require.NoError(t, site.dimMeters(false)) + assert.Equal(t, []bool{true, false}, m.dimCalls) + + // failed write is retried + m.dimErr = errors.New("nope") + require.Error(t, site.dimMeters(true)) + require.Error(t, site.dimMeters(true)) + assert.Equal(t, []bool{true, false, true, true}, m.dimCalls) +}