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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue