evcc-io/hems/eebus/eebus.go

275 lines
7.4 KiB
Go

package eebus
import (
"context"
"errors"
"sync"
"time"
ucapi "github.com/enbility/eebus-go/usecases/api"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/core/site"
"github.com/evcc-io/evcc/hems/smartgrid"
"github.com/evcc-io/evcc/plugin"
"github.com/evcc-io/evcc/server/eebus"
"github.com/evcc-io/evcc/util"
)
type EEBus struct {
mux sync.RWMutex
log *util.Logger
*eebus.Connector
cs *eebus.ControllableSystem
root api.Circuit
passthrough func(bool) error
status status
statusUpdated time.Time
failsafeDuration time.Duration
smartgridConsumptionId uint
consumptionLimit ucapi.LoadLimit // LPC-041
consumptionLimitActivated time.Time
failsafeConsumptionLimit float64
smartgridProductionId uint
productionLimit ucapi.LoadLimit
productionLimitActivated time.Time
failsafeProductionLimit float64
heartbeat *util.Value[struct{}]
interval time.Duration
}
type Limits struct {
ContractualConsumptionNominalMax float64
FailsafeConsumptionActivePowerLimit float64
ProductionNominalMax float64
FailsafeProductionActivePowerLimit float64
FailsafeDurationMinimum time.Duration
}
// NewFromConfig creates an EEBus HEMS from generic config
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*EEBus, error) {
cc := struct {
Ski string
Limits `mapstructure:",squash"`
Passthrough *plugin.Config
Interval time.Duration
}{
Limits: Limits{
ContractualConsumptionNominalMax: 24800,
FailsafeConsumptionActivePowerLimit: 4200,
ProductionNominalMax: 0,
FailsafeProductionActivePowerLimit: 0,
FailsafeDurationMinimum: 2 * time.Hour,
},
Interval: 10 * time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
passthroughS, err := cc.Passthrough.BoolSetter(ctx, "dim")
if err != nil {
return nil, err
}
// setup grid control circuit
gridcontrol, err := smartgrid.SetupCircuit()
if err != nil {
return nil, err
}
site.SetCircuit(gridcontrol)
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, gridcontrol, cc.Interval)
}
// NewEEBus creates EEBus HEMS
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, root api.Circuit, interval time.Duration) (*EEBus, error) {
if eebus.Instance == nil {
return nil, errors.New("eebus not configured")
}
c := &EEBus{
log: util.NewLogger("eebus"),
root: root,
passthrough: passthrough,
cs: eebus.Instance.ControllableSystem(),
Connector: eebus.NewConnector(),
heartbeat: util.NewValue[struct{}](2 * time.Minute), // LPC-031
interval: interval,
failsafeDuration: limits.FailsafeDurationMinimum,
failsafeConsumptionLimit: limits.FailsafeConsumptionActivePowerLimit,
failsafeProductionLimit: limits.FailsafeProductionActivePowerLimit,
}
// simulate a received heartbeat
// otherwise a heartbeat timeout is assumed when the state machine is called for the first time
c.heartbeat.Set(struct{}{})
if err := eebus.Instance.RegisterDevice(ski, "", c); err != nil {
return nil, err
}
if err := c.Wait(ctx); err != nil {
eebus.Instance.UnregisterDevice(ski, c)
return nil, err
}
// controllable system
eebus.LogEntities(c.log.DEBUG, "CS LPC", c.cs.CsLPCInterface)
eebus.LogEntities(c.log.DEBUG, "CS LPP", c.cs.CsLPPInterface)
// set initial values
if err := c.cs.CsLPCInterface.SetConsumptionNominalMax(limits.ContractualConsumptionNominalMax); err != nil {
c.log.ERROR.Println("CS LPC SetConsumptionNominalMax:", err)
}
if c.failsafeConsumptionLimit > 0 {
if err := c.cs.CsLPCInterface.SetFailsafeConsumptionActivePowerLimit(c.failsafeConsumptionLimit, true); err != nil {
c.log.ERROR.Println("CS LPC SetFailsafeConsumptionActivePowerLimit:", err)
}
}
if err := c.cs.CsLPPInterface.SetProductionNominalMax(limits.ProductionNominalMax); err != nil {
c.log.ERROR.Println("CS LPP SetProductionNominalMax:", err)
}
if c.failsafeProductionLimit > 0 {
if err := c.cs.CsLPPInterface.SetFailsafeProductionActivePowerLimit(c.failsafeProductionLimit, true); err != nil {
c.log.ERROR.Println("CS LPP SetFailsafeProductionActivePowerLimit:", err)
}
}
if c.failsafeDuration > 0 {
if err := c.cs.CsLPCInterface.SetFailsafeDurationMinimum(c.failsafeDuration, true); err != nil {
c.log.ERROR.Println("CS LPC SetFailsafeDurationMinimum:", err)
}
if err := c.cs.CsLPPInterface.SetFailsafeDurationMinimum(c.failsafeDuration, true); err != nil {
c.log.ERROR.Println("CS LPP SetFailsafeDurationMinimum:", err)
}
}
return c, nil
}
func (c *EEBus) Run() {
for range time.Tick(c.interval) {
if err := c.run(); err != nil {
c.log.ERROR.Println(err)
}
}
}
func (c *EEBus) run() error {
c.mux.Lock()
defer c.mux.Unlock()
c.log.TRACE.Println("status:", c.status)
// check heartbeat
_, heartbeatErr := c.heartbeat.Get()
if heartbeatErr != nil && c.status != StatusFailsafe {
// LPC-914/2
c.log.WARN.Println("missing heartbeat- entering failsafe mode")
c.setStatusAndLimit(StatusFailsafe, c.failsafeConsumptionLimit, c.failsafeProductionLimit)
return nil
}
if c.status == StatusFailsafe {
// LPC-914/2
if heartbeatErr != nil || time.Since(c.statusUpdated) <= c.failsafeDuration {
return nil
}
c.log.DEBUG.Println("heartbeat returned or failsafe duration exceeded- leaving failsafe mode")
c.setStatusAndLimit(StatusNormal, 0, 0)
}
// LPC-914/1
if c.consumptionLimitActivated.IsZero() {
if c.consumptionLimit.IsActive {
c.log.WARN.Println("activating consumption limit")
c.setConsumptionLimit(c.consumptionLimit.Value)
}
} else {
if time.Since(c.consumptionLimitActivated) > c.consumptionLimit.Duration {
c.log.DEBUG.Println("consumption limit duration exceeded")
c.setConsumptionLimit(0)
c.consumptionLimit.IsActive = false
}
}
// LPP
if c.productionLimitActivated.IsZero() {
if c.productionLimit.IsActive {
c.log.WARN.Println("activating production limit")
c.setProductionLimit(c.productionLimit.Value, true)
}
} else {
if time.Since(c.productionLimitActivated) > c.productionLimit.Duration {
c.log.DEBUG.Println("production limit duration exceeded")
c.setProductionLimit(0, false)
c.productionLimit.IsActive = false
}
}
return nil
}
func (c *EEBus) setStatusAndLimit(status status, consumption, production float64) {
c.status = status
c.statusUpdated = time.Now()
c.setConsumptionLimit(consumption)
c.setProductionLimit(production, true)
}
func (c *EEBus) setConsumptionLimit(limit float64) {
active := limit > 0
if active {
c.consumptionLimitActivated = time.Now()
} else {
c.consumptionLimitActivated = time.Time{}
}
c.root.Dim(active)
c.root.SetMaxPower(limit)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
if c.passthrough != nil {
if err := c.passthrough(limit > 0); err != nil {
c.log.ERROR.Printf("passthrough failed: %v", err)
}
}
}
func (c *EEBus) setProductionLimit(limit float64, active bool) {
if active {
c.productionLimitActivated = time.Now()
} else {
c.productionLimitActivated = time.Time{}
}
c.root.Curtail(active)
// TODO make ProductionNominalMax configurable (Site kWp)
// c.root.SetMaxProduction(limit)
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.root.GetChargePower(), limit, active); err != nil {
c.log.ERROR.Printf("smartgrid session: %v", err)
}
}