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 (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/grid-x/modbus"
|
"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
|
// stripHeader validates the AA55 response frame and returns the bare payload
|
||||||
// (without the 5-byte header and trailing 2-byte CRC).
|
// (without the 5-byte header and trailing 2-byte CRC).
|
||||||
// buf[2] is the inverter source address, which varies by family — only the
|
// 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)
|
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
|
// stripHeader
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,16 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/evcc-io/evcc/util/modbus"
|
"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
|
// cacheTTL serves all sources within one poll cycle (well under 1s) while
|
||||||
// forcing a fresh read on the next cycle.
|
// forcing a fresh read on the next cycle.
|
||||||
const cacheTTL = 2 * time.Second
|
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
|
// cache de-duplicates block reads across all AA55UDP instances so multiple
|
||||||
// sources covering the same (host, block) share one UDP exchange per cycle.
|
// sources covering the same (host, block) share one UDP exchange per cycle.
|
||||||
var cache = modbus.NewCache(cacheTTL)
|
var cache = modbus.NewCache(cacheTTL)
|
||||||
|
|
@ -38,6 +42,11 @@ type AA55UDP struct {
|
||||||
scale float64
|
scale float64
|
||||||
cacheKey string // precomputed cache key (remoteAddr/pdu); empty disables caching
|
cacheKey string // precomputed cache key (remoteAddr/pdu); empty disables caching
|
||||||
delay time.Duration // minimum gap between sends to the inverter (0 disables)
|
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.
|
// 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
|
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.
|
// FloatGetter implements the evcc plugin.FloatGetter interface.
|
||||||
func (p *AA55UDP) FloatGetter() (func() (float64, error), error) {
|
func (p *AA55UDP) FloatGetter() (func() (float64, error), error) {
|
||||||
|
if p.writable {
|
||||||
|
return nil, errors.New("register configured for write")
|
||||||
|
}
|
||||||
return p.query, nil
|
return p.query, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -175,7 +265,7 @@ func (p *AA55UDP) sendRecv(packet []byte) ([]byte, error) {
|
||||||
return nil, fmt.Errorf("write: %w", err)
|
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)
|
return nil, fmt.Errorf("deadline: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"net/netip"
|
"net/netip"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/plugin/aa55"
|
"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
|
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 {
|
if err != nil {
|
||||||
|
_ = conn.Close()
|
||||||
return nil, fmt.Errorf("aa55udp: %w", err)
|
return nil, fmt.Errorf("aa55udp: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ products:
|
||||||
- brand: GoodWe
|
- brand: GoodWe
|
||||||
description:
|
description:
|
||||||
generic: ET/EH/BT/BH Hybrid Inverter (WiFi)
|
generic: ET/EH/BT/BH Hybrid Inverter (WiFi)
|
||||||
|
capabilities: ["battery-control"]
|
||||||
params:
|
params:
|
||||||
- name: usage
|
- name: usage
|
||||||
choice: ["grid", "pv", "battery"]
|
choice: ["grid", "pv", "battery"]
|
||||||
|
|
@ -16,6 +17,15 @@ params:
|
||||||
type: duration
|
type: duration
|
||||||
default: 0.1s
|
default: 0.1s
|
||||||
advanced: true
|
advanced: true
|
||||||
|
- preset: battery-params
|
||||||
|
- name: maxchargepower
|
||||||
|
default: 10000
|
||||||
|
- name: maxdischargepower
|
||||||
|
default: 10000
|
||||||
|
- name: minsoc
|
||||||
|
default: 10
|
||||||
|
- name: maxsoc
|
||||||
|
default: 100
|
||||||
render: |
|
render: |
|
||||||
type: custom
|
type: custom
|
||||||
{{- if eq .usage "grid" }}
|
{{- if eq .usage "grid" }}
|
||||||
|
|
@ -133,4 +143,86 @@ render: |
|
||||||
address: 37007 # soc
|
address: 37007 # soc
|
||||||
decode: uint16nan
|
decode: uint16nan
|
||||||
delay: {{ .delay }}
|
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 }}
|
{{- end }}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,15 @@ func (c *Cache) get(key string) ([]byte, bool) {
|
||||||
return e.payload, true
|
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.
|
// put inserts or overwrites a payload in the cache.
|
||||||
func (c *Cache) put(key string, payload []byte) {
|
func (c *Cache) put(key string, payload []byte) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue