aa55 udp: support block reads (#29095)
This commit is contained in:
parent
2556c8e88f
commit
08b9dbbfc1
8 changed files with 1031 additions and 642 deletions
|
|
@ -2,235 +2,77 @@ package plugin
|
|||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"time"
|
||||
"net/netip"
|
||||
|
||||
"github.com/evcc-io/evcc/plugin/aa55"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
)
|
||||
|
||||
// AA55UDP implements the GoodWe WiFi AA55-over-UDP wire protocol as a generic
|
||||
// evcc source plugin.
|
||||
//
|
||||
// The inverter speaks a simple request/response protocol over UDP port 8899:
|
||||
//
|
||||
// Request: [6-byte Modbus PDU body] [Modbus CRC-16, little-endian]
|
||||
// Response: AA 55 [src] 03 [byteCount] [payload…] [CRC]
|
||||
//
|
||||
// src varies by inverter family (0x7F for DT/DNS, 0xF7 for ET/EH/BT/BH);
|
||||
// only the AA 55 magic bytes and function code 0x03 are validated.
|
||||
//
|
||||
// Each instance reads exactly one value from one register (or register pair
|
||||
// for 32-bit values), matching how Modbus source plugins work. The PDU is
|
||||
// constructed from register and count; the decoded value is always at offset 0
|
||||
// of the response payload.
|
||||
type AA55UDP struct {
|
||||
log *util.Logger
|
||||
conn *net.UDPConn
|
||||
pdu []byte // 6-byte PDU body, no CRC
|
||||
decode string // int32be | uint32be | int16be | uint16be | float32be
|
||||
scale float64
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.AddCtx("aa55udp", NewAA55UDPFromConfig)
|
||||
}
|
||||
|
||||
// NewAA55UDPFromConfig creates an AA55UDP plugin from a source block:
|
||||
// NewAA55UDPFromConfig creates a GoodWe AA55-over-UDP plugin.
|
||||
//
|
||||
// Register read mode (single register):
|
||||
//
|
||||
// source: aa55udp
|
||||
// host: 192.168.1.26
|
||||
// id: 127 # 0x7F for DT/DNS/ES/EM (default); 247 (0xF7) for ET/EH/BT/BH
|
||||
// register: 30127
|
||||
// count: 2 # 1 = 16-bit, 2 = 32-bit
|
||||
// decode: int32be
|
||||
// scale: 1.0
|
||||
//
|
||||
// Block read mode (fetch an enclosing block once, extract the target register):
|
||||
//
|
||||
// source: aa55udp
|
||||
// host: 192.168.1.26 # inverter IP; port 8899 is always used
|
||||
// id: 0x7F # inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH
|
||||
// register: 30127 # Modbus register address (0-based, uint16)
|
||||
// count: 2 # number of registers to read (1=U16, 2=S32/U32)
|
||||
// decode: int32be # int32be | uint32be | int16be | uint16be | float32be
|
||||
// id: 247 # 0x7F (default) for DT/DNS/ES/EM; 247 (0xF7) for ET/EH/BT/BH
|
||||
// register: 35139 # target register
|
||||
// count: 2 # 1 = 16-bit, 2 = 32-bit
|
||||
// block: # enclosing block fetched in a single UDP exchange
|
||||
// register: 35100 # block start register
|
||||
// count: 125 # block length (registers)
|
||||
// decode: int32be # int32be | uint32be | uint32nan | int16be | uint16be | float32be
|
||||
// scale: 1.0 # optional multiplier (default 1.0)
|
||||
func NewAA55UDPFromConfig(_ context.Context, other map[string]any) (Plugin, error) {
|
||||
func NewAA55UDPFromConfig(ctx context.Context, other map[string]any) (Plugin, error) {
|
||||
cc := struct {
|
||||
Host string `mapstructure:"host"`
|
||||
Id int `mapstructure:"id"`
|
||||
Register uint16 `mapstructure:"register"`
|
||||
Count uint16 `mapstructure:"count"`
|
||||
Decode string `mapstructure:"decode"`
|
||||
Scale float64 `mapstructure:"scale"`
|
||||
Host string
|
||||
Id int
|
||||
Register uint16
|
||||
Count uint16
|
||||
Block *aa55.Block
|
||||
Decode string
|
||||
Scale float64
|
||||
}{
|
||||
Id: int(aa55InverterAddr),
|
||||
Id: int(aa55.InverterAddr),
|
||||
Count: 2,
|
||||
Scale: 1.0,
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if cc.Count == 0 {
|
||||
return nil, errors.New("aa55udp: count must be ≥ 1")
|
||||
}
|
||||
|
||||
if cc.Id < 0 || cc.Id > 255 {
|
||||
return nil, fmt.Errorf("aa55udp: id must be 0-255, got %d", cc.Id)
|
||||
}
|
||||
|
||||
switch cc.Decode {
|
||||
case "int32be", "uint32be", "int16be", "uint16be", "float32be":
|
||||
default:
|
||||
return nil, fmt.Errorf("aa55udp: unsupported decode %q (want int32be|uint32be|int16be|uint16be|float32be)", cc.Decode)
|
||||
}
|
||||
|
||||
pdu := buildPDU(byte(cc.Id), cc.Register, cc.Count)
|
||||
|
||||
addr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899"))
|
||||
raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(cc.Host, "8899"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: resolve %s: %w", cc.Host, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
conn, err := net.DialUDP("udp4", nil, addr)
|
||||
dialer := &net.Dialer{Timeout: request.Timeout}
|
||||
conn, err := dialer.DialUDP(ctx, "udp4", netip.AddrPort{}, raddr.AddrPort())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: dial %s: %w", cc.Host, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &AA55UDP{
|
||||
log: util.NewLogger("aa55udp"),
|
||||
conn: conn,
|
||||
pdu: pdu,
|
||||
decode: cc.Decode,
|
||||
scale: cc.Scale,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// FloatGetter implements the evcc Plugin interface.
|
||||
func (p *AA55UDP) FloatGetter() (func() (float64, error), error) {
|
||||
return p.query, nil
|
||||
}
|
||||
|
||||
// query sends the PDU and returns the decoded, scaled value at offset 0 of the
|
||||
// response payload.
|
||||
func (p *AA55UDP) query() (float64, error) {
|
||||
packet := append(p.pdu, modbusCRC16(p.pdu)...)
|
||||
|
||||
if _, err := p.conn.Write(packet); err != nil {
|
||||
return 0, fmt.Errorf("aa55udp write: %w", err)
|
||||
}
|
||||
|
||||
if err := p.conn.SetReadDeadline(time.Now().Add(4 * time.Second)); err != nil {
|
||||
return 0, fmt.Errorf("aa55udp deadline: %w", err)
|
||||
}
|
||||
|
||||
buf := make([]byte, 512)
|
||||
n, err := p.conn.Read(buf)
|
||||
res, err := aa55.New(util.NewLogger("aa55udp"), conn, cc.Id, cc.Register, cc.Count, cc.Block, cc.Decode, cc.Scale)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp read: %w", err)
|
||||
return nil, fmt.Errorf("aa55udp: %w", err)
|
||||
}
|
||||
|
||||
payload, err := stripAA55Header(buf[:n])
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp: %w", err)
|
||||
}
|
||||
|
||||
v, err := decodeAt(payload, 0, p.decode)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("aa55udp: %w", err)
|
||||
}
|
||||
|
||||
return v * p.scale, nil
|
||||
}
|
||||
|
||||
// aa55InverterAddr is the default inverter address byte, used by DT/DNS and ES/EM families.
|
||||
// ET/EH/BT/BH families require 0xF7 instead.
|
||||
const aa55InverterAddr = 0x7F
|
||||
|
||||
// aa55ReadFunc is the Modbus function code for READ HOLDING REGISTERS.
|
||||
const aa55ReadFunc = 0x03
|
||||
|
||||
// buildPDU constructs the 6-byte PDU for a READ HOLDING REGISTERS request.
|
||||
// addr is the inverter address byte: 0x7F for DT/DNS/ES/EM, 0xF7 for ET/EH/BT/BH.
|
||||
func buildPDU(addr byte, register, count uint16) []byte {
|
||||
return []byte{
|
||||
addr, aa55ReadFunc,
|
||||
byte(register >> 8), byte(register),
|
||||
byte(count >> 8), byte(count),
|
||||
}
|
||||
}
|
||||
|
||||
// parsePDUHex decodes a hex string (spaces allowed) into exactly 6 bytes.
|
||||
// Kept for use in tests.
|
||||
func parsePDUHex(s string) ([]byte, error) {
|
||||
clean := strings.ReplaceAll(s, " ", "")
|
||||
b, err := hex.DecodeString(clean)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("aa55udp: invalid pdu %q: %w", s, err)
|
||||
}
|
||||
if len(b) != 6 {
|
||||
return nil, fmt.Errorf("aa55udp: pdu must be 6 bytes, got %d", len(b))
|
||||
}
|
||||
return b, nil
|
||||
}
|
||||
|
||||
// stripAA55Header 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 and varies by family — only the
|
||||
// AA 55 magic bytes and function code 0x03 are validated.
|
||||
func stripAA55Header(buf []byte) ([]byte, error) {
|
||||
if len(buf) < 6 || buf[0] != 0xAA || buf[1] != 0x55 || buf[3] != 0x03 {
|
||||
return nil, errors.New("invalid response header")
|
||||
}
|
||||
byteCount := int(buf[4])
|
||||
if len(buf) < 5+byteCount+2 {
|
||||
return nil, errors.New("short response")
|
||||
}
|
||||
return buf[5 : 5+byteCount], nil
|
||||
}
|
||||
|
||||
// decodeAt extracts an integer at the given byte offset of payload and
|
||||
// interprets it according to decode.
|
||||
func decodeAt(payload []byte, offset int, decode string) (float64, error) {
|
||||
switch decode {
|
||||
case "float32be":
|
||||
if len(payload) < offset+4 {
|
||||
return 0, fmt.Errorf("payload too short for float32be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
bits := binary.BigEndian.Uint32(payload[offset:])
|
||||
return float64(math.Float32frombits(bits)), nil
|
||||
case "int32be":
|
||||
if len(payload) < offset+4 {
|
||||
return 0, fmt.Errorf("payload too short for int32be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(int32(binary.BigEndian.Uint32(payload[offset:]))), nil
|
||||
case "uint32be":
|
||||
if len(payload) < offset+4 {
|
||||
return 0, fmt.Errorf("payload too short for uint32be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(binary.BigEndian.Uint32(payload[offset:])), nil
|
||||
case "int16be":
|
||||
if len(payload) < offset+2 {
|
||||
return 0, fmt.Errorf("payload too short for int16be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(int16(binary.BigEndian.Uint16(payload[offset:]))), nil
|
||||
case "uint16be":
|
||||
if len(payload) < offset+2 {
|
||||
return 0, fmt.Errorf("payload too short for uint16be at offset %d (len=%d)", offset, len(payload))
|
||||
}
|
||||
return float64(binary.BigEndian.Uint16(payload[offset:])), nil
|
||||
}
|
||||
return 0, fmt.Errorf("unknown decode type: %s", decode)
|
||||
}
|
||||
|
||||
// modbusCRC16 computes the Modbus CRC-16 (little-endian byte order).
|
||||
func modbusCRC16(data []byte) []byte {
|
||||
crc := uint16(0xFFFF)
|
||||
for _, b := range data {
|
||||
crc ^= uint16(b)
|
||||
for range 8 {
|
||||
if crc&0x0001 != 0 {
|
||||
crc = (crc >> 1) ^ 0xA001
|
||||
} else {
|
||||
crc >>= 1
|
||||
}
|
||||
}
|
||||
}
|
||||
return []byte{byte(crc & 0xFF), byte(crc >> 8)}
|
||||
return res, nil
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue