GoodWe: add battery mode control over WiFi (AA55/UDP) (#30965)
This commit is contained in:
parent
d835cfbde3
commit
0a62ba106b
6 changed files with 273 additions and 2 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue