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

View file

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

View file

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

View file

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