139 lines
4.7 KiB
Go
139 lines
4.7 KiB
Go
// Package aa55 implements the GoodWe WiFi AA55-over-UDP wire protocol used by
|
|
// the GoodWe inverter families (DT/DNS, ES/EM, ET/EH/BT/BH).
|
|
//
|
|
// The inverter speaks a simple request/response protocol over UDP port 8899:
|
|
//
|
|
// Request: [6-byte PDU body] [Modbus CRC-16, little-endian]
|
|
// Response: AA 55 [src] 03 [byteCount] [payload…] [CRC]
|
|
package aa55
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
|
|
"github.com/grid-x/modbus"
|
|
)
|
|
|
|
// InverterAddr is the default inverter address byte, used by DT/DNS and ES/EM
|
|
// families. ET/EH/BT/BH families require 0xF7 (247) instead.
|
|
const InverterAddr byte = 0x7F
|
|
|
|
// buildPDU constructs the 6-byte PDU body 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, modbus.FuncCodeReadHoldingRegisters,
|
|
byte(register >> 8), byte(register),
|
|
byte(count >> 8), byte(count),
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// AA 55 magic bytes and the READ HOLDING REGISTERS function code are validated.
|
|
func stripHeader(buf []byte) ([]byte, error) {
|
|
if len(buf) < 6 || buf[0] != 0xAA || buf[1] != 0x55 || buf[3] != modbus.FuncCodeReadHoldingRegisters {
|
|
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
|
|
}
|
|
|
|
// 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)}
|
|
}
|
|
|
|
// decodeMeta describes the properties of a supported decode type.
|
|
type decodeMeta struct {
|
|
size int
|
|
}
|
|
|
|
func decodeMetadata(name string) (decodeMeta, bool) {
|
|
switch name {
|
|
case "float32be", "int32be", "uint32be", "uint32nan":
|
|
return decodeMeta{size: 4}, true
|
|
case "int16be", "uint16be":
|
|
return decodeMeta{size: 2}, true
|
|
default:
|
|
return decodeMeta{}, false
|
|
}
|
|
}
|
|
|
|
// validateDecode returns an error if decode is not a supported type.
|
|
func validateDecode(decode string) error {
|
|
if _, ok := decodeMetadata(decode); !ok {
|
|
return fmt.Errorf("unsupported decode %q (want int32be|uint32be|uint32nan|int16be|uint16be|float32be)", decode)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// decodeSize returns the number of bytes required to decode the given type.
|
|
// Panics if decode type is unknown — callers must validateDecode first.
|
|
func decodeSize(decode string) int {
|
|
if info, ok := decodeMetadata(decode); ok {
|
|
return info.size
|
|
}
|
|
panic(fmt.Sprintf("unknown decode type %q", decode))
|
|
}
|
|
|
|
// decodeAt extracts a value 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 "uint32nan":
|
|
// Like uint32be but treats 0xFFFFFFFF (not-connected sentinel) as 0.
|
|
// Used for PV string power registers where disconnected strings report NaN.
|
|
if len(payload) < offset+4 {
|
|
return 0, fmt.Errorf("payload too short for uint32nan at offset %d (len=%d)", offset, len(payload))
|
|
}
|
|
if v := binary.BigEndian.Uint32(payload[offset:]); v != 0xFFFFFFFF {
|
|
return float64(v), nil
|
|
}
|
|
return 0, 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)
|
|
}
|