diff --git a/plugin/aa55/aa55.go b/plugin/aa55/aa55.go new file mode 100644 index 000000000..70b68e5f8 --- /dev/null +++ b/plugin/aa55/aa55.go @@ -0,0 +1,139 @@ +// Package aa55 implements the GoodWe WiFi AA55-over-UDP wire protocol used by +// the GoodWe inverter families (DT/DNS, ES/EM, ET/EH/BT/BH). +// +// The inverter speaks a simple request/response protocol over UDP port 8899: +// +// Request: [6-byte PDU body] [Modbus CRC-16, little-endian] +// Response: AA 55 [src] 03 [byteCount] [payload…] [CRC] +package aa55 + +import ( + "encoding/binary" + "errors" + "fmt" + "math" + + "github.com/grid-x/modbus" +) + +// InverterAddr is the default inverter address byte, used by DT/DNS and ES/EM +// families. ET/EH/BT/BH families require 0xF7 (247) instead. +const InverterAddr byte = 0x7F + +// buildPDU constructs the 6-byte PDU body for a READ HOLDING REGISTERS request. +// addr is the inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH. +func buildPDU(addr byte, register, count uint16) []byte { + return []byte{ + addr, modbus.FuncCodeReadHoldingRegisters, + byte(register >> 8), byte(register), + byte(count >> 8), byte(count), + } +} + +// stripHeader 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, which varies by family — only the +// AA 55 magic bytes and the READ HOLDING REGISTERS function code are validated. +func stripHeader(buf []byte) ([]byte, error) { + if len(buf) < 6 || buf[0] != 0xAA || buf[1] != 0x55 || buf[3] != modbus.FuncCodeReadHoldingRegisters { + 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 +} + +// 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 range 8 { + if crc&0x0001 != 0 { + crc = (crc >> 1) ^ 0xA001 + } else { + crc >>= 1 + } + } + } + return []byte{byte(crc & 0xFF), byte(crc >> 8)} +} + +// decodeMeta describes the properties of a supported decode type. +type decodeMeta struct { + size int +} + +func decodeMetadata(name string) (decodeMeta, bool) { + switch name { + case "float32be", "int32be", "uint32be", "uint32nan": + return decodeMeta{size: 4}, true + case "int16be", "uint16be": + return decodeMeta{size: 2}, true + default: + return decodeMeta{}, false + } +} + +// validateDecode returns an error if decode is not a supported type. +func validateDecode(decode string) error { + if _, ok := decodeMetadata(decode); !ok { + return fmt.Errorf("unsupported decode %q (want int32be|uint32be|uint32nan|int16be|uint16be|float32be)", decode) + } + return nil +} + +// decodeSize returns the number of bytes required to decode the given type. +// Panics if decode type is unknown — callers must validateDecode first. +func decodeSize(decode string) int { + if info, ok := decodeMetadata(decode); ok { + return info.size + } + panic(fmt.Sprintf("unknown decode type %q", decode)) +} + +// decodeAt extracts a value 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 "float32be": + if len(payload) < offset+4 { + return 0, fmt.Errorf("payload too short for float32be at offset %d (len=%d)", offset, len(payload)) + } + bits := binary.BigEndian.Uint32(payload[offset:]) + return float64(math.Float32frombits(bits)), nil + 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 "uint32nan": + // Like uint32be but treats 0xFFFFFFFF (not-connected sentinel) as 0. + // Used for PV string power registers where disconnected strings report NaN. + if len(payload) < offset+4 { + return 0, fmt.Errorf("payload too short for uint32nan at offset %d (len=%d)", offset, len(payload)) + } + if v := binary.BigEndian.Uint32(payload[offset:]); v != 0xFFFFFFFF { + return float64(v), nil + } + return 0, 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) +} diff --git a/plugin/aa55/aa55_test.go b/plugin/aa55/aa55_test.go new file mode 100644 index 000000000..04f3301a2 --- /dev/null +++ b/plugin/aa55/aa55_test.go @@ -0,0 +1,341 @@ +package aa55 + +import ( + "encoding/binary" + "encoding/hex" + "math" + "testing" + + "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 +// --------------------------------------------------------------------------- + +func TestBuildPDU_DTpower(t *testing.T) { + got := buildPDU(0x7F, 0x75AF, 2) + assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, got) +} + +func TestBuildPDU_DefaultAddress(t *testing.T) { + // When address is omitted from config, InverterAddr (0x7F) must be used. + // This guards existing DT/DNS and ES/EM setups that rely on the default. + got := buildPDU(InverterAddr, 0x75AF, 2) + assert.Equal(t, byte(0x7F), got[0], "default address byte must be 0x7F") +} + +func TestBuildPDU_ETgrid(t *testing.T) { + got := buildPDU(0xF7, 0x8943, 2) + assert.Equal(t, []byte{0xf7, 0x03, 0x89, 0x43, 0x00, 0x02}, got) +} + +func TestBuildPDU_SoC(t *testing.T) { + got := buildPDU(0xF7, 0x908F, 1) + assert.Equal(t, []byte{0xf7, 0x03, 0x90, 0x8f, 0x00, 0x01}, got) +} + +// --------------------------------------------------------------------------- +// stripHeader +// --------------------------------------------------------------------------- + +func TestStripHeader_DT(t *testing.T) { + payload, err := stripHeader(mustHex(t, capGW3000DNS30)) + require.NoError(t, err) + assert.Equal(t, 146, len(payload)) +} + +func TestStripHeader_ET(t *testing.T) { + payload, err := stripHeader(mustHex(t, capGW10kET)) + require.NoError(t, err) + assert.Equal(t, 250, len(payload)) +} + +func TestStripHeader_BadMagic(t *testing.T) { + _, err := stripHeader([]byte{0xFF, 0x55, 0x7F, 0x03, 0x04, 0x01, 0x02, 0x03, 0x04, 0x00, 0x00}) + require.Error(t, err) +} + +func TestStripHeader_Short(t *testing.T) { + _, err := stripHeader([]byte{0xAA, 0x55, 0x7F, 0x03, 0x10, 0x01, 0x02}) + require.Error(t, err) + assert.Contains(t, err.Error(), "short") +} + +// --------------------------------------------------------------------------- +// decodeAt +// --------------------------------------------------------------------------- + +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_Float32BE(t *testing.T) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, math.Float32bits(123.456)) + v, err := decodeAt(payload, 0, "float32be") + require.NoError(t, err) + assert.InDelta(t, 123.456, v, 0.001) +} + +func TestDecodeAt_Uint32NAN_Normal(t *testing.T) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, 8310) // e.g. 83.10 W string power + v, err := decodeAt(payload, 0, "uint32nan") + require.NoError(t, err) + assert.InDelta(t, 8310.0, v, 0) +} + +func TestDecodeAt_Uint32NAN_Disconnected(t *testing.T) { + payload := make([]byte, 4) + binary.BigEndian.PutUint32(payload, 0xFFFFFFFF) // disconnected string sentinel + v, err := decodeAt(payload, 0, "uint32nan") + require.NoError(t, err) + assert.InDelta(t, 0.0, v, 0) // must return 0, not 4.3GW +} + +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) +} + +// --------------------------------------------------------------------------- +// validateDecode / decodeSize +// --------------------------------------------------------------------------- + +func TestValidateDecode_OK(t *testing.T) { + for _, d := range []string{"int32be", "uint32be", "uint32nan", "int16be", "uint16be", "float32be"} { + assert.NoError(t, validateDecode(d), d) + } +} + +func TestValidateDecode_Reject(t *testing.T) { + assert.Error(t, validateDecode("float32")) + assert.Error(t, validateDecode("")) +} + +func TestDecodeSize(t *testing.T) { + assert.Equal(t, 4, decodeSize("int32be")) + assert.Equal(t, 4, decodeSize("uint32nan")) + assert.Equal(t, 2, decodeSize("uint16be")) +} + +// --------------------------------------------------------------------------- +// modbusCRC16 +// --------------------------------------------------------------------------- + +func TestModbusCRC16_DTPdu(t *testing.T) { + pdu := buildPDU(0x7F, 0x75AF, 2) + crc := modbusCRC16(pdu) + assert.Len(t, crc, 2) + assert.Equal(t, crc, modbusCRC16(pdu), "CRC must be deterministic") +} + +func TestModbusCRC16_ETPdu(t *testing.T) { + pdu := buildPDU(0xF7, 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. + // Known-good value verified against real hardware. + pdu := buildPDU(0x7F, 0x7594, 0x49) + 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. +// --------------------------------------------------------------------------- + +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) +} + +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) +} + +func TestDT_Energy_GW20KAU(t *testing.T) { + assertBlockOffset(t, capGW20kAUDT, 90, "uint32be", 0.1, 4304.8) +} + +func TestET_PV_GW10K(t *testing.T) { + assertBlockOffset(t, capGW10kET, 74, "int32be", 1.0, 831.0) +} + +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) +} + +func TestET_Battery_GW10K_Charging(t *testing.T) { + assertBlockOffset(t, capGW10kET, 164, "int32be", 1.0, -2512.0) +} + +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) +} + +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) +} + +// --------------------------------------------------------------------------- +// Cache +// --------------------------------------------------------------------------- + +func TestCache_GetMiss(t *testing.T) { + c := newResponseCache() + _, ok := c.get([]byte("nope")) + assert.False(t, ok) +} + +func TestCache_PutGet(t *testing.T) { + c := newResponseCache() + c.put([]byte("k"), []byte{1, 2, 3}) + got, ok := c.get([]byte("k")) + require.True(t, ok) + assert.Equal(t, []byte{1, 2, 3}, got) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +func mustHex(t *testing.T, s string) []byte { + t.Helper() + b, err := hex.DecodeString(s) + require.NoError(t, err) + return b +} + +func assertBlockOffset(t *testing.T, capHex string, offset int, decode string, scale, expected float64) { + t.Helper() + payload, err := stripHeader(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) +} diff --git a/plugin/aa55/aa55udp.go b/plugin/aa55/aa55udp.go new file mode 100644 index 000000000..d99b82374 --- /dev/null +++ b/plugin/aa55/aa55udp.go @@ -0,0 +1,177 @@ +package aa55 + +import ( + "errors" + "fmt" + "net" + "time" + + "github.com/evcc-io/evcc/util" +) + +// AA55UDP is the GoodWe AA55-over-UDP source plugin transport. +// +// Two read modes are supported, both built from logical parameters +// (id, register, count) on the Go side: +// +// Register read: single register, value at offset 0 of response payload. +// Block read: enclosing block fetched once, target register extracted at +// its computed offset. Multiple sources sharing the same +// (host, block) share one UDP exchange per poll cycle via the +// response cache. +type AA55UDP struct { + log *util.Logger + conn *net.UDPConn + pdu []byte // 6-byte PDU body, no CRC + offset int // byte offset into the response payload (0 for register reads) + decode string // int32be | uint32be | uint32nan | int16be | uint16be | float32be + scale float64 + cacheKey []byte // precomputed cache key (remoteAddr/pdu); nil disables caching +} + +// Block describes the enclosing register block to fetch in block-read mode. +// When set, one UDP exchange reads Count registers starting at Register, and +// each source extracts its own target register at the computed offset, sharing +// the response via the cache. +type Block struct { + Register uint16 + Count uint16 +} + +// readConfig holds the resolved read mode configuration. +type readConfig struct { + pdu []byte + offset int + useCache bool +} + +// buildReadConfig resolves the read mode from the target register (register, +// count, id) and the optional enclosing block. In both modes the PDU is built +// on the Go side; the template only supplies logical parameters. +func buildReadConfig(id int, register, count uint16, block *Block) (readConfig, error) { + if id < 0 || id > 255 { + return readConfig{}, fmt.Errorf("id must be 0-255, got %d", id) + } + if count == 0 { + return readConfig{}, errors.New("count must be ≥ 1") + } + + // Block mode: fetch the whole block and extract the target register at its + // offset. Multiple sources sharing the same block share one UDP exchange. + if block != nil { + if block.Count == 0 { + return readConfig{}, errors.New("block count must be ≥ 1") + } + // The target register must fit entirely within the block. + if register < block.Register || uint32(register)+uint32(count) > uint32(block.Register)+uint32(block.Count) { + return readConfig{}, fmt.Errorf("register %d+%d does not fit in block %d+%d", register, count, block.Register, block.Count) + } + return readConfig{ + pdu: buildPDU(byte(id), block.Register, block.Count), + offset: int(register-block.Register) * 2, + useCache: true, + }, nil + } + + // Register mode: single targeted read, value at offset 0, no caching. + return readConfig{pdu: buildPDU(byte(id), register, count), useCache: false}, nil +} + +// New constructs an AA55UDP from a high-level configuration. It validates +// decode, resolves the read mode (register vs block), and wraps the conn. +// The caller is responsible for dialling conn. +func New(log *util.Logger, conn *net.UDPConn, id int, register, count uint16, block *Block, decode string, scale float64) (*AA55UDP, error) { + if err := validateDecode(decode); err != nil { + return nil, err + } + cfg, err := buildReadConfig(id, register, count, block) + if err != nil { + return nil, err + } + ap := &AA55UDP{ + log: log, + conn: conn, + decode: decode, + scale: scale, + pdu: cfg.pdu, + offset: cfg.offset, + } + if cfg.useCache { + ap.cacheKey = []byte(conn.RemoteAddr().String() + "/" + string(cfg.pdu)) + } + return ap, nil +} + +// FloatGetter implements the evcc plugin.FloatGetter interface. +func (p *AA55UDP) FloatGetter() (func() (float64, error), error) { + return p.query, nil +} + +// query fetches the payload and returns the decoded, scaled value at p.offset. +func (p *AA55UDP) query() (float64, error) { + payload, err := p.fetch() + if err != nil { + return 0, err + } + + minLen := p.offset + decodeSize(p.decode) + if len(payload) < minLen { + return 0, fmt.Errorf("payload too short (len=%d, need=%d)", len(payload), minLen) + } + + v, err := decodeAt(payload, p.offset, p.decode) + if err != nil { + return 0, err + } + + return v * p.scale, nil +} + +// fetch returns the response payload, using caching for block-read mode. +func (p *AA55UDP) fetch() ([]byte, error) { + if p.cacheKey != nil { + if payload, ok := cache.get(p.cacheKey); ok { + p.log.TRACE.Printf("cache hit for %s pdu=%x", p.conn.RemoteAddr(), p.pdu) + return payload, nil + } + } + + packet := append(p.pdu, modbusCRC16(p.pdu)...) + raw, err := p.sendRecv(packet) + if err != nil { + return nil, err + } + + payload, err := stripHeader(raw) + if err != nil { + return nil, err + } + + if p.cacheKey != nil { + cache.put(p.cacheKey, payload) + } + return payload, nil +} + +// sendRecv sends packet over p.conn and returns the raw response bytes. +func (p *AA55UDP) sendRecv(packet []byte) ([]byte, error) { + p.log.TRACE.Printf("send to %s: %x", p.conn.RemoteAddr(), packet) + + if _, err := p.conn.Write(packet); err != nil { + return nil, fmt.Errorf("write: %w", err) + } + + if err := p.conn.SetReadDeadline(time.Now().Add(4 * time.Second)); err != nil { + return nil, fmt.Errorf("deadline: %w", err) + } + + buf := make([]byte, 512) + n, err := p.conn.Read(buf) + if err != nil { + return nil, fmt.Errorf("read: %w", err) + } + + p.log.TRACE.Printf("recv from %s: %x", p.conn.RemoteAddr(), buf[:n]) + + return buf[:n], nil +} diff --git a/plugin/aa55/aa55udp_test.go b/plugin/aa55/aa55udp_test.go new file mode 100644 index 000000000..c067b3826 --- /dev/null +++ b/plugin/aa55/aa55udp_test.go @@ -0,0 +1,183 @@ +package aa55 + +import ( + "encoding/hex" + "net" + "testing" + + "github.com/evcc-io/evcc/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Wire-protocol primitives (buildPDU, stripHeader, DecodeAt, ModbusCRC16, +// Cache, real-capture register tests) are covered in aa55_test.go. +// This file covers the AA55UDP plugin adapter end-to-end via FloatGetter. + +// 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) { + response := singleRegResponse(t, capGW17kDT, 54, 4, 0x7f) + p := &AA55UDP{ + log: util.NewLogger("test"), + conn: mockConn(t, response), + pdu: buildPDU(0x7F, 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(0x7F, 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(0xF7, 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(0xF7, 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(0xF7, 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) +} + +// TestBuildReadConfig_RegisterMode verifies default register-read config. +func TestBuildReadConfig_RegisterMode(t *testing.T) { + cfg, err := buildReadConfig(int(InverterAddr), 0x75AF, 2, nil) + require.NoError(t, err) + assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, cfg.pdu) + assert.Equal(t, 0, cfg.offset) + assert.False(t, cfg.useCache) +} + +// TestBuildReadConfig_BlockMode verifies the block PDU is built from the block +// register/count and the target register's offset is computed within it. +// ET grid (0x8943) within block READ 125 @ 0x891C → offset (35139-35100)*2 = 78. +func TestBuildReadConfig_BlockMode(t *testing.T) { + cfg, err := buildReadConfig(0xF7, 0x8943, 2, &Block{Register: 0x891C, Count: 125}) + require.NoError(t, err) + assert.Equal(t, []byte{0xf7, 0x03, 0x89, 0x1c, 0x00, 0x7d}, cfg.pdu) + assert.Equal(t, 78, cfg.offset) + assert.True(t, cfg.useCache) +} + +// TestBuildReadConfig_RejectsRegisterOutsideBlock rejects a target register +// that does not fit entirely within the configured block. +func TestBuildReadConfig_RejectsRegisterOutsideBlock(t *testing.T) { + // before block start + _, err := buildReadConfig(0xF7, 0x8900, 2, &Block{Register: 0x891C, Count: 125}) + require.Error(t, err) + + // past block end (0x891C+125 = 0x8999; 0x8998+2 overruns) + _, err = buildReadConfig(0xF7, 0x8998, 2, &Block{Register: 0x891C, Count: 125}) + require.Error(t, err) +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// 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() + cap, err := hex.DecodeString(capHex) + require.NoError(t, err) + blockPayload, err := stripHeader(cap) + 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 stripHeader + 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 +} diff --git a/plugin/aa55/cache.go b/plugin/aa55/cache.go new file mode 100644 index 000000000..aeb38e457 --- /dev/null +++ b/plugin/aa55/cache.go @@ -0,0 +1,61 @@ +package aa55 + +import ( + "sync" + "time" +) + +const cacheTTL = 2 * time.Second + +// cache is the package-level response cache shared across all AA55UDP plugin +// instances. Sharing at package level ensures that multiple source blocks for +// the same (host, pdu) pair — e.g. the four Ppv string registers all using +// READ 125 @ 0x891C — share one UDP exchange per poll cycle. +// +// TTL is 2 s: long enough to serve all source blocks within one evcc poll +// cycle (which completes in well under 1 s), short enough that the next cycle +// always fetches fresh data. +var cache = newResponseCache() + +type cacheEntry struct { + payload []byte + expiresAt time.Time +} + +type responseCache struct { + mu sync.Mutex + data map[string]cacheEntry +} + +func newResponseCache() *responseCache { + return &responseCache{data: make(map[string]cacheEntry)} +} + +// get returns the cached payload if it exists and is fresh, or (nil, false) +// otherwise. Expired entries are deleted on access. The map lookup +// m[string(key)] is alloc-free — the Go compiler elides the conversion. +func (c *responseCache) get(key []byte) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + + entry, ok := c.data[string(key)] + if !ok { + return nil, false + } + if time.Now().After(entry.expiresAt) { + delete(c.data, string(key)) + return nil, false + } + return entry.payload, true +} + +// put inserts or overwrites a payload in the cache. +func (c *responseCache) put(key, payload []byte) { + c.mu.Lock() + defer c.mu.Unlock() + + c.data[string(key)] = cacheEntry{ + payload: payload, + expiresAt: time.Now().Add(cacheTTL), + } +} diff --git a/plugin/aa55udp.go b/plugin/aa55udp.go index 6e4385ce8..3d303b018 100644 --- a/plugin/aa55udp.go +++ b/plugin/aa55udp.go @@ -2,235 +2,77 @@ package plugin import ( "context" - "encoding/binary" - "encoding/hex" - "errors" "fmt" - "math" "net" - "strings" - "time" + "net/netip" + "github.com/evcc-io/evcc/plugin/aa55" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/request" ) -// 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 | float32be - scale float64 -} - func init() { registry.AddCtx("aa55udp", NewAA55UDPFromConfig) } -// NewAA55UDPFromConfig creates an AA55UDP plugin from a source block: +// NewAA55UDPFromConfig creates a GoodWe AA55-over-UDP plugin. +// +// Register read mode (single register): +// +// source: aa55udp +// host: 192.168.1.26 +// id: 127 # 0x7F for DT/DNS/ES/EM (default); 247 (0xF7) for ET/EH/BT/BH +// register: 30127 +// count: 2 # 1 = 16-bit, 2 = 32-bit +// decode: int32be +// scale: 1.0 +// +// Block read mode (fetch an enclosing block once, extract the target register): // // source: aa55udp // host: 192.168.1.26 # inverter IP; port 8899 is always used -// id: 0x7F # inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH -// 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 | float32be +// id: 247 # 0x7F (default) for DT/DNS/ES/EM; 247 (0xF7) for ET/EH/BT/BH +// register: 35139 # target register +// count: 2 # 1 = 16-bit, 2 = 32-bit +// block: # enclosing block fetched in a single UDP exchange +// register: 35100 # block start register +// count: 125 # block length (registers) +// decode: int32be # int32be | uint32be | uint32nan | int16be | uint16be | float32be // scale: 1.0 # optional multiplier (default 1.0) -func NewAA55UDPFromConfig(_ context.Context, other map[string]any) (Plugin, error) { +func NewAA55UDPFromConfig(ctx context.Context, other map[string]any) (Plugin, error) { cc := struct { - Host string `mapstructure:"host"` - Id int `mapstructure:"id"` - Register uint16 `mapstructure:"register"` - Count uint16 `mapstructure:"count"` - Decode string `mapstructure:"decode"` - Scale float64 `mapstructure:"scale"` + Host string + Id int + Register uint16 + Count uint16 + Block *aa55.Block + Decode string + Scale float64 }{ - Id: int(aa55InverterAddr), + Id: int(aa55.InverterAddr), 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") - } - - if cc.Id < 0 || cc.Id > 255 { - return nil, fmt.Errorf("aa55udp: id must be 0-255, got %d", cc.Id) - } - - switch cc.Decode { - case "int32be", "uint32be", "int16be", "uint16be", "float32be": - default: - return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be|float32be)", cc.Decode) - } - - pdu := buildPDU(byte(cc.Id), cc.Register, cc.Count) - - addr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899")) + raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899")) if err != nil { - return nil, fmt.Errorf("aa55udp: resolve %s: %w", cc.Host, err) + return nil, err } - conn, err := net.DialUDP("udp4", nil, addr) + dialer := &net.Dialer{Timeout: request.Timeout} + conn, err := dialer.DialUDP(ctx, "udp4", netip.AddrPort{}, raddr.AddrPort()) if err != nil { - return nil, fmt.Errorf("aa55udp: dial %s: %w", cc.Host, err) + return nil, 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) + res, err := aa55.New(util.NewLogger("aa55udp"), conn, cc.Id, cc.Register, cc.Count, cc.Block, cc.Decode, cc.Scale) if err != nil { - return 0, fmt.Errorf("aa55udp read: %w", err) + return nil, fmt.Errorf("aa55udp: %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 default inverter address byte, used by DT/DNS and ES/EM families. -// ET/EH/BT/BH families require 0xF7 instead. -const aa55InverterAddr = 0x7F - -// aa55ReadFunc is the Modbus function code for READ HOLDING REGISTERS. -const aa55ReadFunc = 0x03 - -// buildPDU constructs the 6-byte PDU for a READ HOLDING REGISTERS request. -// addr is the inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH. -func buildPDU(addr byte, register, count uint16) []byte { - return []byte{ - addr, 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 "float32be": - if len(payload) < offset+4 { - return 0, fmt.Errorf("payload too short for float32be at offset %d (len=%d)", offset, len(payload)) - } - bits := binary.BigEndian.Uint32(payload[offset:]) - return float64(math.Float32frombits(bits)), nil - 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 range 8 { - if crc&0x0001 != 0 { - crc = (crc >> 1) ^ 0xA001 - } else { - crc >>= 1 - } - } - } - return []byte{byte(crc & 0xFF), byte(crc >> 8)} + return res, nil } diff --git a/plugin/aa55udp_test.go b/plugin/aa55udp_test.go index 0ebefb259..a89163270 100644 --- a/plugin/aa55udp_test.go +++ b/plugin/aa55udp_test.go @@ -1,448 +1,47 @@ package plugin import ( - "encoding/binary" - "encoding/hex" - "math" - "net" + "context" "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(0x7F, 0x75AF, 2) - assert.Equal(t, []byte{0x7f, 0x03, 0x75, 0xaf, 0x00, 0x02}, got) -} - -func TestBuildPDU_DefaultAddress(t *testing.T) { - // When address is omitted from config, aa55InverterAddr (0x7F) must be used. - // This guards existing DT/DNS and ES/EM setups that rely on the default. - got := buildPDU(aa55InverterAddr, 0x75AF, 2) - assert.Equal(t, byte(0x7F), got[0], "default address byte must be 0x7F") -} - -func TestBuildPDU_ETgrid(t *testing.T) { - // ET grid: register 0x8943, count 2 - got := buildPDU(0xF7, 0x8943, 2) - assert.Equal(t, []byte{0xf7, 0x03, 0x89, 0x43, 0x00, 0x02}, got) -} - -func TestBuildPDU_SoC(t *testing.T) { - // ET SoC: register 0x908F, count 1 (U16) - got := buildPDU(0xF7, 0x908F, 1) - assert.Equal(t, []byte{0xf7, 0x03, 0x90, 0x8f, 0x00, 0x01}, got) -} - -// --------------------------------------------------------------------------- -// stripAA55Header tests -// --------------------------------------------------------------------------- - -func TestStripAA55Header_DT(t *testing.T) { - // DT source byte = 0x7F - payload, err := stripAA55Header(mustHex(t, capGW3000DNS30)) +// TestAA55UDPFromConfig_Block verifies that the nested block map decodes and a +// target register that fits within the block is accepted (block-read mode). +func TestAA55UDPFromConfig_Block(t *testing.T) { + _, err := NewAA55UDPFromConfig(context.Background(), map[string]any{ + "host": "127.0.0.1", + "id": 247, + "register": 35139, + "count": 2, + "block": map[string]any{"register": 35100, "count": 125}, + "decode": "int32be", + }) 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}) +// TestAA55UDPFromConfig_BlockRejectsOutOfRange verifies that a target register +// outside the configured block is rejected. +func TestAA55UDPFromConfig_BlockRejectsOutOfRange(t *testing.T) { + _, err := NewAA55UDPFromConfig(context.Background(), map[string]any{ + "host": "127.0.0.1", + "id": 247, + "register": 36017, // outside READ 125 @ 35100 + "count": 2, + "block": map[string]any{"register": 35100, "count": 125}, + "decode": "float32be", + }) 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") +// TestAA55UDPFromConfig_Register verifies register-read mode (no block). +func TestAA55UDPFromConfig_Register(t *testing.T) { + _, err := NewAA55UDPFromConfig(context.Background(), map[string]any{ + "host": "127.0.0.1", + "register": 30127, + "count": 2, + "decode": "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_Float32BE(t *testing.T) { - payload := make([]byte, 4) - bits := math.Float32bits(123.456) - binary.BigEndian.PutUint32(payload, bits) - v, err := decodeAt(payload, 0, "float32be") - require.NoError(t, err) - assert.InDelta(t, 123.456, v, 0.001) -} - -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(0x7F, 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(0xF7, 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(0x7F, 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(0x7F, 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(0xF7, 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(0xF7, 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(0xF7, 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 } diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml index 12e32fa0c..ec3483d92 100644 --- a/templates/definition/meter/goodwe-wifi-et.yaml +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -18,7 +18,10 @@ render: | source: aa55udp host: {{ or .host .uri }} id: 247 - register: 35139 + block: # READ 125 @ 0x891C + register: 35100 + count: 125 + register: 35139 # grid power count: 2 decode: int32be energy: @@ -32,17 +35,52 @@ render: | {{- end }} {{- if eq .usage "pv" }} power: - source: aa55udp - host: {{ or .host .uri }} - id: 247 - register: 35137 - count: 2 - decode: int32be + source: calc + add: + - source: aa55udp + host: {{ or .host .uri }} + id: 247 + block: # READ 125 @ 0x891C + register: 35100 + count: 125 + register: 35105 # Ppv1 + count: 2 + decode: uint32nan + - source: aa55udp + host: {{ or .host .uri }} + id: 247 + block: + register: 35100 + count: 125 + register: 35109 # Ppv2 + count: 2 + decode: uint32nan + - source: aa55udp + host: {{ or .host .uri }} + id: 247 + block: + register: 35100 + count: 125 + register: 35113 # Ppv3 + count: 2 + decode: uint32nan + - source: aa55udp + host: {{ or .host .uri }} + id: 247 + block: + register: 35100 + count: 125 + register: 35117 # Ppv4 + count: 2 + decode: uint32nan energy: source: aa55udp host: {{ or .host .uri }} id: 247 - register: 35191 + block: + register: 35100 + count: 125 + register: 35191 # pv energy count: 2 decode: uint32be scale: 0.1 @@ -52,14 +90,20 @@ render: | source: aa55udp host: {{ or .host .uri }} id: 247 - register: 35182 + block: # READ 125 @ 0x891C + register: 35100 + count: 125 + register: 35182 # battery power count: 2 decode: int32be energy: source: aa55udp host: {{ or .host .uri }} id: 247 - register: 35209 + block: + register: 35100 + count: 125 + register: 35209 # battery discharge energy count: 2 decode: uint32be scale: 0.1 @@ -67,7 +111,10 @@ render: | source: aa55udp host: {{ or .host .uri }} id: 247 - register: 37007 + block: # READ 13 @ 0x9088 + register: 37000 + count: 13 + register: 37007 # soc count: 1 decode: uint16be {{- end }}