evcc-io/meter/danfoss.go

189 lines
5.1 KiB
Go

package meter
import (
"context"
"fmt"
"strings"
"time"
comlynx "github.com/PanterSoft/comlynx-go"
"github.com/evcc-io/evcc/api"
"github.com/evcc-io/evcc/api/implement"
"github.com/evcc-io/evcc/util"
)
// DanfossTLX is a PV meter for Danfoss TripleLynx TLX inverters via ComLynx RS485.
type DanfossTLX struct {
implement.Caps
conn *comlynx.Client
powerFallback bool // some TLX variants don't support aggregate power; sum per-phase instead
}
func init() {
registry.AddCtx("danfoss-tlx", NewDanfossTLXFromConfig)
}
func NewDanfossTLXFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) {
cc := struct {
pvMaxACPower `mapstructure:",squash"`
Usage string
Device string
URI string
Baudrate int
Node string
Timeout time.Duration
}{
Baudrate: comlynx.DefaultBaudrate,
Timeout: comlynx.DefaultTimeout,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if !strings.EqualFold(cc.Usage, "pv") {
return nil, fmt.Errorf("danfoss-tlx only supports usage 'pv', got %q", cc.Usage)
}
cfg := comlynx.Config{
Device: cc.Device,
URI: cc.URI,
Baudrate: cc.Baudrate,
Timeout: cc.Timeout,
Source: comlynx.DefaultSource,
}
if cc.Node != "" {
destination, err := parseComlynxNodeAddress(cc.Node)
if err != nil {
return nil, fmt.Errorf("node %q: %w", cc.Node, err)
}
cfg.Destination = destination
}
return NewDanfossTLX(ctx, cfg, cc.pvMaxACPower.Decorator())
}
func NewDanfossTLX(ctx context.Context, cfg comlynx.Config, maxACPower func() float64) (api.Meter, error) {
log := util.NewLogger("danfoss-tlx")
conn, err := comlynx.New(log.TRACE.Printf, cfg)
if err != nil {
return nil, err
}
if cfg.Destination == (comlynx.Address{}) {
addr, err := comlynx.Discover(conn)
if err != nil {
_ = conn.Close()
return nil, fmt.Errorf("address discovery: %w", err)
}
conn.SetDestination(addr)
log.DEBUG.Printf("discovered inverter at %s", addr)
}
m := &DanfossTLX{
Caps: implement.New(),
conn: conn,
}
// probe capabilities
_, aggregatePowerErr := conn.Read(comlynx.ParamGridPowerTotal)
_, hasEnergy := conn.Read(comlynx.ParamTotalEnergy)
_, _, _, hasVoltages := m.phaseVoltages()
_, _, _, hasCurrents := m.phaseCurrents()
_, _, _, hasPowers := m.phasePowers()
if aggregatePowerErr != nil {
if hasPowers == nil {
m.powerFallback = true
} else {
_ = conn.Close()
return nil, fmt.Errorf("power unavailable: aggregate read failed (%w) and per-phase powers are unavailable", aggregatePowerErr)
}
}
if hasEnergy == nil {
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
}
if hasVoltages == nil {
implement.Has(m, implement.PhaseVoltages(m.phaseVoltages))
}
if hasCurrents == nil {
implement.Has(m, implement.PhaseCurrents(m.phaseCurrents))
}
if hasPowers == nil {
implement.Has(m, implement.PhasePowers(m.phasePowers))
}
implement.May(m, implement.MaxACPowerGetter(maxACPower))
go func() {
<-ctx.Done()
_ = conn.Close()
}()
return m, nil
}
func (m *DanfossTLX) CurrentPower() (float64, error) {
if m.powerFallback {
p1, p2, p3, err := m.phasePowers()
if err != nil {
return 0, err
}
return p1 + p2 + p3, nil
}
v, err := m.conn.Read(comlynx.ParamGridPowerTotal)
return float64(v), err
}
func (m *DanfossTLX) totalEnergy() (float64, error) {
v, err := m.conn.Read(comlynx.ParamTotalEnergy)
if err != nil {
return 0, err
}
return float64(v) / 1000, nil // Wh → kWh
}
func (m *DanfossTLX) phaseVoltages() (float64, float64, float64, error) {
return m.getPhases(comlynx.ParamGridVoltageL1, comlynx.ParamGridVoltageL2, comlynx.ParamGridVoltageL3, 10) // raw is V*10
}
func (m *DanfossTLX) phaseCurrents() (float64, float64, float64, error) {
return m.getPhases(comlynx.ParamGridCurrentL1, comlynx.ParamGridCurrentL2, comlynx.ParamGridCurrentL3, 1000) // raw is mA
}
func (m *DanfossTLX) phasePowers() (float64, float64, float64, error) {
return m.getPhases(comlynx.ParamGridPowerL1, comlynx.ParamGridPowerL2, comlynx.ParamGridPowerL3, 1)
}
func (m *DanfossTLX) getPhases(p1, p2, p3 uint16, divisor float64) (float64, float64, float64, error) {
params := [3]uint16{p1, p2, p3}
vals := [3]float64{}
for i, p := range params {
v, err := m.conn.Read(p)
if err != nil {
return 0, 0, 0, err
}
vals[i] = float64(v) / divisor
}
return vals[0], vals[1], vals[2], nil
}
func parseComlynxNodeAddress(value string) (comlynx.Address, error) {
var network, subnet, node int
if _, err := fmt.Sscanf(value, "%x-%x-%x", &network, &subnet, &node); err != nil {
return comlynx.Address{}, fmt.Errorf("expected format N-S-NN in hex (e.g. c-6-b1): %w", err)
}
if network < 0 || network > 0x0f {
return comlynx.Address{}, fmt.Errorf("network component %x out of range (must be 0..f)", network)
}
if subnet < 0 || subnet > 0x0f {
return comlynx.Address{}, fmt.Errorf("subnet component %x out of range (must be 0..f)", subnet)
}
if node < 0 || node > 0xff {
return comlynx.Address{}, fmt.Errorf("node component %x out of range (must be 00..ff)", node)
}
return comlynx.NewAddress(byte(network), byte(subnet), byte(node)), nil
}