GoodWe: add battery mode control over WiFi (AA55/UDP) (#30965)

This commit is contained in:
andig 2026-06-21 22:24:04 +02:00 • committed by GitHub
parent d835cfbde3
commit 0a62ba106b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 273 additions and 2 deletions

View file

@ -9,6 +9,7 @@ package aa55
import (
"errors"
"fmt"
"github.com/grid-x/modbus"
)
@ -27,6 +28,44 @@ func buildPDU(addr byte, register, count uint16) []byte {
}
}
// buildWriteSinglePDU constructs the 6-byte PDU body for a WRITE SINGLE REGISTER
// request: [addr, 0x06, reg_hi, reg_lo, val_hi, val_lo].
func buildWriteSinglePDU(addr byte, register, value uint16) []byte {
return []byte{
addr, modbus.FuncCodeWriteSingleRegister,
byte(register >> 8), byte(register),
byte(value >> 8), byte(value),
}
}
// buildWriteMultiplePDU constructs the WRITE MULTIPLE REGISTERS PDU body:
// [addr, 0x10, reg_hi, reg_lo, count_hi, count_lo, byteCount, data…].
func buildWriteMultiplePDU(addr byte, register uint16, values []byte) []byte {
count := uint16(len(values) / 2)
pdu := []byte{
addr, modbus.FuncCodeWriteMultipleRegisters,
byte(register >> 8), byte(register),
byte(count >> 8), byte(count),
byte(len(values)),
}
return append(pdu, values...)
}
// validateWriteResponse checks the AA55 frame echoed for a write request:
// only the magic bytes and echoed function code are validated (high bit = reject).
func validateWriteResponse(buf []byte, funcCode byte) error {
if len(buf) < 4 || buf[0] != 0xAA || buf[1] != 0x55 {
return errors.New("invalid response header")
}
if buf[3] == funcCode|0x80 {
return fmt.Errorf("write rejected (code %#x)", buf[3])
}
if buf[3] != funcCode {
return fmt.Errorf("unexpected function code %#x", buf[3])
}
return nil
}
// 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

View file

@ -80,6 +80,33 @@ func TestBuildPDU_SoC(t *testing.T) {
assert.Equal(t, []byte{0xf7, 0x03, 0x90, 0x8f, 0x00, 0x01}, got)
}
// ---------------------------------------------------------------------------
// write PDUs
// ---------------------------------------------------------------------------
func TestBuildWriteSinglePDU(t *testing.T) {
// EMSPowerMode (47511 = 0xB997) := 1
got := buildWriteSinglePDU(0xF7, 47511, 1)
assert.Equal(t, []byte{0xf7, 0x06, 0xb9, 0x97, 0x00, 0x01}, got)
}
func TestBuildWriteMultiplePDU(t *testing.T) {
// EMSPowerSet (47512 = 0xB998) := 10000 (0x2710), one register
got := buildWriteMultiplePDU(0xF7, 47512, []byte{0x27, 0x10})
assert.Equal(t, []byte{0xf7, 0x10, 0xb9, 0x98, 0x00, 0x01, 0x02, 0x27, 0x10}, got)
}
func TestValidateWriteResponse(t *testing.T) {
// echoed function code → accepted
require.NoError(t, validateWriteResponse([]byte{0xAA, 0x55, 0xF7, 0x06, 0xb9, 0x97, 0x00, 0x01}, 0x06))
// high bit set → inverter rejected the write
require.Error(t, validateWriteResponse([]byte{0xAA, 0x55, 0xF7, 0x86, 0x02}, 0x06))
// wrong function code
require.Error(t, validateWriteResponse([]byte{0xAA, 0x55, 0xF7, 0x03, 0x00}, 0x06))
// bad magic
require.Error(t, validateWriteResponse([]byte{0xFF, 0x55, 0xF7, 0x06}, 0x06))
}
// ---------------------------------------------------------------------------
// stripHeader
// ---------------------------------------------------------------------------

View file

@ -8,12 +8,16 @@ import (
"github.com/evcc-io/evcc/util"
"github.com/evcc-io/evcc/util/modbus"
gridx "github.com/grid-x/modbus"
)
// cacheTTL serves all sources within one poll cycle (well under 1s) while
// forcing a fresh read on the next cycle.
const cacheTTL = 2 * time.Second
// readTimeout bounds the wait for a response
const readTimeout = 2 * time.Second
// cache de-duplicates block reads across all AA55UDP instances so multiple
// sources covering the same (host, block) share one UDP exchange per cycle.
var cache = modbus.NewCache(cacheTTL)
@ -38,6 +42,11 @@ type AA55UDP struct {
scale float64
cacheKey string // precomputed cache key (remoteAddr/pdu); empty disables caching
delay time.Duration // minimum gap between sends to the inverter (0 disables)
// write mode (set by NewSetter)
id byte
reg modbus.Register
writable bool
}
// readConfig holds the resolved read mode configuration.
@ -109,8 +118,89 @@ func New(log *util.Logger, conn *net.UDPConn, id int, reg modbus.Register, block
return ap, nil
}
// NewSetter constructs a write-mode AA55UDP for a single holding register.
// The register type must be a write type (writesingle/writemultiple).
func NewSetter(log *util.Logger, conn *net.UDPConn, id int, reg modbus.Register, scale float64, delay time.Duration) (*AA55UDP, error) {
if id < 0 || id > 255 {
return nil, fmt.Errorf("id must be 0-255, got %d", id)
}
if err := reg.Error(); err != nil {
return nil, err
}
return &AA55UDP{
log: log,
conn: conn,
scale: scale,
delay: delay,
id: byte(id),
reg: reg,
writable: true,
}, nil
}
// FloatSetter implements the evcc plugin.FloatSetter interface.
func (p *AA55UDP) FloatSetter(_ string) (func(float64) error, error) {
return p.writeFunc()
}
// IntSetter implements the evcc plugin.IntSetter interface.
func (p *AA55UDP) IntSetter(_ string) (func(int64) error, error) {
set, err := p.writeFunc()
if err != nil {
return nil, err
}
return func(val int64) error {
return set(float64(val))
}, nil
}
// writeFunc builds the register writer derived from the register's func code.
func (p *AA55UDP) writeFunc() (func(float64) error, error) {
fc, err := p.reg.FuncCode()
if err != nil {
return nil, err
}
encode, err := p.reg.EncodeFunc()
if err != nil {
return nil, err
}
return func(val float64) error {
val *= p.scale
var pdu []byte
switch fc {
case gridx.FuncCodeWriteSingleRegister:
pdu = buildWriteSinglePDU(p.id, p.reg.Address, uint16(val))
case gridx.FuncCodeWriteMultipleRegisters:
b, err := encode(val)
if err != nil {
return err
}
pdu = buildWriteMultiplePDU(p.id, p.reg.Address, b)
default:
return fmt.Errorf("invalid func code: %d", fc)
}
raw, err := p.sendRecv(append(pdu, modbusCRC16(pdu)...))
if err != nil {
return err
}
if err := validateWriteResponse(raw, fc); err != nil {
return err
}
// a write invalidates cached reads for all sources of this inverter
cache.Clear()
return nil
}, nil
}
// FloatGetter implements the evcc plugin.FloatGetter interface.
func (p *AA55UDP) FloatGetter() (func() (float64, error), error) {
if p.writable {
return nil, errors.New("register configured for write")
}
return p.query, nil
}
@ -175,7 +265,7 @@ func (p *AA55UDP) sendRecv(packet []byte) ([]byte, error) {
return nil, fmt.Errorf("write: %w", err)
}
if err := p.conn.SetReadDeadline(time.Now().Add(4 * time.Second)); err != nil {
if err := p.conn.SetReadDeadline(time.Now().Add(readTimeout)); err != nil {
return nil, fmt.Errorf("deadline: %w", err)
}