evcc-io/charger/eebus-ohpcf.go

487 lines
13 KiB
Go

package charger
import (
"context"
"errors"
"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/eg/lpc"
"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
eg *eebus.EnergyGuard
ctx context.Context
reboost time.Duration
mu sync.RWMutex
log *util.Logger
compressor spineapi.EntityRemoteInterface
mpcEntity spineapi.EntityRemoteInterface
dhwEntity spineapi.EntityRemoteInterface
egLpcEntity 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{
Icon_: "heatpump",
Features_: []api.Feature{api.Continuous, api.Heating, api.IntegratedDevice, api.SwitchDevice},
},
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(),
eg: inst.EnergyGuard(),
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
c.egLpcEntity = 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()
// Energy Guard LPC carries the §14a/LPC consumption limit
case lpc.UseCaseSupportUpdate:
c.mu.Lock()
// use most specific selector
if c.egLpcEntity == nil || len(entity.Address().Entity) < len(c.egLpcEntity.Address().Entity) {
c.egLpcEntity = 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 eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
return c.cem.OHPCF.PausePowerConsumptionProcess(entity, cb)
})
}
if stoppable, err := c.cem.OHPCF.ConsumptionIsStoppable(entity); err == nil && stoppable {
return eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
return c.cem.OHPCF.AbortPowerConsumptionProcess(entity, cb)
})
}
return api.ErrNotAvailable
}
// 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()
}
var _ api.Dimmer = (*EEBusOHPCF)(nil)
// Dimmed implements the api.Dimmer interface, reporting whether a §14a/LPC
// consumption limit is currently active on the heat pump.
func (c *EEBusOHPCF) Dimmed() (bool, error) {
c.mu.RLock()
entity := c.egLpcEntity
c.mu.RUnlock()
if entity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(entity, eebus.LPCLimit) {
return false, api.ErrNotAvailable
}
limit, err := c.eg.EgLPCInterface.ConsumptionLimit(entity)
if err != nil {
// scenario announced but no usable value yet
if errors.Is(err, eebusapi.ErrDataNotAvailable) ||
errors.Is(err, eebusapi.ErrMetadataNotAvailable) ||
errors.Is(err, eebusapi.ErrDataInvalid) {
return false, api.ErrNotAvailable
}
return false, err
}
// an active limit means dimmed; the applied §14a limit value is 0W, so a
// value-based check would never report the dimmed state and never release it
return limit.IsActive, nil
}
// Dim implements the api.Dimmer interface. It writes a §14a/LPC consumption
// limit (fixed 0W safe limit) to the heat pump while dimmed, releasing it otherwise.
func (c *EEBusOHPCF) Dim(dim bool) error {
c.mu.RLock()
entity := c.egLpcEntity
c.mu.RUnlock()
if entity == nil || !c.eg.EgLPCInterface.IsScenarioAvailableAtEntity(entity, eebus.LPCLimit) {
return api.ErrNotAvailable
}
// TODO: change api.Dimmer to make the limit configurable; use a fixed 0W safe limit for now
return eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
return c.eg.EgLPCInterface.WriteConsumptionLimit(entity, ucapi.LoadLimit{Value: 0, IsActive: dim}, cb)
})
}
// 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 eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*model.MsgCounterType, error) {
// 0 = start immediately (relative schedule, see SchedulePowerConsumptionProcess)
return c.cem.OHPCF.SchedulePowerConsumptionProcess(entity, 0, cb)
})
case ohpcfResume:
return eebus.Await(func(cb func(model.ResultDataType, model.MsgCounterType)) (*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
// optional consumption as expected min/max or ErrNotAvailable if none.
func (c *EEBusOHPCF) GetMinMaxPower() (float64, float64, error) {
entity, ok := c.connectedCompressor()
if !ok {
return 0, 0, errNotConnected
}
if power, _ := c.cem.OHPCF.RequestedPowerEstimate(entity); power > 0 {
return power, power, nil
}
if power, _ := c.cem.OHPCF.RequestedPowerMax(entity); power > 0 {
return power, power, nil
}
return 0, 0, api.ErrNotAvailable
}
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
}