diff --git a/go.mod b/go.mod index 27a5a776b..6bee2bf02 100644 --- a/go.mod +++ b/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 diff --git a/go.sum b/go.sum index 427b45073..5203e7ee6 100644 --- a/go.sum +++ b/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= diff --git a/meter/danfoss.go b/meter/danfoss.go new file mode 100644 index 000000000..be1707ea9 --- /dev/null +++ b/meter/danfoss.go @@ -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 +} diff --git a/meter/danfoss_test.go b/meter/danfoss_test.go new file mode 100644 index 000000000..0f9e60652 --- /dev/null +++ b/meter/danfoss_test.go @@ -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) + }) + } +} diff --git a/templates/definition/meter/danfoss-triplelynx-tlx.yaml b/templates/definition/meter/danfoss-triplelynx-tlx.yaml new file mode 100644 index 000000000..603b98dce --- /dev/null +++ b/templates/definition/meter/danfoss-triplelynx-tlx.yaml @@ -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 }} diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index c51caa320..576df08c3 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -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