EEBus: add OHPCF heat pump compressor flexibility (#30636)
This commit is contained in:
parent
cdb2914308
commit
1005f075a2
11 changed files with 656 additions and 7 deletions
|
|
@ -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)
|
||||
|
|
|
|||
44
api/mock.go
44
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
|
||||
|
|
|
|||
457
charger/eebus-ohpcf.go
Normal file
457
charger/eebus-ohpcf.go
Normal file
|
|
@ -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
|
||||
}
|
||||
69
charger/eebus-ohpcf_test.go
Normal file
69
charger/eebus-ohpcf_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
4
go.mod
4
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
|
||||
|
|
|
|||
4
go.sum
4
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=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
27
templates/definition/charger/eebus-ohpcf.yaml
Normal file
27
templates/definition/charger/eebus-ohpcf.yaml
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue