Add Danfoss inverters using ComLynx RS485 (#29065)

This commit is contained in:
Nico 2026-04-15 15:59:19 +02:00 • committed by GitHub
parent 4c7b04c270
commit 467c3c572e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 374 additions and 2 deletions

1
go.mod
View file

@ -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
View file

@ -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
View 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
View 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)
})
}
}

View 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 }}

View file

@ -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