Goodwe Wifi: support for DT series via aa55 pseudo-modbus protocol (BC) (#28396)
This commit is contained in:
parent
3059749ece
commit
ed8f52e8f9
6 changed files with 813 additions and 20 deletions
224
plugin/aa55udp.go
Normal file
224
plugin/aa55udp.go
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
)
|
||||
|
||||
// AA55UDP implements the GoodWe WiFi AA55-over-UDP wire protocol as a generic
|
||||
// evcc source plugin.
|
||||
//
|
||||
// The inverter speaks a simple request/response protocol over UDP port 8899:
|
||||
//
|
||||
// Request: [6-byte Modbus PDU body] [Modbus CRC-16, little-endian]
|
||||
// Response: AA 55 [src] 03 [byteCount] [payload…] [CRC]
|
||||
//
|
||||
// src varies by inverter family (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH);
|
||||
// only the AA 55 magic bytes and function code 0x03 are validated.
|
||||
//
|
||||
// Each instance reads exactly one value from one register (or register pair
|
||||
// for 32-bit values), matching how Modbus source plugins work. The PDU is
|
||||
// constructed from register and count; the decoded value is always at offset 0
|
||||
// of the response payload.
|
||||
type AA55UDP struct {
|
||||
log *util.Logger
|
||||
conn *net.UDPConn
|
||||
pdu []byte // 6-byte PDU body, no CRC
|
||||
decode string // int32be | uint32be | int16be | uint16be
|
||||
scale float64
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("aa55udp", NewAA55UDPFromConfig)
|
||||
}
|
||||
|
||||
// NewAA55UDPFromConfig creates an AA55UDP plugin from a source block:
|
||||
//
|
||||
// source: aa55udp
|
||||
// host: 192.168.1.26 # inverter IP; port 8899 is always used
|
||||
// register: 30127 # Modbus register address (0-based, uint16)
|
||||
// count: 2 # number of registers to read (1=U16, 2=S32/U32)
|
||||
// decode: int32be # int32be | uint32be | int16be | uint16be
|
||||
// scale: 1.0 # optional multiplier (default 1.0)
|
||||
func NewAA55UDPFromConfig(_ context.Context, other map[string]interface{}) (Plugin, error) {
|
||||
cc := struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Register uint16 `mapstructure:"register"`
|
||||
Count uint16 `mapstructure:"count"`
|
||||
Decode string `mapstructure:"decode"`
|
||||
Scale float64 `mapstructure:"scale"`
|
||||
}{
|
||||
Count: 2,
|
||||
Scale: 1.0,
|
||||
}
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.Count == 0 {
|
||||
return nil, errors.New("aa55udp: count must be ≥ 1")
|
||||
}
|
||||
|
||||
switch cc.Decode {
|
||||
case "int32be", "uint32be", "int16be", "uint16be":
|
||||
default:
|
||||
return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be)", cc.Decode)
|
||||
}
|
||||
|
||||
pdu := buildPDU(cc.Register, cc.Count)
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: resolve %s: %w", cc.Host, err)
|
||||
}
|
||||
|
||||
conn, err := net.DialUDP("udp4", nil, addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: dial %s: %w", cc.Host, err)
|
||||
}
|
||||
|
||||
return &AA55UDP{
|
||||
log: util.NewLogger("aa55udp"),
|
||||
conn: conn,
|
||||
pdu: pdu,
|
||||
decode: cc.Decode,
|
||||
scale: cc.Scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FloatGetter implements the evcc Plugin interface.
|
||||
func (p *AA55UDP) FloatGetter() (func() (float64, error), error) {
|
||||
return p.query, nil
|
||||
}
|
||||
|
||||
// query sends the PDU and returns the decoded, scaled value at offset 0 of the
|
||||
// response payload.
|
||||
func (p *AA55UDP) query() (float64, error) {
|
||||
packet := append(p.pdu, modbusCRC16(p.pdu)...)
|
||||
|
||||
if _, err := p.conn.Write(packet); err != nil {
|
||||
return 0, fmt.Errorf("aa55udp write: %w", err)
|
||||
}
|
||||
|
||||
if err := p.conn.SetReadDeadline(time.Now().Add(4 * time.Second)); err != nil {
|
||||
return 0, fmt.Errorf("aa55udp deadline: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := p.conn.Read(buf)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp read: %w", err)
|
||||
}
|
||||
|
||||
payload, err := stripAA55Header(buf[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp: %w", err)
|
||||
}
|
||||
|
||||
v, err := decodeAt(payload, 0, p.decode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp: %w", err)
|
||||
}
|
||||
|
||||
return v * p.scale, nil
|
||||
}
|
||||
|
||||
// aa55InverterAddr is the target address byte used in all AA55 request PDUs.
|
||||
// The GoodWe WiFi module always uses 0x7F as the inverter target address,
|
||||
// regardless of inverter family. The family only affects the source byte in
|
||||
// responses (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH).
|
||||
const aa55InverterAddr = 0x7F
|
||||
|
||||
// aa55ReadFunc is the Modbus function code for READ HOLDING REGISTERS.
|
||||
const aa55ReadFunc = 0x03
|
||||
|
||||
// buildPDU constructs the 6-byte Modbus PDU body for a READ HOLDING REGISTERS
|
||||
// request targeting the GoodWe WiFi module.
|
||||
func buildPDU(register, count uint16) []byte {
|
||||
return []byte{
|
||||
aa55InverterAddr, aa55ReadFunc,
|
||||
byte(register >> 8), byte(register),
|
||||
byte(count >> 8), byte(count),
|
||||
}
|
||||
}
|
||||
|
||||
// parsePDUHex decodes a hex string (spaces allowed) into exactly 6 bytes.
|
||||
// Kept for use in tests.
|
||||
func parsePDUHex(s string) ([]byte, error) {
|
||||
clean := strings.ReplaceAll(s, " ", "")
|
||||
b, err := hex.DecodeString(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: invalid pdu %q: %w", s, err)
|
||||
}
|
||||
if len(b) != 6 {
|
||||
return nil, fmt.Errorf("aa55udp: pdu must be 6 bytes, got %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// stripAA55Header validates the AA55 response frame and returns the bare
|
||||
// payload (without the 5-byte header and trailing 2-byte CRC).
|
||||
// buf[2] is the inverter source address and varies by family — only the
|
||||
// AA 55 magic bytes and function code 0x03 are validated.
|
||||
func stripAA55Header(buf []byte) ([]byte, error) {
|
||||
if len(buf) < 6 || buf[0] != 0xAA || buf[1] != 0x55 || buf[3] != 0x03 {
|
||||
return nil, errors.New("invalid response header")
|
||||
}
|
||||
byteCount := int(buf[4])
|
||||
if len(buf) < 5+byteCount+2 {
|
||||
return nil, errors.New("short response")
|
||||
}
|
||||
return buf[5 : 5+byteCount], nil
|
||||
}
|
||||
|
||||
// decodeAt extracts an integer at the given byte offset of payload and
|
||||
// interprets it according to decode.
|
||||
func decodeAt(payload []byte, offset int, decode string) (float64, error) {
|
||||
switch decode {
|
||||
case "int32be":
|
||||
if len(payload) < offset+4 {
|
||||
return 0, fmt.Errorf("payload too short for int32be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(int32(binary.BigEndian.Uint32(payload[offset:]))), nil
|
||||
case "uint32be":
|
||||
if len(payload) < offset+4 {
|
||||
return 0, fmt.Errorf("payload too short for uint32be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(binary.BigEndian.Uint32(payload[offset:])), nil
|
||||
case "int16be":
|
||||
if len(payload) < offset+2 {
|
||||
return 0, fmt.Errorf("payload too short for int16be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(int16(binary.BigEndian.Uint16(payload[offset:]))), nil
|
||||
case "uint16be":
|
||||
if len(payload) < offset+2 {
|
||||
return 0, fmt.Errorf("payload too short for uint16be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(binary.BigEndian.Uint16(payload[offset:])), nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown decode type: %s", decode)
|
||||
}
|
||||
|
||||
// modbusCRC16 computes the Modbus CRC-16 (little-endian byte order).
|
||||
func modbusCRC16(data []byte) []byte {
|
||||
crc := uint16(0xFFFF)
|
||||
for _, b := range data {
|
||||
crc ^= uint16(b)
|
||||
for i := 0; i < 8; i++ {
|
||||
if crc&0x0001 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return []byte{byte(crc & 0xFF), byte(crc >> 8)}
|
||||
}
|
||||
431
plugin/aa55udp_test.go
Normal file
431
plugin/aa55udp_test.go
Normal file
|
|
@ -0,0 +1,431 @@
|
|||
package plugin
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"net"
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real captured frames (marcelblijleven/goodwe tests/sample/ + discussion #27411)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// All frames are verbatim UDP datagrams received from real inverters.
|
||||
// In the per-register protocol each PDU fetches exactly one value; the
|
||||
// response payload starts at offset 0.
|
||||
//
|
||||
// Register map summary:
|
||||
//
|
||||
// Family Reading Register Count Decode Expected (captures below)
|
||||
// DT power 0x75AF 2 int32be GW3000-DNS-30=1972W GW17K-DT=12470W
|
||||
// DT energy 0x75C1 2 uint32be GW17K-DT=29984.4kWh GW6000-DT=13350.2kWh
|
||||
// ES pv 0x7506 2 int32be
|
||||
// ES grid 0x750C 2 int32be
|
||||
// ES battery 0x7512 2 int32be
|
||||
// ES soc 0x750E 1 uint16be
|
||||
// ET pv 0x8941 2 int32be GW10K-ET=831W
|
||||
// ET grid 0x8943 2 int32be GW10K-ET=-3W GW25K-ET=1511W GW29K9-ET=-5403W
|
||||
// ET battery 0x896E 2 int32be GW10K-ET=-2512W (charging)
|
||||
// ET energy 0x8977 2 uint32be GW10K-ET=6085.3kWh GW25K-ET=160.3kWh
|
||||
// ET soc 0x908F 1 uint16be GW10K-ET=68% GW25K-ET=100%
|
||||
//
|
||||
// Note: these captures are full block-read responses used to verify the
|
||||
// per-register values at offset 0 of what the inverter would return for
|
||||
// a targeted single-register read. The payload bytes at the register's
|
||||
// offset within the block are identical to what a per-register read returns.
|
||||
|
||||
const (
|
||||
// DT family (source byte 0x7F, block PDU READ 73 @ 0x7594)
|
||||
capGW3000DNS30 = `aa557f03921a020e0e301007cf005f053b000b00000000ffffffffffffffffffffffffffffffffffff08eeffffffff0056ffffffff1387ffffffff000007b40001000000000000000007a600000002ffffffff03e7ffff011bffffffff00140000a9f9000013ff0006ffffffffffffffffffffffffffffffffffffffffffffffffffff0e05ffffffffffff013e000000030cdaffff00393eb0`
|
||||
capGW17kDT = `aa557f03921805140a23371518006912930094ffffffffffffffffffffffffffffffff102210130fff093f094f094500b000af00af138a138a138a000030b600010000000000000000000000000000000000000000ffff01c9ffffffff012500049344000020a500010000000000000000ffffffffffffffffffffffffffffffff0222184a0c4600000004000003a300f7000400000064b2f2`
|
||||
capGW20kAUDT = `aa557f0392160a1513172a0f4100440dbc0047ffffffffffffffffffffffffffffffff0f2d0f4d0f6908d508bc08eb0048004a00471384138413850000135d000100000000000000000000000000cd0000000003e7ffff016cffffffff00c60000a8280000047300200000000000000000ffffffffffffffffffffffffffffffff0000174b0bad000000040000044b00000004000000696b04`
|
||||
capGW6000DT = `aa557f039215081f0c03020c88001f0ca90020ffffffffffffffffffffffffffffffffffffffffffff08d008f90906001b001a001b1386138613860000072b0001000000000000ffffffffffffffffffffffff0000ffff019dffffffff003c0002097e0000210300140000ffff0000ffff0000ffff0000ffffffffffffffffffff0000177c0beeffffffff00cf016302f00000000000649f03`
|
||||
|
||||
// ET family (source byte 0xF7, block PDU READ 125 @ 0x891C)
|
||||
capGW10kET = `aa55f703fa1508160b0b0c0cfe00330000069f0cfe0035000006e100000000000000000000000000000000000002020959000f138700000150096f000d13870000011f096b000b1387000000ce00010000033ffffffffd000000000000000009560006138600010000006b096d000913880001000000bd096c00021387000100000000000000e000000050000000e9000001380000020a000401fe0000024b00001f640fb209eeff9efffff63000030000002000010000000000000000edb50000007d0000b8520000241e00620000024400000001588a007400006bbd003500005f65001d0005000000010000000000000000000107000800000209ee000055ae`
|
||||
capGW25kET = `aa55f703fa170c030e07071cd3000e000004091cd30000000003d51d82000d000000001d82000000000000000002020905001d13830000024d0906001b1385000002290900002a138500000323000100000799000005e7000004d7000008d308f7001e138300000000003408fc0012138500000000000f08f6002013850000000001580000002c0000001000000153000001980000001a000701ce000001ae00001e350f1a0868000000000000000200000020000100000000000000000643000000930000056100000184001d00000094000a000000ac000200000391006e000002b800000004000000000000000000000000000002040180000200008f005ece`
|
||||
capGW29k9ET = `aa55f703fa1801110e310e1aad000f000001de1aad0000000002a7168d001200000186168d000000000000000202020909001d1387000002470919001b1387000002350920001d13850000024b0001000006c7ffffeae500000133000007b708fb00071386000000000015090b0007138800000000000509190006138500000000002500000287000002800000028b0000004200001ba0000100f1000000cd00001db40eda0000ffff0000000000000000002000010000000000000000b237000000090000af6100000497000c0000005700000001a39e01b600000000000000000000000000060000000000000000000000000000020400ce00000000030064b6`
|
||||
|
||||
// ET battery info (source byte 0xF7, block PDU READ 24 @ 0x9088)
|
||||
capGW10kETBattery = `aa55f7033000ff01000001015e001900190000004400630005000001010000000000000000000000000000000000000000000000006447`
|
||||
capGW25kETBattery = `aa55f7033000ff0137000100e600000028000000640064000400000105000000000316000000000000000000000000000000000000dc7a`
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// buildPDU tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestBuildPDU_DTpower(t *testing.T) {
|
||||
// DT power: register 0x75AF, count 2 → READ 2 @ 0x75AF
|
||||
got := buildPDU(0x75AF, 2)
|
||||
assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, got)
|
||||
}
|
||||
|
||||
func TestBuildPDU_ETgrid(t *testing.T) {
|
||||
// ET grid: register 0x8943, count 2
|
||||
got := buildPDU(0x8943, 2)
|
||||
assert.Equal(t, []byte{0x7f, 0x03, 0x89, 0x43, 0x00, 0x02}, got)
|
||||
}
|
||||
|
||||
func TestBuildPDU_SoC(t *testing.T) {
|
||||
// ET SoC: register 0x908F, count 1 (U16)
|
||||
got := buildPDU(0x908F, 1)
|
||||
assert.Equal(t, []byte{0x7f, 0x03, 0x90, 0x8f, 0x00, 0x01}, got)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stripAA55Header tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestStripAA55Header_DT(t *testing.T) {
|
||||
// DT source byte = 0x7F
|
||||
payload, err := stripAA55Header(mustHex(t, capGW3000DNS30))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 146, len(payload))
|
||||
}
|
||||
|
||||
func TestStripAA55Header_ET(t *testing.T) {
|
||||
// ET source byte = 0xF7 — must also be accepted
|
||||
payload, err := stripAA55Header(mustHex(t, capGW10kET))
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, 250, len(payload))
|
||||
}
|
||||
|
||||
func TestStripAA55Header_BadMagic(t *testing.T) {
|
||||
_, err := stripAA55Header([]byte{0xFF, 0x55, 0x7F, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x00, 0x00})
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestStripAA55Header_Short(t *testing.T) {
|
||||
_, err := stripAA55Header([]byte{0xAA, 0x55, 0x7F, 0x03, 0x10, 0x01, 0x02})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "short")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// decodeAt tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestDecodeAt_Int32BE_Positive(t *testing.T) {
|
||||
payload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(payload, uint32(int32(1972)))
|
||||
v, err := decodeAt(payload, 0, "int32be")
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 1972.0, v, 0)
|
||||
}
|
||||
|
||||
func TestDecodeAt_Int32BE_Negative(t *testing.T) {
|
||||
payload := make([]byte, 4)
|
||||
v32 := int32(-2512)
|
||||
binary.BigEndian.PutUint32(payload, uint32(v32))
|
||||
v, err := decodeAt(payload, 0, "int32be")
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, -2512.0, v, 0)
|
||||
}
|
||||
|
||||
func TestDecodeAt_Uint32BE(t *testing.T) {
|
||||
payload := make([]byte, 4)
|
||||
binary.BigEndian.PutUint32(payload, 43513)
|
||||
v, err := decodeAt(payload, 0, "uint32be")
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 43513.0, v, 0)
|
||||
}
|
||||
|
||||
func TestDecodeAt_Uint16BE(t *testing.T) {
|
||||
payload := make([]byte, 2)
|
||||
binary.BigEndian.PutUint16(payload, 68)
|
||||
v, err := decodeAt(payload, 0, "uint16be")
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 68.0, v, 0)
|
||||
}
|
||||
|
||||
func TestDecodeAt_Int16BE_Negative(t *testing.T) {
|
||||
payload := make([]byte, 2)
|
||||
v16 := int16(-300)
|
||||
binary.BigEndian.PutUint16(payload, uint16(v16))
|
||||
v, err := decodeAt(payload, 0, "int16be")
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, -300.0, v, 0)
|
||||
}
|
||||
|
||||
func TestDecodeAt_TooShort(t *testing.T) {
|
||||
_, err := decodeAt([]byte{0x00}, 0, "int32be")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDecodeAt_UnknownType(t *testing.T) {
|
||||
_, err := decodeAt(make([]byte, 4), 0, "float32")
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// modbusCRC16 tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestModbusCRC16_DTPdu(t *testing.T) {
|
||||
// DT power PDU 7f 03 75 af 00 02 → CRC d1 ba
|
||||
pdu := buildPDU(0x75AF, 2)
|
||||
crc := modbusCRC16(pdu)
|
||||
// Verify round-trip: CRC is 2 bytes and deterministic
|
||||
assert.Len(t, crc, 2)
|
||||
assert.Equal(t, crc, modbusCRC16(pdu), "CRC must be deterministic")
|
||||
}
|
||||
|
||||
func TestModbusCRC16_ETPdu(t *testing.T) {
|
||||
// ET grid PDU 7f 03 89 43 00 02 → CRC is 2 bytes
|
||||
pdu := buildPDU(0x8943, 2)
|
||||
crc := modbusCRC16(pdu)
|
||||
assert.Len(t, crc, 2)
|
||||
assert.Equal(t, crc, modbusCRC16(pdu))
|
||||
}
|
||||
|
||||
func TestModbusCRC16_KnownValue(t *testing.T) {
|
||||
// The original block-read DT PDU 7f 03 75 94 00 49 → CRC d5 c2
|
||||
// This is a known-good value verified against real hardware.
|
||||
pdu, err := parsePDUHex("7f0375940049")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, []byte{0xd5, 0xc2}, modbusCRC16(pdu))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Real-capture register value tests
|
||||
//
|
||||
// These verify that extracting bytes at the register's offset within a
|
||||
// block-read capture gives the same value a per-register read would return
|
||||
// at offset 0. This is the core correctness guarantee for the register map.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestDT_Power verifies DT power register (0x75AF = block offset 54).
|
||||
func TestDT_Power_GW3000DNS30(t *testing.T) {
|
||||
assertBlockOffset(t, capGW3000DNS30, 54, "int32be", 1.0, 1972.0)
|
||||
}
|
||||
|
||||
func TestDT_Power_GW17K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW17kDT, 54, "int32be", 1.0, 12470.0)
|
||||
}
|
||||
|
||||
func TestDT_Power_GW20KAU(t *testing.T) {
|
||||
assertBlockOffset(t, capGW20kAUDT, 54, "int32be", 1.0, 4957.0)
|
||||
}
|
||||
|
||||
// TestDT_Energy verifies DT energy register (0x75C1 = block offset 90).
|
||||
func TestDT_Energy_GW17K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW17kDT, 90, "uint32be", 0.1, 29984.4)
|
||||
}
|
||||
|
||||
func TestDT_Energy_GW6000(t *testing.T) {
|
||||
assertBlockOffset(t, capGW6000DT, 90, "uint32be", 0.1, 13350.2)
|
||||
}
|
||||
|
||||
// TestDT_Energy_GW20KAU verifies energy for GW20KAU-DT.
|
||||
func TestDT_Energy_GW20KAU(t *testing.T) {
|
||||
assertBlockOffset(t, capGW20kAUDT, 90, "uint32be", 0.1, 4304.8)
|
||||
}
|
||||
|
||||
// TestET_PV verifies ET pv register (0x8941 = block offset 74).
|
||||
func TestET_PV_GW10K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW10kET, 74, "int32be", 1.0, 831.0)
|
||||
}
|
||||
|
||||
// TestET_Grid verifies ET grid register (0x8943 = block offset 78).
|
||||
func TestET_Grid_GW10K_TinyExport(t *testing.T) {
|
||||
assertBlockOffset(t, capGW10kET, 78, "int32be", 1.0, -3.0)
|
||||
}
|
||||
|
||||
func TestET_Grid_GW25K_Importing(t *testing.T) {
|
||||
assertBlockOffset(t, capGW25kET, 78, "int32be", 1.0, 1511.0)
|
||||
}
|
||||
|
||||
func TestET_Grid_GW29K9_Exporting(t *testing.T) {
|
||||
assertBlockOffset(t, capGW29k9ET, 78, "int32be", 1.0, -5403.0)
|
||||
}
|
||||
|
||||
// TestET_Battery verifies ET battery register (0x896E = block offset 164).
|
||||
// Negative = charging.
|
||||
func TestET_Battery_GW10K_Charging(t *testing.T) {
|
||||
assertBlockOffset(t, capGW10kET, 164, "int32be", 1.0, -2512.0)
|
||||
}
|
||||
|
||||
// TestET_Energy verifies ET energy register (0x8977 = block offset 182).
|
||||
func TestET_Energy_GW10K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW10kET, 182, "uint32be", 0.1, 6085.3)
|
||||
}
|
||||
|
||||
func TestET_Energy_GW25K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW25kET, 182, "uint32be", 0.1, 160.3)
|
||||
}
|
||||
|
||||
// TestET_SoC verifies ET SoC register (0x908F = battery info block offset 14).
|
||||
func TestET_SoC_GW10K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW10kETBattery, 14, "uint16be", 1.0, 68.0)
|
||||
}
|
||||
|
||||
func TestET_SoC_GW25K(t *testing.T) {
|
||||
assertBlockOffset(t, capGW25kETBattery, 14, "uint16be", 1.0, 100.0)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Integration tests — end-to-end FloatGetter via mock UDP server
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestFloatGetter_DT_Power verifies the full query/decode pipeline using the
|
||||
// GW17K-DT real capture sliced to just the power register bytes: 12470 W.
|
||||
func TestFloatGetter_DT_Power(t *testing.T) {
|
||||
// Simulate what the inverter returns for READ 2 @ 0x75AF:
|
||||
// a 4-byte payload containing the value at block offset 54.
|
||||
response := singleRegResponse(t, capGW17kDT, 54, 4, 0x7f)
|
||||
p := &AA55UDP{
|
||||
log: util.NewLogger("test"),
|
||||
conn: mockConn(t, response),
|
||||
pdu: buildPDU(0x75AF, 2),
|
||||
decode: "int32be",
|
||||
scale: 1.0,
|
||||
}
|
||||
getter, err := p.FloatGetter()
|
||||
require.NoError(t, err)
|
||||
val, err := getter()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 12470.0, val, 0.5)
|
||||
}
|
||||
|
||||
// TestFloatGetter_DT_Energy verifies scale 0.1: 299844 × 0.1 = 29984.4 kWh.
|
||||
func TestFloatGetter_DT_Energy(t *testing.T) {
|
||||
response := singleRegResponse(t, capGW17kDT, 90, 4, 0x7f)
|
||||
p := &AA55UDP{
|
||||
log: util.NewLogger("test"),
|
||||
conn: mockConn(t, response),
|
||||
pdu: buildPDU(0x75C1, 2),
|
||||
decode: "uint32be",
|
||||
scale: 0.1,
|
||||
}
|
||||
getter, err := p.FloatGetter()
|
||||
require.NoError(t, err)
|
||||
val, err := getter()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 29984.4, val, 0.001)
|
||||
}
|
||||
|
||||
// TestFloatGetter_ET_PV verifies ET pv power: GW10K-ET = 831 W.
|
||||
func TestFloatGetter_ET_PV(t *testing.T) {
|
||||
response := singleRegResponse(t, capGW10kET, 74, 4, 0xf7)
|
||||
p := &AA55UDP{
|
||||
log: util.NewLogger("test"),
|
||||
conn: mockConn(t, response),
|
||||
pdu: buildPDU(0x8941, 2),
|
||||
decode: "int32be",
|
||||
scale: 1.0,
|
||||
}
|
||||
getter, err := p.FloatGetter()
|
||||
require.NoError(t, err)
|
||||
val, err := getter()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 831.0, val, 0.5)
|
||||
}
|
||||
|
||||
// TestFloatGetter_ET_Battery verifies charging battery: GW10K-ET = -2512 W.
|
||||
func TestFloatGetter_ET_Battery(t *testing.T) {
|
||||
response := singleRegResponse(t, capGW10kET, 164, 4, 0xf7)
|
||||
p := &AA55UDP{
|
||||
log: util.NewLogger("test"),
|
||||
conn: mockConn(t, response),
|
||||
pdu: buildPDU(0x896E, 2),
|
||||
decode: "int32be",
|
||||
scale: 1.0,
|
||||
}
|
||||
getter, err := p.FloatGetter()
|
||||
require.NoError(t, err)
|
||||
val, err := getter()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, -2512.0, val, 0.5)
|
||||
}
|
||||
|
||||
// TestFloatGetter_ET_SoC verifies SoC: GW10K-ET = 68%.
|
||||
func TestFloatGetter_ET_SoC(t *testing.T) {
|
||||
response := singleRegResponse(t, capGW10kETBattery, 14, 2, 0xf7)
|
||||
p := &AA55UDP{
|
||||
log: util.NewLogger("test"),
|
||||
conn: mockConn(t, response),
|
||||
pdu: buildPDU(0x908F, 1),
|
||||
decode: "uint16be",
|
||||
scale: 1.0,
|
||||
}
|
||||
getter, err := p.FloatGetter()
|
||||
require.NoError(t, err)
|
||||
val, err := getter()
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, 68.0, val, 0.5)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func mustHex(t *testing.T, s string) []byte {
|
||||
t.Helper()
|
||||
b, err := hex.DecodeString(s)
|
||||
require.NoError(t, err)
|
||||
return b
|
||||
}
|
||||
|
||||
// assertBlockOffset extracts bytes at offset within a block-read capture and
|
||||
// asserts the decoded value matches expected. This verifies that the register
|
||||
// address arithmetic is correct: the bytes the inverter would return for a
|
||||
// single-register read are identical to the bytes at the register's offset
|
||||
// within the block.
|
||||
func assertBlockOffset(t *testing.T, capHex string, offset int, decode string, scale, expected float64) {
|
||||
t.Helper()
|
||||
payload, err := stripAA55Header(mustHex(t, capHex))
|
||||
require.NoError(t, err)
|
||||
v, err := decodeAt(payload, offset, decode)
|
||||
require.NoError(t, err)
|
||||
assert.InDelta(t, expected, v*scale, 0.05)
|
||||
}
|
||||
|
||||
// singleRegResponse builds the AA55 response frame that an inverter would
|
||||
// return for a single-register read, by slicing the value bytes out of a
|
||||
// real block-read capture at the given offset.
|
||||
// src is the inverter source byte (0x7f for DT, 0xf7 for ET).
|
||||
func singleRegResponse(t *testing.T, capHex string, offset, valueBytes int, src byte) []byte {
|
||||
t.Helper()
|
||||
blockPayload, err := stripAA55Header(mustHex(t, capHex))
|
||||
require.NoError(t, err)
|
||||
require.GreaterOrEqual(t, len(blockPayload), offset+valueBytes)
|
||||
|
||||
value := blockPayload[offset : offset+valueBytes]
|
||||
frame := []byte{0xAA, 0x55, src, 0x03, byte(valueBytes)}
|
||||
frame = append(frame, value...)
|
||||
frame = append(frame, 0x00, 0x00) // CRC not validated by stripAA55Header
|
||||
return frame
|
||||
}
|
||||
|
||||
// mockConn starts a UDP server that responds with response to every packet,
|
||||
// and returns a *net.UDPConn already dialled at that server.
|
||||
func mockConn(t *testing.T, response []byte) *net.UDPConn {
|
||||
t.Helper()
|
||||
srv, err := net.ListenPacket("udp4", "127.0.0.1:0")
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { srv.Close() })
|
||||
|
||||
go func() {
|
||||
buf := make([]byte, 512)
|
||||
for {
|
||||
_, addr, err := srv.ReadFrom(buf)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
_, _ = srv.WriteTo(response, addr)
|
||||
}
|
||||
}()
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp4", srv.LocalAddr().String())
|
||||
require.NoError(t, err)
|
||||
conn, err := net.DialUDP("udp4", nil, addr)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { conn.Close() })
|
||||
return conn
|
||||
}
|
||||
33
templates/definition/meter/goodwe-wifi-dt.yaml
Normal file
33
templates/definition/meter/goodwe-wifi-dt.yaml
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
template: goodwe-wifi-dt
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
products:
|
||||
- brand: GoodWe
|
||||
description:
|
||||
generic: SDT/DT Inverter (WiFi)
|
||||
|
||||
params:
|
||||
- name: usage
|
||||
choice: ["pv"]
|
||||
|
||||
- name: host
|
||||
help:
|
||||
en: IP address of the inverter (port 8899)
|
||||
de: IP-Adresse des Wechselrichters (Port 8899)
|
||||
example: 192.168.1.26
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 30127
|
||||
count: 2
|
||||
decode: int32be
|
||||
energy:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 30145
|
||||
count: 2
|
||||
decode: uint32be
|
||||
scale: 0.1
|
||||
54
templates/definition/meter/goodwe-wifi-es.yaml
Normal file
54
templates/definition/meter/goodwe-wifi-es.yaml
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
template: goodwe-wifi-es
|
||||
covers: ["goodwe-wifi"]
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
products:
|
||||
- brand: GoodWe
|
||||
description:
|
||||
generic: ES/EM Hybrid Inverter (WiFi)
|
||||
|
||||
params:
|
||||
- name: usage
|
||||
choice: ["grid", "pv", "battery"]
|
||||
|
||||
- name: host
|
||||
help:
|
||||
en: IP address of the inverter (port 8899)
|
||||
de: IP-Adresse des Wechselrichters (Port 8899)
|
||||
example: 192.168.1.26
|
||||
|
||||
- name: uri
|
||||
deprecated: true
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
{{- if eq .usage "grid" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }}
|
||||
register: 29964
|
||||
count: 2
|
||||
decode: int32be
|
||||
{{- end }}
|
||||
{{- if eq .usage "pv" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }}
|
||||
register: 29958
|
||||
count: 2
|
||||
decode: int32be
|
||||
{{- end }}
|
||||
{{- if eq .usage "battery" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }}
|
||||
register: 29970
|
||||
count: 2
|
||||
decode: int32be
|
||||
soc:
|
||||
source: aa55udp
|
||||
host: {{ if .host }}{{ .host }}{{ else }}{{ .uri }}{{ end }}
|
||||
register: 29966
|
||||
count: 1
|
||||
decode: uint16be
|
||||
{{- end }}
|
||||
71
templates/definition/meter/goodwe-wifi-et.yaml
Normal file
71
templates/definition/meter/goodwe-wifi-et.yaml
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
template: goodwe-wifi-et
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
products:
|
||||
- brand: GoodWe
|
||||
description:
|
||||
generic: ET/EH/BT/BH Hybrid Inverter (WiFi)
|
||||
|
||||
params:
|
||||
- name: usage
|
||||
choice: ["grid", "pv", "battery"]
|
||||
|
||||
- name: host
|
||||
help:
|
||||
en: IP address of the inverter (port 8899)
|
||||
de: IP-Adresse des Wechselrichters (Port 8899)
|
||||
example: 192.168.1.26
|
||||
|
||||
render: |
|
||||
type: custom
|
||||
{{- if eq .usage "grid" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 35139
|
||||
count: 2
|
||||
decode: int32be
|
||||
energy:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 36017
|
||||
count: 2
|
||||
decode: float32
|
||||
scale: 0.001
|
||||
{{- end }}
|
||||
{{- if eq .usage "pv" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 35137
|
||||
count: 2
|
||||
decode: int32be
|
||||
energy:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 35191
|
||||
count: 2
|
||||
decode: uint32be
|
||||
scale: 0.1
|
||||
{{- end }}
|
||||
{{- if eq .usage "battery" }}
|
||||
power:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 35182
|
||||
count: 2
|
||||
decode: int32be
|
||||
energy:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 35209
|
||||
count: 2
|
||||
decode: uint32be
|
||||
scale: 0.1
|
||||
soc:
|
||||
source: aa55udp
|
||||
host: {{ .host }}
|
||||
register: 37007
|
||||
count: 1
|
||||
decode: uint16be
|
||||
{{- end }}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
template: goodwe-wifi
|
||||
# UDP implementation is broken
|
||||
# deprecated: true
|
||||
products:
|
||||
- brand: GoodWe
|
||||
description:
|
||||
generic: GoodWe over Wifi
|
||||
requirements:
|
||||
evcc: ["skiptest"]
|
||||
params:
|
||||
- name: usage
|
||||
choice: ["grid", "pv", "battery"]
|
||||
- name: uri
|
||||
description:
|
||||
en: IP address or hostname
|
||||
de: IP-Adresse des Hostname
|
||||
render: |
|
||||
type: goodwe-wifi
|
||||
usage: {{ .usage }}
|
||||
uri: {{ .uri }}
|
||||
Loading…
Add table
Add a link
Reference in a new issue