aa55 udp: support block reads (#29095)

This commit is contained in:
Klaas Gadeyne 2026-06-04 11:41:03 +02:00 • committed by GitHub
parent 2556c8e88f
commit 08b9dbbfc1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 1031 additions and 642 deletions

139
plugin/aa55/aa55.go Normal file
View file

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

341
plugin/aa55/aa55_test.go Normal file
View file

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

177
plugin/aa55/aa55udp.go Normal file
View file

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

183
plugin/aa55/aa55udp_test.go Normal file
View file

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

61
plugin/aa55/cache.go Normal file
View file

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

View file

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

View file

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

View file

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