EEBus: state the limit to a controllable system on connect (#32491)

This commit is contained in:
andig 2026-08-03 18:13:37 +02:00 • committed by GitHub
parent dc1ac7c6dd
commit 4a878838e7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 175 additions and 13 deletions

View file

@ -35,6 +35,9 @@ type EEBus struct {
maEntity spineapi.EntityRemoteInterface
egLpcEntity spineapi.EntityRemoteInterface
egLppEntity spineapi.EntityRemoteInterface
dimmed bool // last limits written, re-stated on reconnect// last limits written, re-stated on reconnect
curtailPercent int
}
// maScenarios holds the spec scenario numbers for the active monitoring use case.
@ -111,12 +114,13 @@ func NewEEBus(ctx context.Context, ski, ip string, usage *templates.Usage) (api.
}
c := &EEBus{
log: util.NewLogger("eebus-" + useCase),
ma: ma,
eg: inst.EnergyGuard(),
mm: mm,
scenarios: scenarios,
connector: eebus.NewConnector(),
log: util.NewLogger("eebus-" + useCase),
ma: ma,
eg: inst.EnergyGuard(),
mm: mm,
scenarios: scenarios,
connector: eebus.NewConnector(),
curtailPercent: 100,
}
if err := inst.RegisterDevice(ski, ip, c); err != nil {
@ -166,6 +170,20 @@ func eebusReadValue[T any](uc eebusapi.UseCaseBaseInterface, entity spineapi.Ent
return res, nil
}
func (c *EEBus) lastDimmed() bool {
c.mu.Lock()
defer c.mu.Unlock()
return c.dimmed
}
func (c *EEBus) lastCurtailPercent() int {
c.mu.Lock()
defer c.mu.Unlock()
return c.curtailPercent
}
func (c *EEBus) readValue(scenario uint, update func(entity spineapi.EntityRemoteInterface) (float64, error)) (float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
@ -258,9 +276,17 @@ func (c *EEBus) Dim(dim bool) error {
return api.ErrNotAvailable
}
return eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
if err := eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
return c.eg.EgLPCInterface.WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: value, IsActive: dim}, cb)
})
}); err != nil {
return err
}
c.mu.Lock()
c.dimmed = dim
c.mu.Unlock()
return nil
}
var _ api.Curtailer = (*EEBus)(nil)
@ -311,7 +337,15 @@ func (c *EEBus) SetCurtailPercent(percent int) error {
}
}
return eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
if err := eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
return c.eg.EgLPPInterface.WriteProductionLimit(entity, ucapi.LoadLimit{Value: value, IsActive: curtail}, cb)
})
}); err != nil {
return err
}
c.mu.Lock()
c.curtailPercent = percent
c.mu.Unlock()
return nil
}

View file

@ -79,6 +79,9 @@ func (c *EEBus) egLpcUseCaseSupportUpdate(entity spineapi.EntityRemoteInterface)
// prefer the shallowest (device-level) entity
if c.egLpcEntity == nil || len(entity.Address().Entity) < len(c.egLpcEntity.Address().Entity) {
c.egLpcEntity = entity
// [LPC-913]: state the limit to the newly available CS
go eebus.AssertLimit(c.log, func() error { return c.Dim(c.lastDimmed()) })
}
}
@ -93,5 +96,8 @@ func (c *EEBus) egLppUseCaseSupportUpdate(entity spineapi.EntityRemoteInterface)
// prefer the shallowest (device-level) entity
if c.egLppEntity == nil || len(entity.Address().Entity) < len(c.egLppEntity.Address().Entity) {
c.egLppEntity = entity
// [LPP-913]: state the limit to the newly available CS
go eebus.AssertLimit(c.log, func() error { return c.SetCurtailPercent(c.lastCurtailPercent()) })
}
}

View file

@ -5,8 +5,11 @@ package meter
import (
"testing"
"time"
ucapi "github.com/enbility/eebus-go/usecases/api"
eglpc "github.com/enbility/eebus-go/usecases/eg/lpc"
eglpp "github.com/enbility/eebus-go/usecases/eg/lpp"
egmocks "github.com/enbility/eebus-go/usecases/mocks"
spineapi "github.com/enbility/spine-go/api"
spinemocks "github.com/enbility/spine-go/mocks"
@ -42,6 +45,27 @@ func ackWrite(_ spineapi.EntityRemoteInterface, _ ucapi.LoadLimit, cb func(model
cb(model.ResultDataType{}, 0)
}
// captureWrite acks a mocked write and reports the limit it carried.
func captureWrite(written chan<- ucapi.LoadLimit) func(spineapi.EntityRemoteInterface, ucapi.LoadLimit, func(model.ResultDataType, model.MsgCounterType)) {
return func(entity spineapi.EntityRemoteInterface, limit ucapi.LoadLimit, cb func(model.ResultDataType, model.MsgCounterType)) {
written <- limit
ackWrite(entity, limit, cb)
}
}
// awaitLimit waits for a limit written by the background AssertLimit retry.
func awaitLimit(t *testing.T, written <-chan ucapi.LoadLimit) ucapi.LoadLimit {
t.Helper()
select {
case limit := <-written:
return limit
case <-time.After(time.Second):
t.Fatal("no limit written after connect")
return ucapi.LoadLimit{}
}
}
// --- LPC: Dim/Dimmed (Active Power Consumption Limit) -------------------------
// ATC_COM_PT_EGMessages_001/003 (LPC-TS-001/001-2): the EG sends an activated,
@ -246,6 +270,43 @@ func TestLPP_CurtailedPercent_NoNominal(t *testing.T) {
assert.ErrorIs(t, err, api.ErrNotAvailable)
}
// ATC_COM_PT_EGMessages_002 (LPC-913/LPP-913): after initial connection the EG
// states a limit to the CS - deactivated when nothing is being limited.
func TestLPC_LPP_InitialLimit(t *testing.T) {
t.Run("consumption", func(t *testing.T) {
c, lpc, _, entity := newEGMeter(t)
c.egLpcEntity = nil
written := make(chan ucapi.LoadLimit, 1)
lpc.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPCLimit).Return(true)
lpc.EXPECT().
WriteConsumptionLimit(entity, mock.Anything, mock.Anything).
Run(captureWrite(written)).
Return(new(model.MsgCounterType), nil)
c.UseCaseEvent(nil, entity, eglpc.UseCaseSupportUpdate)
assert.Equal(t, ucapi.LoadLimit{Value: 0, IsActive: false}, awaitLimit(t, written))
})
t.Run("production", func(t *testing.T) {
c, _, lpp, entity := newEGMeter(t)
c.egLppEntity = nil
c.curtailPercent = 100
written := make(chan ucapi.LoadLimit, 1)
lpp.EXPECT().IsScenarioAvailableAtEntity(entity, eebus.LPPLimit).Return(true)
lpp.EXPECT().
WriteProductionLimit(entity, mock.Anything, mock.Anything).
Run(captureWrite(written)).
Return(new(model.MsgCounterType), nil)
c.UseCaseEvent(nil, entity, eglpp.UseCaseSupportUpdate)
assert.Equal(t, ucapi.LoadLimit{Value: 0, IsActive: false}, awaitLimit(t, written))
})
}
// TestLPC_LPP_NonCoverage records the Controllable-System and connection/heartbeat
// abstract test cases that belong to eebus-go and the evcc HEMS/charger, not the meter.
func TestLPC_LPP_NonCoverage(t *testing.T) {
@ -254,7 +315,6 @@ func TestLPC_LPP_NonCoverage(t *testing.T) {
"ATC_COM_PT_CSFS_001", // failsafe values → hems/eebus + eebus-go
"ATC_COM_PT_EGHeartbeat_001", // heartbeat cadence → eebus-go
"ATC_COM_PT_EGConnection_001", // connection setup → eebus-go
"ATC_COM_PT_EGMessages_002", // resend-after-reboot/NACK → eebus-go
} {
t.Run(atc, func(t *testing.T) {
t.Skip("not applicable: covered by eebus-go or the evcc HEMS/charger, not the grid meter")