From 1005f075a2d2a5faf8f234050c761e09646e223a Mon Sep 17 00:00:00 2001 From: andig Date: Mon, 29 Jun 2026 11:54:53 +0200 Subject: [PATCH] EEBus: add OHPCF heat pump compressor flexibility (#30636) --- api/api.go | 7 +- api/mock.go | 44 +- charger/eebus-ohpcf.go | 457 ++++++++++++++++++ charger/eebus-ohpcf_test.go | 69 +++ core/loadpoint_effective.go | 14 + core/loadpoint_effective_test.go | 23 + go.mod | 4 +- go.sum | 4 +- server/eebus/eebus.go | 9 +- server/eebus/scenarios.go | 5 + templates/definition/charger/eebus-ohpcf.yaml | 27 ++ 11 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 charger/eebus-ohpcf.go create mode 100644 charger/eebus-ohpcf_test.go create mode 100644 templates/definition/charger/eebus-ohpcf.yaml diff --git a/api/api.go b/api/api.go index 7bb11c8b2..fc4eb53e6 100644 --- a/api/api.go +++ b/api/api.go @@ -9,7 +9,7 @@ import ( "golang.org/x/oauth2" ) -//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff +//go:generate go tool mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // Meter provides total active power in W type Meter interface { @@ -192,6 +192,11 @@ type CurrentLimiter interface { GetMinMaxCurrent() (float64, float64, error) } +// PowerLimiter returns the power limits in W +type PowerLimiter interface { + GetMinMaxPower() (float64, float64, error) +} + // SocLimiter returns the soc limit type SocLimiter interface { GetLimitSoc() (int64, error) diff --git a/api/mock.go b/api/mock.go index 549bffcca..86663c9a0 100644 --- a/api/mock.go +++ b/api/mock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff) +// Source: github.com/evcc-io/evcc/api (interfaces: Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff) // // Generated by this command: // -// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff +// mockgen -package api -destination mock.go github.com/evcc-io/evcc/api Charger,ChargeState,CurrentLimiter,PowerLimiter,CurrentGetter,PhaseSwitcher,PhaseGetter,FeatureDescriber,Identifier,Meter,MeterEnergy,MeterReturnEnergy,PhaseCurrents,Vehicle,ConnectionTimer,ChargeRater,Battery,BatteryController,BatterySocLimiter,Circuit,Dimmer,HEMS,Tariff // // Package api is a generated GoMock package. @@ -177,6 +177,46 @@ func (mr *MockCurrentLimiterMockRecorder) GetMinMaxCurrent() *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinMaxCurrent", reflect.TypeOf((*MockCurrentLimiter)(nil).GetMinMaxCurrent)) } +// MockPowerLimiter is a mock of PowerLimiter interface. +type MockPowerLimiter struct { + ctrl *gomock.Controller + recorder *MockPowerLimiterMockRecorder + isgomock struct{} +} + +// MockPowerLimiterMockRecorder is the mock recorder for MockPowerLimiter. +type MockPowerLimiterMockRecorder struct { + mock *MockPowerLimiter +} + +// NewMockPowerLimiter creates a new mock instance. +func NewMockPowerLimiter(ctrl *gomock.Controller) *MockPowerLimiter { + mock := &MockPowerLimiter{ctrl: ctrl} + mock.recorder = &MockPowerLimiterMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockPowerLimiter) EXPECT() *MockPowerLimiterMockRecorder { + return m.recorder +} + +// GetMinMaxPower mocks base method. +func (m *MockPowerLimiter) GetMinMaxPower() (float64, float64, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "GetMinMaxPower") + ret0, _ := ret[0].(float64) + ret1, _ := ret[1].(float64) + ret2, _ := ret[2].(error) + return ret0, ret1, ret2 +} + +// GetMinMaxPower indicates an expected call of GetMinMaxPower. +func (mr *MockPowerLimiterMockRecorder) GetMinMaxPower() *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetMinMaxPower", reflect.TypeOf((*MockPowerLimiter)(nil).GetMinMaxPower)) +} + // MockCurrentGetter is a mock of CurrentGetter interface. type MockCurrentGetter struct { ctrl *gomock.Controller diff --git a/charger/eebus-ohpcf.go b/charger/eebus-ohpcf.go new file mode 100644 index 000000000..4a3360268 --- /dev/null +++ b/charger/eebus-ohpcf.go @@ -0,0 +1,457 @@ +package charger + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + eebusapi "github.com/enbility/eebus-go/api" + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/enbility/eebus-go/usecases/cem/ohpcf" + "github.com/enbility/eebus-go/usecases/ma/mdt" + "github.com/enbility/eebus-go/usecases/ma/mpc" + spineapi "github.com/enbility/spine-go/api" + "github.com/enbility/spine-go/model" + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/server/eebus" + "github.com/evcc-io/evcc/util" +) + +// EEBusOHPCF controls a remote heat pump compressor via the EEBus OHPCF use case +// (Optimization of Self-Consumption by Heat Pump Compressor Flexibility). +// +// The compressor announces an optional power consumption that the CEM may +// schedule, pause, resume or abort. evcc models this as an on/off switch: +// enabling the charger schedules or resumes the optional consumption, disabling +// it pauses or aborts the running process. +type EEBusOHPCF struct { + *embed + cem *eebus.CustomerEnergyManagement + ma *eebus.MonitoringAppliance + + ctx context.Context + reboost time.Duration + + mu sync.RWMutex + log *util.Logger + compressor spineapi.EntityRemoteInterface + mpcEntity spineapi.EntityRemoteInterface + dhwEntity spineapi.EntityRemoteInterface + enabled bool + reboosting bool + + connector *eebus.Connector +} + +// errNotConnected is returned whenever the compressor entity is not (yet) available. +var errNotConnected = errors.New("not connected") + +func init() { + registry.AddCtx("eebus-ohpcf", NewEEBusOHPCFFromConfig) +} + +// NewEEBusOHPCFFromConfig creates an EEBus OHPCF charger from generic config +func NewEEBusOHPCFFromConfig(ctx context.Context, other map[string]any) (api.Charger, error) { + cc := struct { + embed `mapstructure:",squash"` + Ski string + Ip string + Reboost time.Duration + }{ + embed: embed{ + Features_: []api.Feature{api.Heating, api.IntegratedDevice}, + }, + Reboost: 10 * time.Minute, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return NewEEBusOHPCF(ctx, &cc.embed, cc.Ski, cc.Ip, cc.Reboost) +} + +// NewEEBusOHPCF creates an EEBus OHPCF charger, registers it with the EEBus +// instance and waits for the connection. +func NewEEBusOHPCF(ctx context.Context, embed *embed, ski, ip string, reboost time.Duration) (api.Charger, error) { + inst, err := eebus.Instance() + if err != nil { + return nil, err + } + + c := &EEBusOHPCF{ + embed: embed, + log: util.NewLogger("eebus-ohpcf"), + cem: inst.CustomerEnergyManagement(), + ma: inst.MonitoringAppliance(), + connector: eebus.NewConnector(), + ctx: ctx, + reboost: reboost, + } + + if err := inst.RegisterDevice(ski, ip, c); err != nil { + return nil, err + } + + if err := c.connector.Wait(ctx); err != nil { + inst.UnregisterDevice(ski, c) + return nil, err + } + + // unregister device when context is cancelled (e.g. UI config validation) + go func() { + <-ctx.Done() + inst.UnregisterDevice(ski, c) + }() + + return c, nil +} + +var _ eebus.Device = (*EEBusOHPCF)(nil) + +// Connect implements the eebus.Device interface +func (c *EEBusOHPCF) Connect(connected bool) { + c.connector.Connect(connected) + + if connected { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + c.compressor = nil + c.mpcEntity = nil + c.dhwEntity = nil +} + +// UseCaseEvent implements the eebus.Device interface +func (c *EEBusOHPCF) UseCaseEvent(_ spineapi.DeviceRemoteInterface, entity spineapi.EntityRemoteInterface, event eebusapi.EventType) { + // device/entity removal fires the use case update event with a nil entity + if entity == nil { + return + } + + switch event { + case ohpcf.UseCaseSupportUpdate, + ohpcf.DataUpdateRequestedPowerEstimate, + ohpcf.DataUpdateRequestedPowerMax, + ohpcf.DataUpdateConsumptionIsStoppable, + ohpcf.DataUpdateConsumptionIsPausable, + ohpcf.DataUpdateConsumptionStartTime, + ohpcf.DataUpdateConsumptionState, + ohpcf.DataUpdateMinimalRunDuration, + ohpcf.DataUpdateMinimalPauseDuration: + c.mu.Lock() + c.compressor = entity + c.mu.Unlock() + + // Monitoring Appliance MPC provides the measured power consumption + case mpc.UseCaseSupportUpdate: + c.mu.Lock() + // use most specific selector + if c.mpcEntity == nil || len(entity.Address().Entity) < len(c.mpcEntity.Address().Entity) { + c.mpcEntity = entity + } + c.mu.Unlock() + + // Monitoring Appliance MDT provides the DHW temperature + case mdt.UseCaseSupportUpdate, mdt.DataUpdateTemperature: + c.mu.Lock() + c.dhwEntity = entity + c.mu.Unlock() + } +} + +func (c *EEBusOHPCF) connectedCompressor() (spineapi.EntityRemoteInterface, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.compressor, c.compressor != nil +} + +func (c *EEBusOHPCF) setEnabled(enabled bool) { + c.mu.Lock() + defer c.mu.Unlock() + + c.enabled = enabled +} + +func (c *EEBusOHPCF) lastEnabled() bool { + c.mu.RLock() + defer c.mu.RUnlock() + + return c.enabled +} + +// ohpcfStatus maps the compressor process state to a charge status: running is +// consuming (C), any other connected state is standby (B). Disconnected (A) is handled in Status. +func ohpcfStatus(state ucapi.CompressorPowerConsumptionStateType) api.ChargeStatus { + if state == ucapi.CompressorPowerConsumptionStateRunning { + return api.StatusC + } + return api.StatusB +} + +var _ api.Charger = (*EEBusOHPCF)(nil) + +// Status implements the api.Charger interface +func (c *EEBusOHPCF) Status() (api.ChargeStatus, error) { + entity, ok := c.connectedCompressor() + if !ok { + return api.StatusNone, errNotConnected + } + + state, err := c.cem.OHPCF.PowerConsumptionProcessState(entity) + if err != nil { + // connected but no flexibility announced yet: standby, not disconnected + return api.StatusB, nil + } + + return ohpcfStatus(state), nil +} + +// Enabled reports the commanded on/off intent; Status reflects the actual +// compressor state. +func (c *EEBusOHPCF) Enabled() (bool, error) { + if _, ok := c.connectedCompressor(); !ok { + return false, errNotConnected + } + + return c.lastEnabled(), nil +} + +// Enable schedules/resumes the optional consumption when on, pauses/aborts it +// when off; while on a reboost loop reschedules newly announced consumption. +func (c *EEBusOHPCF) Enable(enable bool) error { + c.setEnabled(enable) + + if enable { + c.startReboost() + } + + return c.apply() +} + +// startReboost launches the reboost loop, unless one is already running or no +// reboost interval is configured. +func (c *EEBusOHPCF) startReboost() { + if c.reboost <= 0 { + return + } + + c.mu.Lock() + defer c.mu.Unlock() + + if c.reboosting { + return + } + + c.reboosting = true + go c.reboostLoop() +} + +// reboostLoop reschedules a freshly announced optional consumption after each +// reboost interval; it exits when the charger is disabled or the context ends. +func (c *EEBusOHPCF) reboostLoop() { + defer func() { + c.mu.Lock() + c.reboosting = false + c.mu.Unlock() + }() + + for { + select { + case <-c.ctx.Done(): + return + case <-time.After(c.reboost): + if !c.lastEnabled() { + return + } + if err := c.apply(); err != nil { + c.log.DEBUG.Printf("reboost: %v", err) + } + } + } +} + +type ohpcfAction int + +const ( + ohpcfNone ohpcfAction = iota + ohpcfSchedule + ohpcfResume + ohpcfStop +) + +// ohpcfControlAction returns the command needed to reach the desired on/off +// state; it returns an action only on a state transition, so repeats are no-ops. +func ohpcfControlAction(state ucapi.CompressorPowerConsumptionStateType, enable bool) ohpcfAction { + if enable { + switch state { + case ucapi.CompressorPowerConsumptionStateAvailable: + return ohpcfSchedule + case ucapi.CompressorPowerConsumptionStatePaused: + return ohpcfResume + } + return ohpcfNone + } + + switch state { + case ucapi.CompressorPowerConsumptionStateRunning, + ucapi.CompressorPowerConsumptionStateScheduled: + return ohpcfStop + } + + return ohpcfNone +} + +// stop pauses the optional consumption if the compressor permits it, otherwise +// it aborts the process. +func (c *EEBusOHPCF) stop(entity spineapi.EntityRemoteInterface) error { + if pausable, err := c.cem.OHPCF.ConsumptionIsPausable(entity); err == nil && pausable { + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.PausePowerConsumptionProcess(entity, cb) + }) + } + + if stoppable, err := c.cem.OHPCF.ConsumptionIsStoppable(entity); err == nil && stoppable { + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.AbortPowerConsumptionProcess(entity, cb) + }) + } + + return api.ErrNotAvailable +} + +// ohpcfWriteTimeout bounds how long a control write waits for its result. +const ohpcfWriteTimeout = 10 * time.Second + +// await runs a control write and waits for the heat pump's result, returning an +// error if the write is rejected or no result arrives within the timeout. +func (c *EEBusOHPCF) await(write func(func(model.ResultDataType)) (*model.MsgCounterType, error)) error { + res := make(chan model.ResultDataType, 1) + + if _, err := write(func(r model.ResultDataType) { res <- r }); err != nil { + return err + } + + select { + case r := <-res: + if r.ErrorNumber != nil && *r.ErrorNumber != 0 { + err := fmt.Errorf("write rejected: %d", *r.ErrorNumber) + if r.Description != nil { + err = fmt.Errorf("%w (%s)", err, *r.Description) + } + c.log.ERROR.Println(err) + return err + } + return nil + case <-time.After(ohpcfWriteTimeout): + return errors.New("write result timeout") + } +} + +// MaxCurrent implements the api.Charger interface. OHPCF is on/off and cannot +// be modulated, so the offered current is ignored. +func (c *EEBusOHPCF) MaxCurrent(int64) error { + return c.apply() +} + +// apply issues the command to align the optional consumption with the on/off +// intent. It is idempotent: ohpcfControlAction only acts on a state transition. +func (c *EEBusOHPCF) apply() error { + entity, ok := c.connectedCompressor() + if !ok { + return errNotConnected + } + + state, err := c.cem.OHPCF.PowerConsumptionProcessState(entity) + if err != nil { + // no process state announced yet, nothing to control + return nil + } + + switch ohpcfControlAction(state, c.lastEnabled()) { + case ohpcfSchedule: + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + // 0 = start immediately (relative schedule, see SchedulePowerConsumptionProcess) + return c.cem.OHPCF.SchedulePowerConsumptionProcess(entity, 0, cb) + }) + case ohpcfResume: + return c.await(func(cb func(model.ResultDataType)) (*model.MsgCounterType, error) { + return c.cem.OHPCF.ResumePowerConsumptionProcess(entity, cb) + }) + case ohpcfStop: + return c.stop(entity) + } + + return nil +} + +var _ api.PowerLimiter = (*EEBusOHPCF)(nil) + +// GetMinMaxPower implements the api.PowerLimiter interface, reporting the +// announced optional consumption (estimate..max) or ErrNotAvailable if none. +func (c *EEBusOHPCF) GetMinMaxPower() (float64, float64, error) { + entity, ok := c.connectedCompressor() + if !ok { + return 0, 0, errNotConnected + } + + estimate, err := c.cem.OHPCF.RequestedPowerEstimate(entity) + if err != nil { + return 0, 0, api.ErrNotAvailable + } + + maxPower, err := c.cem.OHPCF.RequestedPowerMax(entity) + if err != nil { + maxPower = estimate + } + + return estimate, maxPower, nil +} + +var _ api.Meter = (*EEBusOHPCF)(nil) + +// CurrentPower implements the api.Meter interface and reports the heat pump's +// measured power consumption via the MPC use case. +func (c *EEBusOHPCF) CurrentPower() (float64, error) { + c.mu.RLock() + entity := c.mpcEntity + c.mu.RUnlock() + + if entity == nil || !c.ma.MaMPCInterface.IsScenarioAvailableAtEntity(entity, eebus.MPCPower) { + return 0, api.ErrNotAvailable + } + + power, err := c.ma.MaMPCInterface.Power(entity) + if err != nil { + return 0, eebus.WrapError(err) + } + + return power, nil +} + +var _ api.Battery = (*EEBusOHPCF)(nil) + +// Soc implements the api.Battery interface and reports the heat pump's domestic +// hot water temperature in °C via the MDT use case. +func (c *EEBusOHPCF) Soc() (float64, error) { + c.mu.RLock() + entity := c.dhwEntity + c.mu.RUnlock() + + if entity == nil || !c.ma.MaMDTInterface.IsScenarioAvailableAtEntity(entity, eebus.MDTTemperature) { + return 0, api.ErrNotAvailable + } + + temp, err := c.ma.MaMDTInterface.Temperature(entity, model.UnitOfMeasurementTypedegC) + if err != nil { + return 0, eebus.WrapError(err) + } + + return temp, nil +} diff --git a/charger/eebus-ohpcf_test.go b/charger/eebus-ohpcf_test.go new file mode 100644 index 000000000..7f34c5303 --- /dev/null +++ b/charger/eebus-ohpcf_test.go @@ -0,0 +1,69 @@ +package charger + +import ( + "testing" + + ucapi "github.com/enbility/eebus-go/usecases/api" + "github.com/evcc-io/evcc/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// methods accessing the compressor entity must error when it is absent, +// so a missing compressor is not mistaken for an idle device. +func TestEEBusOHPCFNotConnected(t *testing.T) { + c := &EEBusOHPCF{} + + status, err := c.Status() + require.ErrorIs(t, err, errNotConnected) + assert.Equal(t, api.StatusNone, status) + + _, err = c.Enabled() + require.ErrorIs(t, err, errNotConnected) + + require.ErrorIs(t, c.Enable(true), errNotConnected) + require.ErrorIs(t, c.MaxCurrent(16), errNotConnected) +} + +// status mapping: running is C, every other connected state (incl. completed +// and stopped after a boost) is standby B, never disconnected. +func TestOHPCFStatus(t *testing.T) { + tc := []struct { + state ucapi.CompressorPowerConsumptionStateType + want api.ChargeStatus + }{ + {ucapi.CompressorPowerConsumptionStateRunning, api.StatusC}, + {ucapi.CompressorPowerConsumptionStateAvailable, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateScheduled, api.StatusB}, + {ucapi.CompressorPowerConsumptionStatePaused, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateCompleted, api.StatusB}, + {ucapi.CompressorPowerConsumptionStateStopped, api.StatusB}, + } + + for _, tc := range tc { + assert.Equal(t, tc.want, ohpcfStatus(tc.state), "%v", tc.state) + } +} + +// on/off control: enable schedules/resumes, disable stops, and an already +// running/scheduled process issues no further command. +func TestOHPCFControlAction(t *testing.T) { + tc := []struct { + state ucapi.CompressorPowerConsumptionStateType + enable bool + want ohpcfAction + }{ + {ucapi.CompressorPowerConsumptionStateAvailable, true, ohpcfSchedule}, + {ucapi.CompressorPowerConsumptionStatePaused, true, ohpcfResume}, + {ucapi.CompressorPowerConsumptionStateScheduled, true, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStateRunning, true, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStateRunning, false, ohpcfStop}, + {ucapi.CompressorPowerConsumptionStateScheduled, false, ohpcfStop}, + {ucapi.CompressorPowerConsumptionStateAvailable, false, ohpcfNone}, + {ucapi.CompressorPowerConsumptionStatePaused, false, ohpcfNone}, + } + + for _, tc := range tc { + assert.Equal(t, tc.want, ohpcfControlAction(tc.state, tc.enable), "%v enable=%v", tc.state, tc.enable) + } +} diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go index 786d8be00..822dba67f 100644 --- a/core/loadpoint_effective.go +++ b/core/loadpoint_effective.go @@ -161,6 +161,14 @@ func (lp *Loadpoint) effectiveMinCurrent() float64 { } } + // power-limited chargers (e.g. EEBus OHPCF heat pump) report their demand in + // W; convert to per-phase current so the PV enable gate covers it + if c, ok := api.Cap[api.PowerLimiter](lp.charger); ok { + if res, _, err := c.GetMinMaxPower(); err == nil && res > 0 { + chargerMin = res / (Voltage * float64(lp.minActivePhases())) + } + } + switch { case max(vehicleMin, chargerMin) == 0: return lpMin @@ -187,6 +195,12 @@ func (lp *Loadpoint) effectiveMaxCurrent() float64 { } } + if c, ok := api.Cap[api.PowerLimiter](lp.charger); ok { + if _, res, err := c.GetMinMaxPower(); err == nil && res > 0 { + maxCurrent = min(maxCurrent, res/(Voltage*float64(lp.maxActivePhases()))) + } + } + return maxCurrent } diff --git a/core/loadpoint_effective_test.go b/core/loadpoint_effective_test.go index 2b6dc2dd4..47a3a7ae2 100644 --- a/core/loadpoint_effective_test.go +++ b/core/loadpoint_effective_test.go @@ -72,6 +72,29 @@ func TestEffectiveMinMaxCurrent(t *testing.T) { } } +func TestEffectivePowerLimiter(t *testing.T) { + Voltage = 230 + ctrl := gomock.NewController(t) + + lp := NewLoadpoint(util.NewLogger("foo"), nil) + phases := float64(lp.minActivePhases()) // == maxActivePhases for default lp + + powerLimiter := api.NewMockPowerLimiter(ctrl) + // min 10A, max 12A worth of power across all phases + powerLimiter.EXPECT().GetMinMaxPower().Return(230*phases*10, 230*phases*12, nil).AnyTimes() + + lp.charger = struct { + api.Charger + api.PowerLimiter + }{ + Charger: api.NewMockCharger(ctrl), + PowerLimiter: powerLimiter, + } + + assert.Equal(t, 10.0, lp.effectiveMinCurrent(), "min") + assert.Equal(t, 12.0, lp.effectiveMaxCurrent(), "max") +} + func TestNextPlan(t *testing.T) { clock := clock.NewMock() diff --git a/go.mod b/go.mod index 55bb980ab..39e0a6974 100644 --- a/go.mod +++ b/go.mod @@ -29,7 +29,7 @@ require ( github.com/denisbrodbeck/machineid v1.0.1 github.com/dylanmei/iso8601 v0.1.0 github.com/eclipse/paho.mqtt.golang v1.5.1 - github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557 + github.com/enbility/eebus-go v0.7.1-0.20260608105936-abdcf864d0cf github.com/enbility/ship-go v0.6.1-0.20260518113001-134687068e3c github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885 github.com/evcc-io/openapi-mcp v0.6.1-0.20260503092507-6199c7ad3baf @@ -261,3 +261,5 @@ tool ( replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea replace github.com/lorenzodonini/ocpp-go => github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b + +replace github.com/enbility/eebus-go => github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6 diff --git a/go.sum b/go.sum index a5235aabc..ab55839d2 100644 --- a/go.sum +++ b/go.sum @@ -136,8 +136,6 @@ github.com/eclipse/paho.golang v0.23.0 h1:KHgl2wz6EJo7cMBmkuhpt7C576vP+kpPv7jjvS github.com/eclipse/paho.golang v0.23.0/go.mod h1:nQRhTkoZv8EAiNs5UU0/WdQIx2NrnWUpL9nsGJTQN04= github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE= github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU= -github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557 h1:YQvO8wiUG1OEjFAkKtLTrCFze8KpG7YZ1TwrQKj9H54= -github.com/enbility/eebus-go v0.7.1-0.20260520155652-11bc53084557/go.mod h1:L1nBEy/dBASQRoz/9JF7KEmqFdnXHLbZqDpxiA0ezr4= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a h1:foChWb8lhzqa6lWDRs6COYMdp649YlUirFP8GqoT0JQ= github.com/enbility/go-avahi v0.0.0-20240909195612-d5de6b280d7a/go.mod h1:H64mhYcAQUGUUnVqMdZQf93kPecH4M79xwH95Lddt3U= github.com/enbility/ship-go v0.6.1-0.20260518113001-134687068e3c h1:ZNYyr/SDaAMHi6ubJqWuoId8QUu5V4xXWCIRf7VnfCY= @@ -146,6 +144,8 @@ github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885 h1:nBqjAPONpI/ github.com/enbility/spine-go v0.7.1-0.20260520153416-0104ce40c885/go.mod h1:ddWZU5BQGyzRGF20Z7rUKFFzbCp+cfu9mZgYtOMW/fM= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6 h1:XOYvxKtT1oxT37w/5oEiRLuPbm9FuJPt3fiYhX0h8Po= github.com/enbility/zeroconf/v2 v2.0.0-20240920094356-be1cae74fda6/go.mod h1:BszP9qFV14mPXgyIREbgIdQtWxbAj3OKqvK02HihMoM= +github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6 h1:ax+dfxmeF/hN3IIUXFnDw3y3BE7pr0m/699k08+Q9sM= +github.com/evcc-io/eebus-go v0.0.0-20260627085352-933afd2a4ea6/go.mod h1:L1nBEy/dBASQRoz/9JF7KEmqFdnXHLbZqDpxiA0ezr4= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea h1:F6eyC8V8wvc3ranlsR4coAls+OPBkVvxyX0a2VqdlPc= github.com/evcc-io/modbus v0.0.0-20250501165638-8b6f1fbdb7ea/go.mod h1:swrNGAVgI1r/3d/sEKE7qgujdRR9aHVPYKyc3gvpVTc= github.com/evcc-io/ocpp-go v0.0.0-20251212212612-b7f92ee0443b h1:5vFr7wOwoi5ylybUnxxBk1U3FRF2+3Sf0sKx8thxw18= diff --git a/server/eebus/eebus.go b/server/eebus/eebus.go index 57afae406..3f98ca09c 100644 --- a/server/eebus/eebus.go +++ b/server/eebus/eebus.go @@ -17,12 +17,14 @@ import ( "github.com/enbility/eebus-go/usecases/cem/evcem" "github.com/enbility/eebus-go/usecases/cem/evsecc" "github.com/enbility/eebus-go/usecases/cem/evsoc" + "github.com/enbility/eebus-go/usecases/cem/ohpcf" "github.com/enbility/eebus-go/usecases/cem/opev" "github.com/enbility/eebus-go/usecases/cem/oscev" csplc "github.com/enbility/eebus-go/usecases/cs/lpc" cslpp "github.com/enbility/eebus-go/usecases/cs/lpp" eglpc "github.com/enbility/eebus-go/usecases/eg/lpc" eglpp "github.com/enbility/eebus-go/usecases/eg/lpp" + "github.com/enbility/eebus-go/usecases/ma/mdt" "github.com/enbility/eebus-go/usecases/ma/mgcp" "github.com/enbility/eebus-go/usecases/ma/mpc" shipapi "github.com/enbility/ship-go/api" @@ -47,6 +49,7 @@ type CustomerEnergyManagement struct { EvSoc ucapi.CemEVSOCInterface OpEV ucapi.CemOPEVInterface OscEV ucapi.CemOSCEVInterface + OHPCF ucapi.CemOHPCFInterface } // Controllable System @@ -59,6 +62,7 @@ type ControllableSystem struct { type MonitoringAppliance struct { ucapi.MaMGCPInterface ucapi.MaMPCInterface + ucapi.MaMDTInterface } // Energy Guard @@ -200,12 +204,14 @@ func NewServer(other Config) (*EEBus, error) { OpEV: opev.NewOPEV(localEntity, c.ucCallback), OscEV: oscev.NewOSCEV(localEntity, c.ucCallback), EvSoc: evsoc.NewEVSOC(localEntity, c.ucCallback), + OHPCF: ohpcf.NewOHPCF(localEntity, c.ucCallback), } // monitoring appliance to meters c.ma = MonitoringAppliance{ MaMGCPInterface: mgcp.NewMGCP(localEntity, c.ucCallback), MaMPCInterface: mpc.NewMPC(localEntity, c.ucCallback), + MaMDTInterface: mdt.NewMDT(localEntity, c.ucCallback), } } @@ -240,8 +246,9 @@ func NewServer(other Config) (*EEBus, error) { c.cem.EvseCC, c.cem.EvCC, c.cem.EvCem, c.cem.OpEV, c.cem.OscEV, c.cem.EvSoc, + c.cem.OHPCF, c.cs.CsLPCInterface, c.cs.CsLPPInterface, - c.ma.MaMGCPInterface, c.ma.MaMPCInterface, + c.ma.MaMGCPInterface, c.ma.MaMPCInterface, c.ma.MaMDTInterface, c.eg.EgLPCInterface, c.eg.EgLPPInterface, } { c.service.AddUseCase(uc) diff --git a/server/eebus/scenarios.go b/server/eebus/scenarios.go index d40671f4c..84bbbaa62 100644 --- a/server/eebus/scenarios.go +++ b/server/eebus/scenarios.go @@ -29,6 +29,11 @@ const ( MPCFrequency uint = 5 // S5 frequency ) +// MDT — Monitoring of Domestic Hot Water Temperature (UC TS v1.0.0) +const ( + MDTTemperature uint = 1 // S1 DHW temperature +) + // LPC — Limitation of Power Consumption (UC TS v1.0.0). Same scenario layout for CS and EG roles. const ( LPCLimit uint = 1 // S1 LoadControl: consumption limit diff --git a/templates/definition/charger/eebus-ohpcf.yaml b/templates/definition/charger/eebus-ohpcf.yaml new file mode 100644 index 000000000..e933432d6 --- /dev/null +++ b/templates/definition/charger/eebus-ohpcf.yaml @@ -0,0 +1,27 @@ +template: eebus-ohpcf +products: + - description: + de: EEBUS Wärmepumpe (OHPCF) + en: EEBUS heat pump (OHPCF) +group: heating +capabilities: ["meter"] +params: + - preset: eebus + - name: reboost + type: duration + default: 10m + advanced: true + description: + de: Reboost-Intervall + en: Reboost interval + help: + de: Wartezeit, nach der eine neu angekündigte optionale Leistungsaufnahme erneut eingeplant wird. + en: Wait time after which a newly announced optional power consumption is scheduled again. +render: | + type: eebus-ohpcf + ski: {{ .ski }} + {{ if .ip }}ip: {{ .ip }}{{ end }} + reboost: {{ .reboost }} + features: + - heating + - integrateddevice