evcc-io/hems/custom/custom.go

242 lines
5.6 KiB
Go

package custom
import (
"context"
"errors"
"fmt"
"math"
"sync"
"time"
"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/util"
)
func init() {
config.AddCtx(api.Custom, NewFromConfig)
}
// Custom implements a plugin-configurable HEMS.
type Custom struct {
mu sync.Mutex
log *util.Logger
site site.API
publishFunc func()
maxConsumptionPower func() (float64, error)
curtailedPercent func() (int64, error)
productionNominalMax float64
interval time.Duration
smartgridConsumptionID uint
smartgridProductionID uint
consumptionLimit *float64
productionPercent int // allowed feed-in percent (0..100), 100 = uncurtailed
}
// NewFromConfig creates a custom HEMS from generic config.
func NewFromConfig(ctx context.Context, other map[string]any, site site.API) (*Custom, error) {
cc := struct {
MaxConsumptionPower *plugin.Config
CurtailedPercent *plugin.Config
ProductionNominalMax float64
Interval time.Duration
}{
Interval: 10 * time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
maxConsumptionPowerG, err := cc.MaxConsumptionPower.FloatGetter(ctx)
if err != nil {
return nil, fmt.Errorf("max consumption power: %w", err)
}
curtailedPercentG, err := cc.CurtailedPercent.IntGetter(ctx)
if err != nil {
return nil, fmt.Errorf("curtailed percent: %w", err)
}
return NewCustom(site, maxConsumptionPowerG, curtailedPercentG, math.Abs(cc.ProductionNominalMax), cc.Interval)
}
// NewCustom creates a custom HEMS.
func NewCustom(site site.API, maxConsumptionPower func() (float64, error), curtailedPercent func() (int64, error), productionNominalMax float64, interval time.Duration) (*Custom, error) {
if maxConsumptionPower == nil && curtailedPercent == nil {
return nil, errors.New("must have either maxconsumptionpower or curtailedpercent")
}
if curtailedPercent != nil && productionNominalMax == 0 {
return nil, errors.New("cannot have curtailedpercent without productionnominalmax")
}
c := &Custom{
log: util.NewLogger("custom"),
site: site,
maxConsumptionPower: maxConsumptionPower,
curtailedPercent: curtailedPercent,
productionNominalMax: productionNominalMax,
productionPercent: 100,
interval: interval,
}
// read the plugins once synchronously so limits are valid as soon as NewCustom returns
if err := c.run(); err != nil {
return nil, err
}
return c, nil
}
func (c *Custom) SetUpdated(f func()) {
c.mu.Lock()
defer c.mu.Unlock()
c.publishFunc = f
}
// Run starts the control loop. NewCustom already ran the first pass.
func (c *Custom) 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 *Custom) run() error {
return errors.Join(c.runDim(), c.runCurtail())
}
// runDim reads the consumption limit. No-op if not configured.
// The previous limit is retained if reading fails.
func (c *Custom) runDim() error {
if c.maxConsumptionPower == nil {
return nil
}
limit, err := c.maxConsumptionPower()
if err != nil {
return err
}
if limit < 0 {
return fmt.Errorf("invalid consumption limit: %.0fW", limit)
}
c.setConsumptionLimit(limit)
if err := smartgrid.UpdateSession(&c.smartgridConsumptionID, smartgrid.Dim, c.site.GetGridPower(), limit, limit > 0); err != nil {
return fmt.Errorf("smartgrid session: %v", err)
}
return nil
}
// runCurtail reads the curtailment percentage. No-op if not configured.
// The previous percentage is retained if reading fails.
func (c *Custom) runCurtail() error {
if c.curtailedPercent == nil {
return nil
}
percent, err := c.curtailedPercent()
if err != nil {
return err
}
if percent < 0 || percent > 100 {
return fmt.Errorf("invalid curtailment percent: %d", percent)
}
c.setProductionLimit(int(percent))
active := percent < 100
var limit float64
if active {
limit = float64(percent) / 100 * c.productionNominalMax
}
if err := smartgrid.UpdateSession(&c.smartgridProductionID, smartgrid.Curtail, c.site.GetGridPower(), limit, active); err != nil {
return fmt.Errorf("smartgrid session: %v", err)
}
return nil
}
// setConsumptionLimit applies the dimming limit.
func (c *Custom) setConsumptionLimit(limit float64) {
c.mu.Lock()
defer c.mu.Unlock()
c.consumptionLimit = nil
if limit > 0 {
c.consumptionLimit = &limit
}
}
// setProductionLimit applies the curtailment limit.
func (c *Custom) setProductionLimit(percent int) {
c.mu.Lock()
defer c.mu.Unlock()
c.productionPercent = percent
}
var _ api.HEMS = (*Custom)(nil)
// CurtailedPercent implements api.HEMS, returning the allowed production percent.
func (c *Custom) CurtailedPercent() *int {
if c.curtailedPercent == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
return new(c.productionPercent)
}
// MaxConsumptionPower implements api.HEMS, returning the active wattage cap.
func (c *Custom) MaxConsumptionPower() *float64 {
if c.maxConsumptionPower == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.consumptionLimit == nil {
return new(0.0)
}
return new(*c.consumptionLimit)
}
// MaxProductionPower implements api.HEMS.
func (c *Custom) MaxProductionPower() *float64 {
if c.curtailedPercent == nil {
return nil
}
c.mu.Lock()
defer c.mu.Unlock()
if c.productionPercent >= 100 {
return new(0.0)
}
return new(float64(c.productionPercent) / 100 * c.productionNominalMax)
}