388 lines
11 KiB
Go
388 lines
11 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/config"
|
|
"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"
|
|
)
|
|
|
|
func init() {
|
|
config.AddCtx("eebus", NewFromConfig)
|
|
}
|
|
|
|
type EEBus struct {
|
|
mux sync.RWMutex
|
|
log *util.Logger
|
|
|
|
*eebus.Connector
|
|
cs *eebus.ControllableSystem
|
|
|
|
site site.API
|
|
passthrough func(bool) error
|
|
publishFunc func()
|
|
|
|
status status
|
|
statusUpdated time.Time
|
|
|
|
failsafeDuration time.Duration
|
|
|
|
smartgridConsumptionId uint
|
|
consumptionLimit ucapi.LoadLimit // LPC-041
|
|
consumptionLimitActivated *time.Time // nil until first connected, then always set
|
|
failsafeConsumptionLimit float64
|
|
|
|
smartgridProductionId uint
|
|
productionLimit ucapi.LoadLimit // feed-in limit (NOT production despite its name)
|
|
productionLimitActivated *time.Time // nil until first connected, then always set
|
|
failsafeProductionLimit *float64 // feed-in limit (NOT production despite its name)
|
|
productionNominalMax 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{
|
|
// contractual max power at the grid connection point reported to the control box
|
|
// (EEBus LPC, EMS device type). Default: standard 3x35A x 230V house connection.
|
|
// This is the connection capacity, not the SteuVE Pmin (see failsafe limit below).
|
|
ContractualConsumptionNominalMax: 24150, // 3 * 35A * 230V
|
|
FailsafeConsumptionActivePowerLimit: 4200,
|
|
|
|
ProductionNominalMax: 0,
|
|
FailsafeProductionActivePowerLimit: nil, // 0 is a valid limit
|
|
|
|
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
|
|
}
|
|
|
|
return NewEEBus(ctx, cc.Ski, cc.Limits, passthroughS, site, cc.Interval)
|
|
}
|
|
|
|
// NewEEBus creates EEBus HEMS
|
|
func NewEEBus(ctx context.Context, ski string, limits Limits, passthrough func(bool) error, site site.API, interval time.Duration) (*EEBus, error) {
|
|
inst, err := eebus.Instance()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
c := &EEBus{
|
|
log: util.NewLogger("eebus"),
|
|
site: site,
|
|
passthrough: passthrough,
|
|
cs: inst.ControllableSystem(),
|
|
Connector: eebus.NewConnector(),
|
|
heartbeat: util.NewValue[struct{}](2 * time.Minute), // LPC-031
|
|
interval: interval,
|
|
|
|
failsafeDuration: limits.FailsafeDurationMinimum,
|
|
failsafeConsumptionLimit: limits.FailsafeConsumptionActivePowerLimit,
|
|
failsafeProductionLimit: limits.FailsafeProductionActivePowerLimit,
|
|
productionNominalMax: limits.ProductionNominalMax,
|
|
}
|
|
|
|
// 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 := inst.RegisterDevice(ski, "", c); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := c.Wait(ctx); err != nil {
|
|
inst.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 != nil && *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) SetUpdated(f func()) {
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
c.publishFunc = f
|
|
}
|
|
|
|
// Connect overrides the embedded Connector: on first connect, limit state
|
|
// becomes valid (nil -> known). A later disconnect/reconnect is a no-op here.
|
|
func (c *EEBus) Connect(connected bool) {
|
|
c.Connector.Connect(connected)
|
|
|
|
if !connected {
|
|
return
|
|
}
|
|
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
|
|
if c.consumptionLimitActivated == nil {
|
|
c.consumptionLimitActivated = new(time.Time)
|
|
}
|
|
if c.productionLimitActivated == nil {
|
|
c.productionLimitActivated = new(time.Time)
|
|
}
|
|
}
|
|
|
|
func (c *EEBus) Run() {
|
|
for range time.Tick(c.interval) {
|
|
if err := c.run(); err != nil {
|
|
c.log.ERROR.Println(err)
|
|
}
|
|
|
|
if c.publishFunc != nil {
|
|
c.publishFunc()
|
|
}
|
|
}
|
|
}
|
|
|
|
func (c *EEBus) run() error {
|
|
c.mux.Lock()
|
|
defer c.mux.Unlock()
|
|
|
|
c.log.TRACE.Println("status:", c.status)
|
|
|
|
_, heartbeatErr := c.heartbeat.Get()
|
|
|
|
// LPC-911 / LPP-911: heartbeat lost while operating, enter failsafe.
|
|
if heartbeatErr != nil && c.status != StatusFailsafe {
|
|
c.log.WARN.Println("missing heartbeat- entering failsafe mode")
|
|
c.setStatus(StatusFailsafe)
|
|
|
|
c.setConsumptionLimit(c.failsafeConsumptionLimit)
|
|
|
|
if c.failsafeProductionLimit != nil {
|
|
// production limit is negative, failsafe limits are always positive
|
|
c.setProductionLimit(-*c.failsafeProductionLimit, true)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if c.status == StatusFailsafe {
|
|
if heartbeatErr != nil {
|
|
// LPC-921 / LPP-921: still no heartbeat - keep applying the failsafe
|
|
// limit. The failsafe limit is our self-determined protective default
|
|
// for the Unlimited-autonomous state.
|
|
return nil
|
|
}
|
|
|
|
// LPC-918/919/920 / LPP-equivalent: heartbeat returned - leave failsafe
|
|
// immediately. Fall through to the LPC-914/1 block below, which will
|
|
// apply whatever fresh limit the EG sent (or release the limit if the
|
|
// EG has not sent an active limit since the failsafe entry).
|
|
c.log.DEBUG.Println("heartbeat returned- leaving failsafe mode")
|
|
c.setStatus(StatusNormal)
|
|
|
|
c.setConsumptionLimit(0)
|
|
c.setProductionLimit(0, false)
|
|
}
|
|
|
|
// LPC-914/1
|
|
if !limitActive(c.consumptionLimitActivated) {
|
|
if c.consumptionLimit.IsActive {
|
|
c.log.WARN.Println("activating consumption limit")
|
|
c.setConsumptionLimit(c.consumptionLimit.Value)
|
|
}
|
|
} else {
|
|
switch {
|
|
case !c.consumptionLimit.IsActive:
|
|
c.log.DEBUG.Println("consumption limit released")
|
|
c.setConsumptionLimit(0)
|
|
case time.Since(*c.consumptionLimitActivated) > c.consumptionLimit.Duration:
|
|
c.log.DEBUG.Println("consumption limit duration exceeded")
|
|
c.setConsumptionLimit(0)
|
|
c.consumptionLimit.IsActive = false
|
|
}
|
|
}
|
|
|
|
// LPP
|
|
if !limitActive(c.productionLimitActivated) {
|
|
if c.productionLimit.IsActive {
|
|
if c.productionNominalMax <= 0 {
|
|
return errors.New("production limit received but productionNominalMax is not configured")
|
|
}
|
|
|
|
c.log.WARN.Println("activating production limit")
|
|
c.setProductionLimit(c.productionLimit.Value, true)
|
|
}
|
|
} else {
|
|
switch {
|
|
case !c.productionLimit.IsActive:
|
|
c.log.DEBUG.Println("production limit released")
|
|
c.setProductionLimit(0, false)
|
|
case 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
|
|
}
|
|
|
|
// limitActive reports whether t denotes a currently active limit: known (non-nil) and non-zero.
|
|
func limitActive(t *time.Time) bool {
|
|
return t != nil && !t.IsZero()
|
|
}
|
|
|
|
// activatedAt returns now if active, else a known-but-zero timestamp.
|
|
func activatedAt(active bool) *time.Time {
|
|
if active {
|
|
t := time.Now()
|
|
return &t
|
|
}
|
|
return new(time.Time)
|
|
}
|
|
|
|
func (c *EEBus) setStatus(status status) {
|
|
c.status = status
|
|
c.statusUpdated = time.Now()
|
|
}
|
|
|
|
func (c *EEBus) setConsumptionLimit(limit float64) {
|
|
active := limit > 0
|
|
c.consumptionLimitActivated = activatedAt(active)
|
|
|
|
if err := smartgrid.UpdateSession(&c.smartgridConsumptionId, smartgrid.Dim, c.site.GetGridPower(), 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) {
|
|
c.productionLimitActivated = activatedAt(active)
|
|
|
|
if err := smartgrid.UpdateSession(&c.smartgridProductionId, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
|
|
c.log.ERROR.Printf("smartgrid session: %v", err)
|
|
}
|
|
}
|
|
|
|
var _ api.HEMS = (*EEBus)(nil)
|
|
|
|
// CurtailedPercent implements api.HEMS, converting the active LPP production
|
|
// limit to an allowed production percent via the configured nominal production power.
|
|
func (c *EEBus) CurtailedPercent() *int {
|
|
c.mux.RLock()
|
|
defer c.mux.RUnlock()
|
|
|
|
// without a nominal reference the W limit cannot be expressed as a percent
|
|
if c.productionNominalMax <= 0 {
|
|
return nil
|
|
}
|
|
|
|
percent := 100
|
|
if limitActive(c.productionLimitActivated) {
|
|
// production limits are negative watts
|
|
percent = int(-c.productionLimit.Value / c.productionNominalMax * 100)
|
|
}
|
|
|
|
return &percent
|
|
}
|
|
|
|
// MaxConsumptionPower implements api.HEMS: nil until first connected,
|
|
// else failsafe limit in failsafe, else the active EG-supplied LPC limit, else 0.
|
|
func (c *EEBus) MaxConsumptionPower() *float64 {
|
|
c.mux.RLock()
|
|
defer c.mux.RUnlock()
|
|
if c.consumptionLimitActivated == nil {
|
|
return nil
|
|
}
|
|
if !limitActive(c.consumptionLimitActivated) {
|
|
return new(0.0)
|
|
}
|
|
if c.status == StatusFailsafe {
|
|
return new(c.failsafeConsumptionLimit)
|
|
}
|
|
return new(c.consumptionLimit.Value)
|
|
}
|
|
|
|
// MaxProductionPower implements api.HEMS. Scaffolding only — EEBus does not
|
|
// publish a wattage-typed production cap yet.
|
|
func (c *EEBus) MaxProductionPower() *float64 {
|
|
c.mux.RLock()
|
|
defer c.mux.RUnlock()
|
|
if c.productionLimitActivated == nil {
|
|
return nil
|
|
}
|
|
if !limitActive(c.productionLimitActivated) {
|
|
return new(0.0)
|
|
}
|
|
if c.status == StatusFailsafe {
|
|
return c.failsafeProductionLimit
|
|
}
|
|
return new(c.productionLimit.Value)
|
|
}
|