Modbus: reuse modbus decoder in aa55 (#30848)

This commit is contained in:
andig 2026-06-14 18:15:43 +02:00 • committed by GitHub
parent 18609ee874
commit cae5e66085
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 83 additions and 246 deletions

View file

@ -8,10 +8,7 @@
package aa55
import (
"encoding/binary"
"errors"
"fmt"
"math"
"github.com/grid-x/modbus"
)
@ -60,80 +57,3 @@ func modbusCRC16(data []byte) []byte {
}
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)
}

View file

@ -1,11 +1,10 @@
package aa55
import (
"encoding/binary"
"encoding/hex"
"math"
"testing"
"github.com/evcc-io/evcc/util/modbus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@ -108,107 +107,6 @@ func TestStripHeader_Short(t *testing.T) {
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
// ---------------------------------------------------------------------------
@ -243,63 +141,63 @@ func TestModbusCRC16_KnownValue(t *testing.T) {
// ---------------------------------------------------------------------------
func TestDT_Power_GW3000DNS30(t *testing.T) {
assertBlockOffset(t, capGW3000DNS30, 54, "int32be", 1.0, 1972.0)
assertBlockOffset(t, capGW3000DNS30, 54, "int32", 1.0, 1972.0)
}
func TestDT_Power_GW17K(t *testing.T) {
assertBlockOffset(t, capGW17kDT, 54, "int32be", 1.0, 12470.0)
assertBlockOffset(t, capGW17kDT, 54, "int32", 1.0, 12470.0)
}
func TestDT_Power_GW20KAU(t *testing.T) {
assertBlockOffset(t, capGW20kAUDT, 54, "int32be", 1.0, 4957.0)
assertBlockOffset(t, capGW20kAUDT, 54, "int32", 1.0, 4957.0)
}
func TestDT_Energy_GW17K(t *testing.T) {
assertBlockOffset(t, capGW17kDT, 90, "uint32be", 0.1, 29984.4)
assertBlockOffset(t, capGW17kDT, 90, "uint32", 0.1, 29984.4)
}
func TestDT_Energy_GW6000(t *testing.T) {
assertBlockOffset(t, capGW6000DT, 90, "uint32be", 0.1, 13350.2)
assertBlockOffset(t, capGW6000DT, 90, "uint32", 0.1, 13350.2)
}
func TestDT_Energy_GW20KAU(t *testing.T) {
assertBlockOffset(t, capGW20kAUDT, 90, "uint32be", 0.1, 4304.8)
assertBlockOffset(t, capGW20kAUDT, 90, "uint32", 0.1, 4304.8)
}
func TestET_PV_GW10K(t *testing.T) {
assertBlockOffset(t, capGW10kET, 74, "int32be", 1.0, 831.0)
assertBlockOffset(t, capGW10kET, 74, "int32", 1.0, 831.0)
}
func TestET_Grid_GW10K_TinyExport(t *testing.T) {
assertBlockOffset(t, capGW10kET, 78, "int32be", 1.0, -3.0)
assertBlockOffset(t, capGW10kET, 78, "int32", 1.0, -3.0)
}
func TestET_Grid_GW25K_Importing(t *testing.T) {
assertBlockOffset(t, capGW25kET, 78, "int32be", 1.0, 1511.0)
assertBlockOffset(t, capGW25kET, 78, "int32", 1.0, 1511.0)
}
func TestET_Grid_GW29K9_Exporting(t *testing.T) {
assertBlockOffset(t, capGW29k9ET, 78, "int32be", 1.0, -5403.0)
assertBlockOffset(t, capGW29k9ET, 78, "int32", 1.0, -5403.0)
}
func TestET_Battery_GW10K_Charging(t *testing.T) {
assertBlockOffset(t, capGW10kET, 164, "int32be", 1.0, -2512.0)
assertBlockOffset(t, capGW10kET, 164, "int32", 1.0, -2512.0)
}
func TestET_Energy_GW10K(t *testing.T) {
assertBlockOffset(t, capGW10kET, 182, "uint32be", 0.1, 6085.3)
assertBlockOffset(t, capGW10kET, 182, "uint32", 0.1, 6085.3)
}
func TestET_Energy_GW25K(t *testing.T) {
assertBlockOffset(t, capGW25kET, 182, "uint32be", 0.1, 160.3)
assertBlockOffset(t, capGW25kET, 182, "uint32", 0.1, 160.3)
}
func TestET_SoC_GW10K(t *testing.T) {
assertBlockOffset(t, capGW10kETBattery, 14, "uint16be", 1.0, 68.0)
assertBlockOffset(t, capGW10kETBattery, 14, "uint16", 1.0, 68.0)
}
func TestET_SoC_GW25K(t *testing.T) {
assertBlockOffset(t, capGW25kETBattery, 14, "uint16be", 1.0, 100.0)
assertBlockOffset(t, capGW25kETBattery, 14, "uint16", 1.0, 100.0)
}
// ---------------------------------------------------------------------------
@ -317,7 +215,11 @@ func assertBlockOffset(t *testing.T, capHex string, offset int, decode string, s
t.Helper()
payload, err := stripHeader(mustHex(t, capHex))
require.NoError(t, err)
v, err := decodeAt(payload, offset, decode)
reg := modbus.Register{Type: "holding", Decode: decode}
length, err := reg.Length()
require.NoError(t, err)
fn, err := reg.DecodeFunc()
require.NoError(t, err)
v := fn(payload[offset : offset+int(length)*2])
assert.InDelta(t, expected, v*scale, 0.05)
}

View file

@ -31,9 +31,10 @@ var cache = modbus.NewCache(cacheTTL)
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
pdu []byte // 6-byte PDU body, no CRC
offset int // byte offset into the response payload (0 for register reads)
length int // value length in bytes
decode func([]byte) float64 // modbus register decoder
scale float64
cacheKey string // precomputed cache key (remoteAddr/pdu); empty disables caching
delay time.Duration // minimum gap between sends to the inverter (0 disables)
@ -77,14 +78,18 @@ func buildReadConfig(id int, register, count uint16, block *modbus.Block) (readC
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 *modbus.Block, decode string, scale float64, delay time.Duration) (*AA55UDP, error) {
if err := validateDecode(decode); err != nil {
// New constructs an AA55UDP from a high-level configuration. The register count
// and decoder are derived from reg; the read mode is resolved from block.
func New(log *util.Logger, conn *net.UDPConn, id int, reg modbus.Register, block *modbus.Block, scale float64, delay time.Duration) (*AA55UDP, error) {
count, err := reg.Length()
if err != nil {
return nil, err
}
cfg, err := buildReadConfig(id, register, count, block)
decode, err := reg.DecodeFunc()
if err != nil {
return nil, err
}
cfg, err := buildReadConfig(id, reg.Address, count, block)
if err != nil {
return nil, err
}
@ -92,6 +97,7 @@ func New(log *util.Logger, conn *net.UDPConn, id int, register, count uint16, bl
log: log,
conn: conn,
decode: decode,
length: int(count) * 2,
scale: scale,
delay: delay,
pdu: cfg.pdu,
@ -115,17 +121,12 @@ func (p *AA55UDP) query() (float64, error) {
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)
end := p.offset + p.length
if len(payload) < end {
return 0, fmt.Errorf("payload too short (len=%d, need=%d)", len(payload), end)
}
v, err := decodeAt(payload, p.offset, p.decode)
if err != nil {
return 0, err
}
return v * p.scale, nil
return p.decode(payload[p.offset:end]) * p.scale, nil
}
// fetch returns the response payload. In block-read mode the shared cache

View file

@ -11,19 +11,30 @@ import (
"github.com/stretchr/testify/require"
)
// Wire-protocol primitives (buildPDU, stripHeader, DecodeAt, ModbusCRC16,
// Cache, real-capture register tests) are covered in aa55_test.go.
// Wire-protocol primitives are covered in aa55_test.go.
// This file covers the AA55UDP plugin adapter end-to-end via FloatGetter.
func decodeFor(t *testing.T, name string) (func([]byte) float64, int) {
t.Helper()
reg := modbus.Register{Type: "holding", Decode: name}
length, err := reg.Length()
require.NoError(t, err)
fn, err := reg.DecodeFunc()
require.NoError(t, err)
return fn, int(length) * 2
}
// 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)
dec, n := decodeFor(t, "int32")
p := &AA55UDP{
log: util.NewLogger("test"),
conn: mockConn(t, response),
pdu: buildPDU(0x7F, 0x75AF, 2),
decode: "int32be",
decode: dec,
length: n,
scale: 1.0,
}
getter, err := p.FloatGetter()
@ -36,11 +47,13 @@ func TestFloatGetter_DT_Power(t *testing.T) {
// 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)
dec, n := decodeFor(t, "uint32")
p := &AA55UDP{
log: util.NewLogger("test"),
conn: mockConn(t, response),
pdu: buildPDU(0x7F, 0x75C1, 2),
decode: "uint32be",
decode: dec,
length: n,
scale: 0.1,
}
getter, err := p.FloatGetter()
@ -53,11 +66,13 @@ func TestFloatGetter_DT_Energy(t *testing.T) {
// 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)
dec, n := decodeFor(t, "int32")
p := &AA55UDP{
log: util.NewLogger("test"),
conn: mockConn(t, response),
pdu: buildPDU(0xF7, 0x8941, 2),
decode: "int32be",
decode: dec,
length: n,
scale: 1.0,
}
getter, err := p.FloatGetter()
@ -70,11 +85,13 @@ func TestFloatGetter_ET_PV(t *testing.T) {
// TestFloatGetter_ET_Battery verifies charging battery: GW10K-ET = -2512 W.
func TestFloatGetter_ET_Battery(t *testing.T) {
response := singleRegResponse(t, capGW10kET, 164, 4, 0xf7)
dec, n := decodeFor(t, "int32")
p := &AA55UDP{
log: util.NewLogger("test"),
conn: mockConn(t, response),
pdu: buildPDU(0xF7, 0x896E, 2),
decode: "int32be",
decode: dec,
length: n,
scale: 1.0,
}
getter, err := p.FloatGetter()
@ -87,11 +104,13 @@ func TestFloatGetter_ET_Battery(t *testing.T) {
// TestFloatGetter_ET_SoC verifies SoC: GW10K-ET = 68%.
func TestFloatGetter_ET_SoC(t *testing.T) {
response := singleRegResponse(t, capGW10kETBattery, 14, 2, 0xf7)
dec, n := decodeFor(t, "uint16")
p := &AA55UDP{
log: util.NewLogger("test"),
conn: mockConn(t, response),
pdu: buildPDU(0xF7, 0x908F, 1),
decode: "uint16be",
decode: dec,
length: n,
scale: 1.0,
}
getter, err := p.FloatGetter()

View file

@ -60,11 +60,6 @@ func NewAA55UDPFromConfig(ctx context.Context, other map[string]any) (Plugin, er
return nil, err
}
count, err := cc.Register.Length()
if err != nil {
return nil, err
}
raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899"))
if err != nil {
return nil, err
@ -76,7 +71,7 @@ func NewAA55UDPFromConfig(ctx context.Context, other map[string]any) (Plugin, er
return nil, err
}
res, err := aa55.New(util.NewLogger("aa55udp"), conn, cc.Id, cc.Register.Address, count, cc.Block, cc.Register.Decode, cc.Scale, cc.Delay)
res, err := aa55.New(util.NewLogger("aa55udp"), conn, cc.Id, cc.Register, cc.Block, cc.Scale, cc.Delay)
if err != nil {
return nil, fmt.Errorf("aa55udp: %w", err)
}

View file

@ -8,12 +8,12 @@ import (
)
// TestAA55UDPFromConfig_Block verifies the modbus.Register and nested block map
// decode, with the count (2 registers) derived from the int32be decode width.
// decode, with the count (2 registers) derived from the int32 decode width.
func TestAA55UDPFromConfig_Block(t *testing.T) {
_, err := NewAA55UDPFromConfig(context.Background(), map[string]any{
"host": "127.0.0.1",
"id": 247,
"register": map[string]any{"address": 35139, "decode": "int32be"},
"register": map[string]any{"address": 35139, "decode": "int32"},
"block": map[string]any{"register": 35100, "count": 125},
})
require.NoError(t, err)
@ -25,7 +25,7 @@ func TestAA55UDPFromConfig_BlockRejectsOutOfRange(t *testing.T) {
_, err := NewAA55UDPFromConfig(context.Background(), map[string]any{
"host": "127.0.0.1",
"id": 247,
"register": map[string]any{"address": 36017, "decode": "float32be"}, // outside READ 125 @ 35100
"register": map[string]any{"address": 36017, "decode": "float32"}, // outside READ 125 @ 35100
"block": map[string]any{"register": 35100, "count": 125},
})
require.Error(t, err)
@ -35,7 +35,7 @@ func TestAA55UDPFromConfig_BlockRejectsOutOfRange(t *testing.T) {
func TestAA55UDPFromConfig_Register(t *testing.T) {
_, err := NewAA55UDPFromConfig(context.Background(), map[string]any{
"host": "127.0.0.1",
"register": map[string]any{"address": 30127, "decode": "int32be"},
"register": map[string]any{"address": 30127, "decode": "int32"},
})
require.NoError(t, err)
}

View file

@ -16,11 +16,11 @@ render: |
host: {{ .host }}
register:
address: 30127
decode: int32be
decode: int32
energy:
source: aa55udp
host: {{ .host }}
register:
address: 30145
decode: uint32be
decode: uint32
scale: 0.1

View file

@ -19,7 +19,7 @@ render: |
host: {{ or .host .uri }}
register:
address: 29964
decode: int32be
decode: int32
{{- end }}
{{- if eq .usage "pv" }}
power:
@ -27,7 +27,7 @@ render: |
host: {{ or .host .uri }}
register:
address: 29958
decode: int32be
decode: int32
{{- end }}
{{- if eq .usage "battery" }}
power:
@ -35,11 +35,11 @@ render: |
host: {{ or .host .uri }}
register:
address: 29970
decode: int32be
decode: int32
soc:
source: aa55udp
host: {{ or .host .uri }}
register:
address: 29966
decode: uint16be
decode: uint16
{{- end }}

View file

@ -27,7 +27,7 @@ render: |
count: 125
register:
address: 35139 # grid power
decode: int32be
decode: int32
delay: {{ .delay }}
energy:
source: aa55udp
@ -35,7 +35,7 @@ render: |
id: 247
register:
address: 36017
decode: float32be
decode: float32
scale: 0.001
delay: {{ .delay }}
{{- end }}
@ -92,7 +92,7 @@ render: |
count: 125
register:
address: 35191 # pv energy
decode: uint32be
decode: uint32
scale: 0.1
delay: {{ .delay }}
{{- end }}
@ -106,7 +106,7 @@ render: |
count: 125
register:
address: 35182 # battery power
decode: int32be
decode: int32
delay: {{ .delay }}
energy:
source: aa55udp
@ -117,7 +117,7 @@ render: |
count: 125
register:
address: 35209 # battery discharge energy
decode: uint32be
decode: uint32
scale: 0.1
delay: {{ .delay }}
soc:
@ -129,6 +129,6 @@ render: |
count: 13
register:
address: 37007 # soc
decode: uint16be
decode: uint16
delay: {{ .delay }}
{{- end }}