Add Danfoss inverters using ComLynx RS485 (#29065)
This commit is contained in:
parent
4c7b04c270
commit
467c3c572e
6 changed files with 374 additions and 2 deletions
1
go.mod
1
go.mod
|
|
@ -6,6 +6,7 @@ require (
|
|||
dario.cat/mergo v1.0.2
|
||||
github.com/AlecAivazis/survey/v2 v2.3.7
|
||||
github.com/Masterminds/sprig/v3 v3.3.0
|
||||
github.com/PanterSoft/comlynx-go v0.1.0
|
||||
github.com/PuerkitoBio/goquery v1.12.0
|
||||
github.com/WulfgarW/sensonet v0.0.7
|
||||
github.com/andig/go-powerwall v0.3.0
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -16,6 +16,8 @@ github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe
|
|||
github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
|
||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
|
||||
github.com/PanterSoft/comlynx-go v0.1.0 h1:+Kd0lJqHlWQNv9WliijoKTFrcZSZ33RBxIvi6SE5TbQ=
|
||||
github.com/PanterSoft/comlynx-go v0.1.0/go.mod h1:X+jgiMXuPftrh5cpoMm648+DpTM2RdKm5FBCyL8KpLw=
|
||||
github.com/PuerkitoBio/goquery v1.12.0 h1:pAcL4g3WRXekcB9AU/y1mbKez2dbY2AajVhtkO8RIBo=
|
||||
github.com/PuerkitoBio/goquery v1.12.0/go.mod h1:802ej+gV2y7bbIhOIoPY5sT183ZW0YFofScC4q/hIpQ=
|
||||
github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk=
|
||||
|
|
|
|||
187
meter/danfoss.go
Normal file
187
meter/danfoss.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
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
|
||||
}
|
||||
85
meter/danfoss_test.go
Normal file
85
meter/danfoss_test.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package meter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
comlynx "github.com/PanterSoft/comlynx-go"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestDanfossTLXInterfaceCompliance verifies interface compliance at compile
|
||||
// time. No network or hardware access needed.
|
||||
func TestDanfossTLXInterfaceCompliance(t *testing.T) {
|
||||
var _ api.Meter = (*DanfossTLX)(nil)
|
||||
}
|
||||
|
||||
// TestDanfossTLXConfigRejectsNonPV ensures the factory rejects usage modes
|
||||
// other than "pv" before touching any I/O.
|
||||
func TestDanfossTLXConfigRejectsNonPV(t *testing.T) {
|
||||
_, err := NewDanfossTLXFromConfig(t.Context(), map[string]any{
|
||||
"usage": "grid",
|
||||
"device": "/dev/null",
|
||||
})
|
||||
assert.ErrorContains(t, err, "pv", "non-pv usage must be rejected")
|
||||
|
||||
_, err = NewDanfossTLXFromConfig(t.Context(), map[string]any{
|
||||
"usage": "battery",
|
||||
"device": "/dev/null",
|
||||
})
|
||||
assert.ErrorContains(t, err, "pv", "non-pv usage must be rejected")
|
||||
}
|
||||
|
||||
// TestDanfossTLXConfigRejectsDeviceAndURI verifies that supplying both device
|
||||
// and uri is rejected before any I/O.
|
||||
func TestDanfossTLXConfigRejectsDeviceAndURI(t *testing.T) {
|
||||
_, err := NewDanfossTLXFromConfig(t.Context(), map[string]any{
|
||||
"usage": "pv",
|
||||
"device": "/dev/null",
|
||||
"uri": "host:4196",
|
||||
})
|
||||
assert.ErrorContains(t, err, "mutually exclusive")
|
||||
}
|
||||
|
||||
// TestDanfossTLXConfigRejectsNoTransport verifies that omitting both device
|
||||
// and uri returns a clear error.
|
||||
func TestDanfossTLXConfigRejectsNoTransport(t *testing.T) {
|
||||
_, err := NewDanfossTLXFromConfig(t.Context(), map[string]any{
|
||||
"usage": "pv",
|
||||
})
|
||||
assert.ErrorContains(t, err, "device")
|
||||
}
|
||||
|
||||
func TestParseComlynxNodeAddress(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want comlynx.Address
|
||||
wantErr string
|
||||
}{
|
||||
{"valid", "c-6-b1", comlynx.NewAddress(0x0c, 0x06, 0xb1), ""},
|
||||
{"valid lowercase", "a-f-ff", comlynx.NewAddress(0x0a, 0x0f, 0xff), ""},
|
||||
{"valid uppercase", "A-F-FF", comlynx.NewAddress(0x0a, 0x0f, 0xff), ""},
|
||||
{"valid zeros", "0-0-00", comlynx.NewAddress(0x00, 0x00, 0x00), ""},
|
||||
{"network out of range", "10-0-00", comlynx.Address{}, "network component"},
|
||||
{"subnet out of range", "0-10-00", comlynx.Address{}, "subnet component"},
|
||||
{"node out of range", "0-0-100", comlynx.Address{}, "node component"},
|
||||
{"invalid format missing parts", "c-6", comlynx.Address{}, "expected format"},
|
||||
{"invalid format no dashes", "c6b1", comlynx.Address{}, "expected format"},
|
||||
{"invalid hex chars", "g-0-00", comlynx.Address{}, "expected format"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := parseComlynxNodeAddress(tc.input)
|
||||
if tc.wantErr != "" {
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), tc.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tc.want, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
88
templates/definition/meter/danfoss-triplelynx-tlx.yaml
Normal file
88
templates/definition/meter/danfoss-triplelynx-tlx.yaml
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
template: danfoss-triplelynx-tlx
|
||||
products:
|
||||
- brand: Danfoss
|
||||
description:
|
||||
generic: TripleLynx TLX/TLX+
|
||||
requirements:
|
||||
description:
|
||||
de: |
|
||||
Die Kommunikation erfolgt über die RS485-Schnittstelle des Wechselrichters (ComLynx-Protokoll).
|
||||
|
||||
**Verkabelung (RJ45-Stecker am Wechselrichter):**
|
||||
- Pin 1: GND
|
||||
- Pin 2: B (RS485-)
|
||||
- Pin 3: A (RS485+)
|
||||
- Pin 6: B (Daisy-Chain zum nächsten Gerät)
|
||||
- Pin 7: A (Daisy-Chain zum nächsten Gerät)
|
||||
|
||||
Busabschlusswiderstände (120 Ω) am ersten und letzten Gerät der Kette erforderlich
|
||||
(Pins 4→6 und 5→7 am Wechselrichter).
|
||||
|
||||
Verbinde den USB-RS485-Adapter (oder Netzwerk-Seriellbrücke) mit dem RS485-Bus.
|
||||
Mehrere Wechselrichter an einem Bus: `node`-Parameter für jeden Wechselrichter setzen.
|
||||
en: |
|
||||
Communication uses the RS485 port of the inverter (ComLynx protocol).
|
||||
|
||||
**Wiring (RJ45 connector on the inverter):**
|
||||
- Pin 1: GND
|
||||
- Pin 2: B (RS485-)
|
||||
- Pin 3: A (RS485+)
|
||||
- Pin 6: B (daisy-chain to next device)
|
||||
- Pin 7: A (daisy-chain to next device)
|
||||
|
||||
Termination resistors (120 Ω) are required at both ends of the bus
|
||||
(bridge pins 4→6 and 5→7 on the inverter).
|
||||
|
||||
Connect a USB-RS485 adapter (or a network serial bridge) to the RS485 bus.
|
||||
For multiple inverters on one bus set the `node` parameter for each meter entry.
|
||||
params:
|
||||
- name: usage
|
||||
choice: ["pv"]
|
||||
- name: device
|
||||
description:
|
||||
de: Serieller Anschluss (USB-RS485-Adapter)
|
||||
en: Serial port (USB-RS485 adapter)
|
||||
help:
|
||||
de: z. B. /dev/ttyUSB0 — Gegenseitig ausschließend mit uri
|
||||
en: e.g. /dev/ttyUSB0 — mutually exclusive with uri
|
||||
example: /dev/ttyUSB0
|
||||
- name: uri
|
||||
description:
|
||||
de: TCP-Endpunkt einer Netzwerk-Seriellbrücke
|
||||
en: TCP endpoint of a network RS485 bridge
|
||||
help:
|
||||
de: z. B. rs485bridge.lan:4196 — Gegenseitig ausschließend mit device
|
||||
en: e.g. rs485bridge.lan:4196 — mutually exclusive with device
|
||||
example: localhost:4196
|
||||
- name: node
|
||||
description:
|
||||
de: Geräteadresse (hex, Format N-S-NN)
|
||||
en: Inverter node address (hex, N-S-NN)
|
||||
help:
|
||||
de: |
|
||||
Nur erforderlich wenn mehrere Wechselrichter am selben RS485-Bus hängen.
|
||||
Die Adresse wird beim Start automatisch ermittelt und im Log ausgegeben.
|
||||
Format: Netzwerk-Subnetz-Knoten in Hex, z. B. c-6-b1
|
||||
en: |
|
||||
Only required when multiple inverters share the same RS485 bus.
|
||||
The address is discovered automatically on startup and printed to the log.
|
||||
Format: network-subnet-node in hex, e.g. c-6-b1
|
||||
advanced: true
|
||||
example: c-6-b1
|
||||
- name: baudrate
|
||||
default: 19200
|
||||
advanced: true
|
||||
- name: maxacpower
|
||||
render: |
|
||||
type: danfoss-tlx
|
||||
usage: {{ .usage }}
|
||||
{{- if .uri }}
|
||||
uri: {{ .uri }}
|
||||
{{- else if .device }}
|
||||
device: {{ .device }}
|
||||
{{- end }}
|
||||
node: {{ .node }}
|
||||
baudrate: {{ .baudrate }}
|
||||
{{- if eq .usage "pv" }}
|
||||
maxacpower: {{ .maxacpower }}
|
||||
{{- end }}
|
||||
|
|
@ -479,6 +479,15 @@ params:
|
|||
description:
|
||||
en: Price zones
|
||||
de: Preiszonen
|
||||
- name: baudrate
|
||||
description:
|
||||
de: Baudrate
|
||||
en: Baudrate
|
||||
help:
|
||||
de: Typische Werte sind 9600, 19200, 38400, 57600, 115200
|
||||
en: Typical values are 9600, 19200, 38400, 57600, 115200
|
||||
default: 9600
|
||||
type: int
|
||||
|
||||
presets:
|
||||
vehicle-base:
|
||||
|
|
@ -755,8 +764,8 @@ modbus:
|
|||
de: Baudrate
|
||||
en: Baudrate
|
||||
help:
|
||||
de: Prüfe die Geräteeinstellungen, typische Werte sind 9600, 19200, 38400, 57600, 115200
|
||||
en: Check the device settings, typical values are 9600, 19200, 38400, 57600, 115200
|
||||
de: Typische Werte sind 9600, 19200, 38400, 57600, 115200
|
||||
en: Typical values are 9600, 19200, 38400, 57600, 115200
|
||||
default: 9600
|
||||
type: int
|
||||
- name: comset
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue