187 lines
5 KiB
Go
187 lines
5 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/util"
|
|
)
|
|
|
|
// DanfossTLX is a PV meter for Danfoss TripleLynx TLX inverters via ComLynx RS485.
|
|
type DanfossTLX struct {
|
|
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{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)
|
|
}
|
|
}
|
|
|
|
// build decorator functions based on capabilities
|
|
var totalEnergy func() (float64, error)
|
|
if hasEnergy == nil {
|
|
totalEnergy = m.totalEnergy
|
|
}
|
|
|
|
var voltages, currents, powers func() (float64, float64, float64, error)
|
|
if hasVoltages == nil {
|
|
voltages = m.phaseVoltages
|
|
}
|
|
if hasCurrents == nil {
|
|
currents = m.phaseCurrents
|
|
}
|
|
if hasPowers == nil {
|
|
powers = m.phasePowers
|
|
}
|
|
|
|
go func() {
|
|
<-ctx.Done()
|
|
_ = conn.Close()
|
|
}()
|
|
|
|
return decorateMeter(m, totalEnergy, currents, voltages, powers, maxACPower), 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
|
|
}
|