370 lines
9.1 KiB
Go
370 lines
9.1 KiB
Go
package meter
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/cenkalti/backoff/v4"
|
|
"github.com/evcc-io/evcc/api"
|
|
"github.com/evcc-io/evcc/api/implement"
|
|
"github.com/evcc-io/evcc/util"
|
|
"github.com/evcc-io/rct"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
// RCT implements the api.Meter interface
|
|
type RCT struct {
|
|
implement.Caps
|
|
conn *rct.Connection // connection with the RCT device
|
|
usage string // grid, pv, battery
|
|
externalPower bool // whether to query external power
|
|
rSocStrategy *uint8 // remembers overwritten soc strategy value
|
|
}
|
|
|
|
var (
|
|
rctMu sync.Mutex
|
|
rctCache = make(map[string]*rct.Connection)
|
|
)
|
|
|
|
func init() {
|
|
registry.AddCtx("rct", NewRCTFromConfig)
|
|
}
|
|
|
|
// NewRCTFromConfig creates an RCT from generic config
|
|
func NewRCTFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) {
|
|
cc := struct {
|
|
batterySocLimits `mapstructure:",squash"`
|
|
batteryPowerLimits `mapstructure:",squash"`
|
|
pvMaxACPower `mapstructure:",squash"`
|
|
Uri, Usage string
|
|
Capacity float64
|
|
Capacity2 float64
|
|
ExternalPower bool
|
|
Cache time.Duration
|
|
}{
|
|
batterySocLimits: batterySocLimits{
|
|
MinSoc: 20,
|
|
MaxSoc: 95,
|
|
},
|
|
batteryPowerLimits: batteryPowerLimits{
|
|
MaxChargePower: 10000,
|
|
MaxDischargePower: 10000,
|
|
},
|
|
Cache: 30 * time.Second,
|
|
}
|
|
|
|
if err := util.DecodeOther(other, &cc); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if cc.Usage == "" {
|
|
return nil, errors.New("missing usage")
|
|
}
|
|
|
|
return NewRCT(ctx, cc.Uri, cc.Usage, cc.batterySocLimits, cc.batteryPowerLimits, cc.Cache, cc.ExternalPower, cc.Capacity, cc.Capacity2, cc.pvMaxACPower.Decorator())
|
|
}
|
|
|
|
// NewRCT creates an RCT meter
|
|
func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits, cache time.Duration, externalPower bool, capacity, capacity2 float64, maxACPower func() float64) (api.Meter, error) {
|
|
log := util.NewLogger("rct")
|
|
|
|
// re-use connections
|
|
rctMu.Lock()
|
|
conn, ok := rctCache[uri]
|
|
if !ok {
|
|
var err error
|
|
conn, err = rct.NewConnection(ctx, uri, rct.WithErrorCallback(func(err error) {
|
|
if err != nil {
|
|
log.ERROR.Println(err)
|
|
}
|
|
}), rct.WithLogger(log.TRACE.Printf), rct.WithTimeout(cache))
|
|
if err != nil {
|
|
rctMu.Unlock()
|
|
return nil, err
|
|
}
|
|
|
|
rctCache[uri] = conn
|
|
}
|
|
rctMu.Unlock()
|
|
|
|
m := &RCT{
|
|
Caps: implement.New(),
|
|
usage: strings.ToLower(usage),
|
|
conn: conn,
|
|
externalPower: externalPower,
|
|
}
|
|
|
|
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
|
|
|
|
if usage != "pv" {
|
|
implement.Has(m, implement.MeterReturnEnergy(m.returnEnergy))
|
|
}
|
|
|
|
if usage == "pv" {
|
|
curtail := func(percent int) error {
|
|
return m.conn.Write(rct.BufVControlPowerReduction, floatVal(float64(percent)/100))
|
|
}
|
|
|
|
curtailed := func() (bool, error) {
|
|
r, err := m.queryFloat(rct.BufVControlPowerReduction)
|
|
return r != 1, err
|
|
}
|
|
|
|
implement.Has(m, implement.Curtailer(curtailed, curtail))
|
|
implement.May(m, implement.MaxACPowerGetter(maxACPower))
|
|
}
|
|
|
|
if usage == "battery" {
|
|
// validate capacity configuration for dual battery setups
|
|
if capacity2 > 0 && capacity == 0 {
|
|
return nil, errors.New("missing first battery capacity")
|
|
}
|
|
|
|
batterySoc := func() (float64, error) {
|
|
soc, err := m.queryFloat(rct.BatterySoC)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
if capacity2 == 0 {
|
|
return soc * 100, err
|
|
}
|
|
|
|
soc2, err := m.queryFloat(rct.BatteryPlaceholder0Soc)
|
|
return (soc*capacity + soc2*capacity2) / (capacity + capacity2) * 100, err
|
|
}
|
|
|
|
implement.Has(m, implement.Battery(batterySoc))
|
|
implement.May(m, implement.BatterySocLimiter(batterySocLimits.Decorator()))
|
|
implement.May(m, implement.BatteryPowerLimiter(batteryPowerLimits.Decorator()))
|
|
|
|
if capacity != 0 {
|
|
implement.Has(m, implement.BatteryCapacity(func() float64 { return capacity + capacity2 }))
|
|
}
|
|
|
|
batteryMode := func(mode api.BatteryMode) error {
|
|
if mode != api.BatteryNormal {
|
|
batStatus, err := m.queryInt32(rct.BatteryStatus2)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// check for normal operating mode
|
|
if batStatus != 0 && batStatus != 1032 && batStatus != 2048 {
|
|
return fmt.Errorf("invalid battery operating mode: %d", batStatus)
|
|
}
|
|
|
|
// read soc strategy to reset afterwards
|
|
if m.rSocStrategy == nil {
|
|
strategy, err := m.queryUint8(rct.PowerMngSocStrategy)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
m.rSocStrategy = &strategy
|
|
}
|
|
}
|
|
|
|
var eg errgroup.Group
|
|
|
|
switch mode {
|
|
case api.BatteryNormal:
|
|
eg.Go(func() error {
|
|
err := m.conn.Write(rct.PowerMngSocStrategy, []byte{*m.rSocStrategy})
|
|
m.rSocStrategy = nil
|
|
return err
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.BatterySoCTargetMin, floatVal(batterySocLimits.MinSoc/100))
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocMax, floatVal(batterySocLimits.MaxSoc/100))
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngBatteryPowerExternW, floatVal(0))
|
|
})
|
|
|
|
case api.BatteryHold:
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetInternal})
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocMax, floatVal(batterySocLimits.MaxSoc/100))
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.BatterySoCTargetMin, floatVal(batterySocLimits.MaxSoc/100))
|
|
})
|
|
|
|
case api.BatteryCharge:
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngUseGridPowerEnable, []byte{1})
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngBatteryPowerExternW, floatVal(-batteryPowerLimits.MaxChargePower))
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetExternal})
|
|
})
|
|
|
|
case api.BatteryHoldCharge:
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocStrategy, []byte{rct.SOCTargetInternal})
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.BatterySoCTargetMin, floatVal(batterySocLimits.MinSoc/100))
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
return m.conn.Write(rct.PowerMngSocMax, floatVal(batterySocLimits.MinSoc/100))
|
|
})
|
|
|
|
default:
|
|
return api.ErrNotAvailable
|
|
}
|
|
|
|
return eg.Wait()
|
|
}
|
|
|
|
implement.Has(m, implement.BatteryController(batteryMode))
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// CurrentPower implements the api.Meter interface
|
|
func (m *RCT) CurrentPower() (float64, error) {
|
|
switch m.usage {
|
|
case "grid":
|
|
return m.queryFloat(rct.TotalGridPowerW)
|
|
|
|
case "pv":
|
|
var eg errgroup.Group
|
|
var a, b, c float64
|
|
|
|
eg.Go(func() error {
|
|
var err error
|
|
a, err = m.queryFloat(rct.SolarGenAPowerW)
|
|
return err
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
var err error
|
|
b, err = m.queryFloat(rct.SolarGenBPowerW)
|
|
return err
|
|
})
|
|
|
|
if m.externalPower {
|
|
eg.Go(func() error {
|
|
var err error
|
|
c, err = m.queryFloat(rct.S0ExternalPowerW)
|
|
return err
|
|
})
|
|
}
|
|
|
|
err := eg.Wait()
|
|
return a + b + c, err
|
|
|
|
case "battery":
|
|
return m.queryFloat(rct.BatteryPowerW)
|
|
|
|
default:
|
|
return 0, fmt.Errorf("invalid usage: %s", m.usage)
|
|
}
|
|
}
|
|
|
|
// totalEnergy implements the api.MeterEnergy interface
|
|
func (m *RCT) totalEnergy() (float64, error) {
|
|
switch m.usage {
|
|
case "grid":
|
|
res, err := m.queryFloat(rct.TotalEnergyGridLoadWh)
|
|
return res / 1000, err
|
|
|
|
case "pv":
|
|
var eg errgroup.Group
|
|
var a, b float64
|
|
|
|
eg.Go(func() error {
|
|
var err error
|
|
a, err = m.queryFloat(rct.TotalEnergySolarGenAWh)
|
|
return err
|
|
})
|
|
|
|
eg.Go(func() error {
|
|
var err error
|
|
b, err = m.queryFloat(rct.TotalEnergySolarGenBWh)
|
|
return err
|
|
})
|
|
|
|
err := eg.Wait()
|
|
return (a + b) / 1000, err
|
|
|
|
case "battery":
|
|
res, err := m.queryFloat(rct.TotalEnergyBattOutWh)
|
|
return res / 1000, err
|
|
|
|
default:
|
|
return 0, fmt.Errorf("invalid usage: %s", m.usage)
|
|
}
|
|
}
|
|
|
|
// returnEnergy implements the api.MeterReturnEnergy interface
|
|
func (m *RCT) returnEnergy() (float64, error) {
|
|
switch m.usage {
|
|
case "grid":
|
|
res, err := m.queryFloat(rct.TotalEnergyGridFeedInWh)
|
|
return -res / 1000, err
|
|
|
|
case "battery":
|
|
res, err := m.queryFloat(rct.TotalEnergyBattInWh)
|
|
return res / 1000, err
|
|
|
|
default:
|
|
return 0, fmt.Errorf("invalid usage: %s", m.usage)
|
|
}
|
|
}
|
|
|
|
func floatVal(f float64) []byte {
|
|
data := make([]byte, 4)
|
|
binary.BigEndian.PutUint32(data, math.Float32bits(float32(f)))
|
|
return data
|
|
}
|
|
|
|
func queryRCT[T any](id rct.Identifier, fun func(id rct.Identifier) (T, error)) (T, error) {
|
|
bo := backoff.NewExponentialBackOff(
|
|
backoff.WithInitialInterval(500*time.Millisecond),
|
|
backoff.WithMaxInterval(2*time.Second),
|
|
backoff.WithMaxElapsedTime(10*time.Second))
|
|
|
|
return backoff.RetryWithData(func() (T, error) {
|
|
return fun(id)
|
|
}, bo)
|
|
}
|
|
|
|
// queryFloat adds retry logic of recoverable errors to QueryFloat32
|
|
func (m *RCT) queryFloat(id rct.Identifier) (float64, error) {
|
|
res, err := queryRCT(id, m.conn.QueryFloat32)
|
|
return float64(res), err
|
|
}
|
|
|
|
// queryInt32 adds retry logic of recoverable errors to QueryInt32
|
|
func (m *RCT) queryInt32(id rct.Identifier) (int32, error) {
|
|
return queryRCT(id, m.conn.QueryInt32)
|
|
}
|
|
|
|
// queryUint8 adds retry logic of recoverable errors to QueryUint8
|
|
func (m *RCT) queryUint8(id rct.Identifier) (uint8, error) {
|
|
return queryRCT(id, m.conn.QueryUint8)
|
|
}
|