From 0a62ba106b6c8900c4102b558d5bbc5821318000 Mon Sep 17 00:00:00 2001 From: andig Date: Sun, 21 Jun 2026 22:24:04 +0200 Subject: [PATCH] GoodWe: add battery mode control over WiFi (AA55/UDP) (#30965) --- plugin/aa55/aa55.go | 39 ++++++++ plugin/aa55/aa55_test.go | 27 ++++++ plugin/aa55/aa55udp.go | 92 ++++++++++++++++++- plugin/aa55udp.go | 16 +++- .../definition/meter/goodwe-wifi-et.yaml | 92 +++++++++++++++++++ util/modbus/blockcache.go | 9 ++ 6 files changed, 273 insertions(+), 2 deletions(-) diff --git a/plugin/aa55/aa55.go b/plugin/aa55/aa55.go index 63c713427..64832d26b 100644 --- a/plugin/aa55/aa55.go +++ b/plugin/aa55/aa55.go @@ -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 diff --git a/plugin/aa55/aa55_test.go b/plugin/aa55/aa55_test.go index b8acefbda..34235354f 100644 --- a/plugin/aa55/aa55_test.go +++ b/plugin/aa55/aa55_test.go @@ -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 // --------------------------------------------------------------------------- diff --git a/plugin/aa55/aa55udp.go b/plugin/aa55/aa55udp.go index 63ca4d4df..4af82840a 100644 --- a/plugin/aa55/aa55udp.go +++ b/plugin/aa55/aa55udp.go @@ -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) } diff --git a/plugin/aa55udp.go b/plugin/aa55udp.go index 3aa2183f7..95b8915da 100644 --- a/plugin/aa55udp.go +++ b/plugin/aa55udp.go @@ -5,6 +5,7 @@ import ( "fmt" "net" "net/netip" + "strings" "time" "github.com/evcc-io/evcc/plugin/aa55" @@ -71,8 +72,21 @@ 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, cc.Block, cc.Scale, cc.Delay) + log := util.NewLogger("aa55udp") + + // write register type selects the setter, otherwise a (block) reader + if strings.HasPrefix(strings.ToLower(cc.Register.Type), "write") { + res, err := aa55.NewSetter(log, conn, cc.Id, cc.Register, cc.Scale, cc.Delay) + if err != nil { + _ = conn.Close() + return nil, fmt.Errorf("aa55udp: %w", err) + } + return res, nil + } + + res, err := aa55.New(log, conn, cc.Id, cc.Register, cc.Block, cc.Scale, cc.Delay) if err != nil { + _ = conn.Close() return nil, fmt.Errorf("aa55udp: %w", err) } diff --git a/templates/definition/meter/goodwe-wifi-et.yaml b/templates/definition/meter/goodwe-wifi-et.yaml index a61619f60..c288d88d1 100644 --- a/templates/definition/meter/goodwe-wifi-et.yaml +++ b/templates/definition/meter/goodwe-wifi-et.yaml @@ -6,6 +6,7 @@ products: - brand: GoodWe description: generic: ET/EH/BT/BH Hybrid Inverter (WiFi) +capabilities: ["battery-control"] params: - name: usage choice: ["grid", "pv", "battery"] @@ -16,6 +17,15 @@ params: type: duration default: 0.1s advanced: true + - preset: battery-params + - name: maxchargepower + default: 10000 + - name: maxdischargepower + default: 10000 + - name: minsoc + default: 10 + - name: maxsoc + default: 100 render: | type: custom {{- if eq .usage "grid" }} @@ -133,4 +143,86 @@ render: | address: 37007 # soc decode: uint16nan delay: {{ .delay }} + batterymode: + source: switch + switch: + - case: 1 # normal + set: + source: sequence + set: + - source: const + value: 1 # Normal operation mode + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47511 # EMSPowerMode + type: writesingle + encoding: uint16 + delay: {{ .delay }} + - source: const + value: 0 # Maximum allowed power from Grid in W. + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47512 # EMSPowerSet [0-10000] + type: writemultiple + encoding: uint16 + delay: {{ .delay }} + - case: 2 # discharge hold + set: + source: sequence + set: + - source: const + value: 2 # EMSPowerMode 2 "Charge-PV"-Mode. If EMSPowerSet=0 only PV is used to charge. + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47511 # EMSPowerMode + type: writesingle + encoding: uint16 + delay: {{ .delay }} + - source: const + value: 0 # Maximum allowed power from Grid in W. If >0 battery will be charged also from Grid in Standby mode. + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47512 # EMSPowerSet [0-10000] + type: writemultiple + encoding: uint16 + delay: {{ .delay }} + - case: 3 # charge + set: + source: sequence + set: + - source: const + value: 2 # Charge from PV+AC according to the EMSPowerSet below. PV-Preferred. + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47511 # EMSPowerMode + type: writesingle + encoding: uint16 + delay: {{ .delay }} + - source: const + value: {{ .maxchargepower }} # For charge battery mode: Maximum allowed power from Grid in W + set: + source: aa55udp + host: {{ or .host .uri }} + id: 247 + register: + address: 47512 # EMSPowerSet [0-10000] + type: writemultiple + encoding: uint16 + delay: {{ .delay }} + {{- include "battery-params" . }} {{- end }} diff --git a/util/modbus/blockcache.go b/util/modbus/blockcache.go index 1422c1943..2f67138a5 100644 --- a/util/modbus/blockcache.go +++ b/util/modbus/blockcache.go @@ -69,6 +69,15 @@ func (c *Cache) get(key string) ([]byte, bool) { return e.payload, true } +// Clear drops all cached entries. Callers use this after a write to force the +// next read to fetch fresh values instead of serving a stale cached payload. +func (c *Cache) Clear() { + c.mu.Lock() + defer c.mu.Unlock() + + clear(c.data) +} + // put inserts or overwrites a payload in the cache. func (c *Cache) put(key string, payload []byte) { c.mu.Lock()