Implement DeviceInfo and RuntimeData commands

This commit is contained in:
Rob Watson 2022-07-11 20:01:28 +02:00
parent fcd937c52a
commit e467102cfe
6 changed files with 403 additions and 38 deletions

View File

@ -41,19 +41,19 @@ func aa55Checksum(payload []byte) []byte {
func (cmd AA55Command) String() string { return string(cmd.payload) } func (cmd AA55Command) String() string { return string(cmd.payload) }
func (cmd AA55Command) validateResponse(p []byte) error { func (cmd AA55Command) validateResponse(p []byte) ([]byte, error) {
if len(p) < 8 { if len(p) < 8 {
return fmt.Errorf("response truncated") return nil, fmt.Errorf("response truncated")
} }
expectedLen := int(p[aa55ResponseLengthIndex] + aa55ResponseLengthOffset) expectedLen := int(p[aa55ResponseLengthIndex] + aa55ResponseLengthOffset)
if len(p) != expectedLen { if len(p) != expectedLen {
return fmt.Errorf("unexpected response length %d (expected %d)", len(p), expectedLen) return nil, fmt.Errorf("unexpected response length %d (expected %d)", len(p), expectedLen)
} }
responseType := hex.EncodeToString(p[4:6]) responseType := hex.EncodeToString(p[4:6])
if responseType != cmd.responseType { if responseType != cmd.responseType {
return fmt.Errorf("unexpected response type `%s` (expected `%s`)", responseType, cmd.responseType) return nil, fmt.Errorf("unexpected response type `%s` (expected `%s`)", responseType, cmd.responseType)
} }
var sum uint16 var sum uint16
@ -62,8 +62,9 @@ func (cmd AA55Command) validateResponse(p []byte) error {
} }
expSum := binary.BigEndian.Uint16(p[len(p)-2:]) expSum := binary.BigEndian.Uint16(p[len(p)-2:])
if sum != expSum { if sum != expSum {
return fmt.Errorf("invalid response checksum %d (expected %d)", sum, expSum) return nil, fmt.Errorf("invalid response checksum %d (expected %d)", sum, expSum)
} }
return nil // FIXME: use correct offsets
return p[5 : len(p)-2], nil
} }

View File

@ -4,33 +4,29 @@ import (
"bufio" "bufio"
"fmt" "fmt"
"io" "io"
"log"
) )
type command interface { type command interface {
String() string String() string
validateResponse([]byte) error validateResponse([]byte) ([]byte, error)
} }
// Send writes the command to the provided Writer, and reads and validates the
// response.
//
// TODO: accept a context.Context and enforce deadline/timeout.
func Send(cmd command, conn io.ReadWriter) ([]byte, error) { func Send(cmd command, conn io.ReadWriter) ([]byte, error) {
_, err := fmt.Fprint(conn, cmd.String()) _, err := fmt.Fprint(conn, cmd.String())
if err != nil { if err != nil {
return nil, fmt.Errorf("error writing to socket: %s", err) return nil, fmt.Errorf("error writing to socket: %s", err)
} }
log.Printf("sent data to socket: %X", cmd)
p := make([]byte, 4_096) p := make([]byte, 4_096)
r := bufio.NewReader(conn) r := bufio.NewReader(conn)
n, err := r.Read(p) n, err := r.Read(p)
if err != nil { if err != nil {
return nil, fmt.Errorf("error reading from socket: %s", err) return nil, fmt.Errorf("error reading from socket: %s", err)
} }
p = p[:n]
if err := cmd.validateResponse(p); err != nil { return cmd.validateResponse(p[:n])
return nil, fmt.Errorf("error validating response: %s", err)
}
return p, nil
} }

View File

@ -58,6 +58,6 @@ func modbusChecksum(b []byte) uint16 {
func (cmd ModbusCommand) String() string { return string(cmd.payload) } func (cmd ModbusCommand) String() string { return string(cmd.payload) }
func (cmd ModbusCommand) validateResponse(p []byte) error { func (cmd ModbusCommand) validateResponse(p []byte) ([]byte, error) {
return nil return p[5 : len(p)-2], nil
} }

256
inverter/et.go Normal file
View File

@ -0,0 +1,256 @@
package inverter
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"io"
"math"
"strings"
"git.netflux.io/rob/goodwe-go/command"
)
type ET struct {
SerialNumber string
ModelName string
}
// Unexported struct used for parsing binary data only.
type etDeviceInfo struct {
ModbusVersion uint16
RatedPower uint16
ACOutputType uint16
SerialNumber [16]byte
ModelName [10]byte
DSP1SWVersion uint16
DSP2SWVersion uint16
DSPSVNVersion uint16
ArmSWVersion uint16
ArmSVNVersion uint16
SoftwareVersion [12]byte
ArmVersion [12]byte
}
func (info *etDeviceInfo) toDeviceInfo() *DeviceInfo {
serialNumber := string(info.SerialNumber[:])
return &DeviceInfo{
ModbusVersion: int(info.ModbusVersion),
RatedPower: int(info.RatedPower),
ACOutputType: int(info.ACOutputType),
SerialNumber: serialNumber,
ModelName: strings.TrimSpace(string(info.ModelName[:])),
DSP1SWVersion: int(info.DSP1SWVersion),
DSP2SWVersion: int(info.DSP2SWVersion),
DSPSVNVersion: int(info.DSPSVNVersion),
ArmSWVersion: int(info.ArmSWVersion),
ArmSVNVersion: int(info.ArmSVNVersion),
SoftwareVersion: string(info.SoftwareVersion[:]),
ArmVersion: string(info.ArmVersion[:]),
SinglePhase: strings.Contains(serialNumber, "EHU"),
}
}
// Unexported struct used for parsing binary data only.
type etRuntimeData struct {
_ [6]byte
PV1Voltage int16
PV1Current int16
PV1Power int32
PV2Voltage int16
PV2Current int16
PV2Power int32
_ [18]byte
PV2Mode byte
PV1Mode byte
OnGridL1Voltage int16
OnGridL1Current int16
OnGridL1Frequency int16
OnGridL1Power int32
OnGridL2Voltage int16
OnGridL2Current int16
OnGridL2Frequency int16
OnGridL2Power int32
OnGridL3Voltage int16
OnGridL3Current int16
OnGridL3Frequency int16
OnGridL3Power int32
GridMode int16
TotalInverterPower int32
ActivePower int32
ReactivePower int32
ApparentPower int32
BackupL1Voltage int16
BackupL1Current int16
BackupL1Frequency int16
LoadModeL1 int16
BackupL1Power int32
BackupL2Voltage int16
BackupL2Current int16
BackupL2Frequency int16
LoadModeL2 int16
BackupL2Power int32
BackupL3Voltage int16
BackupL3Current int16
BackupL3Frequency int16
LoadModeL3 int16
BackupL3Power int32
LoadL1 int32
LoadL2 int32
LoadL3 int32
BackupLoad int32
Load int32
UPSLoad int16
TemperatureAir int16
TemperatureModule int16
Temperature int16
FunctionBit int16
BusVoltage int16
NBusVoltage int16
BatteryVoltage int16
BatteryCurrent int16
_ [2]byte
BatteryMode int32
WarningCode int16
SafetyCountryCode int16
WorkMode int32
OperationCode int16
ErrorCodes int16
PVGenerationTotal int32
PVGenerationToday int32
EnergyExportTotal int32
EnergyExportTotalHours int32
EnergyExportToday int16
EnergyImportTotal int32
EnergyImportToday int16
EnergyLoadTotal int32
EnergyLoadDay int16
BatteryChargeTotal int32
BatteryChargeToday int16
BatteryDischargeTotal int32
BatteryDischargeToday int16
_ [16]byte
DiagStatusCode int32
}
func (data *etRuntimeData) toRuntimeData(singlePhase bool) *ETRuntimeData {
filterSinglePhase := func(i int) int {
if singlePhase {
return 0
}
return i
}
return &ETRuntimeData{
PV1Voltage: int(data.PV1Voltage),
PV1Current: int(data.PV1Current),
PV1Power: int(data.PV1Power),
PV2Voltage: int(data.PV2Voltage),
PV2Current: int(data.PV2Current),
PV2Power: int(data.PV2Power),
PVPower: int(data.PV1Power) + int(data.PV2Power),
PV2Mode: data.PV2Mode,
PV1Mode: data.PV1Mode,
OnGridL1Voltage: int(data.OnGridL1Voltage),
OnGridL1Current: int(data.OnGridL1Current),
OnGridL1Frequency: int(data.OnGridL1Frequency),
OnGridL1Power: int(data.OnGridL1Power),
OnGridL2Voltage: filterSinglePhase(int(data.OnGridL2Voltage)),
OnGridL2Current: filterSinglePhase(int(data.OnGridL2Current)),
OnGridL2Frequency: filterSinglePhase(int(data.OnGridL2Frequency)),
OnGridL2Power: filterSinglePhase(int(data.OnGridL2Power)),
OnGridL3Voltage: filterSinglePhase(int(data.OnGridL3Voltage)),
OnGridL3Current: filterSinglePhase(int(data.OnGridL3Current)),
OnGridL3Frequency: filterSinglePhase(int(data.OnGridL3Frequency)),
OnGridL3Power: filterSinglePhase(int(data.OnGridL3Power)),
GridMode: int(data.GridMode),
TotalInverterPower: int(data.TotalInverterPower),
ActivePower: int(data.ActivePower),
ReactivePower: int(data.ReactivePower),
ApparentPower: int(data.ApparentPower),
BackupL1Voltage: int(data.BackupL1Voltage),
BackupL1Current: int(data.BackupL1Current),
BackupL1Frequency: int(data.BackupL1Frequency),
LoadModeL1: int(data.LoadModeL1),
BackupL1Power: int(data.BackupL1Power),
BackupL2Voltage: filterSinglePhase(int(data.BackupL2Voltage)),
BackupL2Current: filterSinglePhase(int(data.BackupL2Current)),
BackupL2Frequency: filterSinglePhase(int(data.BackupL2Frequency)),
LoadModeL2: filterSinglePhase(int(data.LoadModeL2)),
BackupL2Power: filterSinglePhase(int(data.BackupL2Power)),
BackupL3Voltage: filterSinglePhase(int(data.BackupL3Voltage)),
BackupL3Current: filterSinglePhase(int(data.BackupL3Current)),
BackupL3Frequency: filterSinglePhase(int(data.BackupL3Frequency)),
LoadModeL3: filterSinglePhase(int(data.LoadModeL3)),
BackupL3Power: filterSinglePhase(int(data.BackupL3Power)),
LoadL1: int(data.LoadL1),
LoadL2: filterSinglePhase(int(data.LoadL2)),
LoadL3: filterSinglePhase(int(data.LoadL3)),
BackupLoad: int(data.BackupLoad),
Load: int(data.Load),
UPSLoad: int(data.UPSLoad),
TemperatureAir: int(data.TemperatureAir),
TemperatureModule: int(data.TemperatureModule),
Temperature: int(data.Temperature),
FunctionBit: int(data.FunctionBit),
BusVoltage: int(data.BusVoltage),
NBusVoltage: int(data.NBusVoltage),
BatteryVoltage: int(data.BatteryVoltage),
BatteryCurrent: int(data.BatteryCurrent),
BatteryMode: int(data.BatteryMode),
WarningCode: int(data.WarningCode),
SafetyCountryCode: int(data.SafetyCountryCode),
WorkMode: int(data.WorkMode),
OperationCode: int(data.OperationCode),
ErrorCodes: int(data.ErrorCodes),
PVGenerationTotal: int(data.PVGenerationTotal),
PVGenerationToday: int(data.PVGenerationToday),
EnergyExportTotal: int(data.EnergyExportTotal),
EnergyExportTotalHours: int(data.EnergyExportTotalHours),
EnergyExportToday: int(data.EnergyExportToday),
EnergyImportTotal: int(data.EnergyImportTotal),
EnergyImportToday: int(data.EnergyImportToday),
EnergyLoadTotal: int(data.EnergyLoadTotal),
EnergyLoadDay: int(data.EnergyLoadDay),
BatteryChargeTotal: int(data.BatteryChargeTotal),
BatteryChargeToday: int(data.BatteryChargeToday),
BatteryDischargeTotal: int(data.BatteryDischargeTotal),
BatteryDischargeToday: int(data.BatteryDischargeToday),
DiagStatusCode: int(data.DiagStatusCode),
HouseConsumption: int32(float64(data.PV1Power) + float64(data.PV2Power) + math.Round(float64(data.BatteryVoltage)*float64(data.BatteryCurrent)) - float64(data.ActivePower)),
}
}
func (inv ET) DeviceInfo(ctx context.Context, conn io.ReadWriter) (*DeviceInfo, error) {
resp, err := command.Send(command.NewModbus(command.ModbusCommandTypeRead, 0x88b8, 0x0021), conn)
if err != nil {
return nil, fmt.Errorf("error sending command: %s", err)
}
var deviceInfo etDeviceInfo
if err := binary.Read(bytes.NewReader(resp), binary.BigEndian, &deviceInfo); err != nil {
return nil, fmt.Errorf("error parsing response: %s", err)
}
return deviceInfo.toDeviceInfo(), nil
}
func (inv ET) RuntimeData(ctx context.Context, conn io.ReadWriter) (*ETRuntimeData, error) {
deviceInfo, err := inv.DeviceInfo(ctx, conn)
if err != nil {
return nil, fmt.Errorf("error fetching device info: %s", err)
}
resp, err := command.Send(command.NewModbus(command.ModbusCommandTypeRead, 0x891c, 0x007d), conn)
if err != nil {
return nil, fmt.Errorf("error sending command: %s", err)
}
var runtimeData etRuntimeData
if err := binary.Read(bytes.NewReader(resp), binary.BigEndian, &runtimeData); err != nil {
return nil, fmt.Errorf("error parsing response: %s", err)
}
return runtimeData.toRuntimeData(deviceInfo.SinglePhase), nil
}

97
inverter/types.go Normal file
View File

@ -0,0 +1,97 @@
package inverter
// DeviceInfo holds the static information about an inverter.
type DeviceInfo struct {
ModbusVersion int `json:"modbus_version"`
RatedPower int `json:"rated_power"`
ACOutputType int `json:"ac_output_type"`
SerialNumber string `json:"serial_number"`
ModelName string `json:"model_name"`
DSP1SWVersion int `json:"dsp1_sw_version"`
DSP2SWVersion int `json:"dsp2_sw_version"`
DSPSVNVersion int `json:"dsp_svn_version"`
ArmSWVersion int `json:"arm_sw_version"`
ArmSVNVersion int `json:"arm_svn_version"`
SoftwareVersion string `json:"software_version"`
ArmVersion string `json:"arm_version"`
SinglePhase bool
}
type ETRuntimeData struct {
PV1Voltage int `json:"pv1_voltage"`
PV1Current int `json:"pv1_current"`
PV1Power int `json:"pv1_power"`
PV2Voltage int `json:"pv2_voltage"`
PV2Current int `json:"pv2_current"`
PV2Power int `json:"pv2_power"`
PVPower int `json:"pv_power"`
PV2Mode byte `json:"pv2_mode"`
PV1Mode byte `json:"pv1_mode"`
OnGridL1Voltage int `json:"on_grid_l1_voltage"`
OnGridL1Current int `json:"on_grid_l1_current"`
OnGridL1Frequency int `json:"on_grid_l1_frequency"`
OnGridL1Power int `json:"on_grid_l1_power"`
OnGridL2Voltage int `json:"on_grid_l2_voltage"`
OnGridL2Current int `json:"on_grid_l2_current"`
OnGridL2Frequency int `json:"on_grid_l2_frequency"`
OnGridL2Power int `json:"on_grid_l2_power"`
OnGridL3Voltage int `json:"on_grid_l3_voltage"`
OnGridL3Current int `json:"on_grid_l3_current"`
OnGridL3Frequency int `json:"on_grid_l3_frequency"`
OnGridL3Power int `json:"on_grid_l3_power"`
GridMode int `json:"grid_mode"`
TotalInverterPower int `json:"total_inverter_power"`
ActivePower int `json:"active_power"`
ReactivePower int `json:"reactive_power"`
ApparentPower int `json:"apparent_power"`
BackupL1Voltage int `json:"backup_l1_voltage"`
BackupL1Current int `json:"backup_l1_current"`
BackupL1Frequency int `json:"backup_l1_frequency"`
LoadModeL1 int `json:"load_mode_l1"`
BackupL1Power int `json:"backup_l1_power"`
BackupL2Voltage int `json:"backup_l2_voltage"`
BackupL2Current int `json:"backup_l2_current"`
BackupL2Frequency int `json:"backup_l2_frequency"`
LoadModeL2 int `json:"load_mode_l2"`
BackupL2Power int `json:"backup_l2_power"`
BackupL3Voltage int `json:"backup_l3_voltage"`
BackupL3Current int `json:"backup_l3_current"`
BackupL3Frequency int `json:"backup_l3_frequency"`
LoadModeL3 int `json:"load_mode_l3"`
BackupL3Power int `json:"backup_l3_power"`
LoadL1 int `json:"load_l1"`
LoadL2 int `json:"load_l2"`
LoadL3 int `json:"load_l3"`
BackupLoad int `json:"backup_load"`
Load int `json:"load"`
UPSLoad int `json:"ups_load"`
TemperatureAir int `json:"temperature_air"`
TemperatureModule int `json:"temperature_module"`
Temperature int `json:"temperature"`
FunctionBit int `json:"-"`
BusVoltage int `json:"bus_voltage"`
NBusVoltage int `json:"nbus_voltage"`
BatteryVoltage int `json:"battery_voltage"`
BatteryCurrent int `json:"battery_current"`
BatteryMode int `json:"battery_mode"`
WarningCode int `json:"warning_code"`
SafetyCountryCode int `json:"safety_country_code"`
WorkMode int `json:"work_mode"`
OperationCode int `json:"operation_code"`
ErrorCodes int `json:"-"`
PVGenerationTotal int `json:"pv_generation_total"`
PVGenerationToday int `json:"pv_generation_today"`
EnergyExportTotal int `json:"energy_export_total"`
EnergyExportTotalHours int `json:"energy_export_total_hours"`
EnergyExportToday int `json:"energy_export_today"`
EnergyImportTotal int `json:"energy_import_total"`
EnergyImportToday int `json:"energy_import_today"`
EnergyLoadTotal int `json:"energy_load_total"`
EnergyLoadDay int `json:"energy_load_day"`
BatteryChargeTotal int `json:"battery_charge_total"`
BatteryChargeToday int `json:"battery_charge_today"`
BatteryDischargeTotal int `json:"battery_discharge_total"`
BatteryDischargeToday int `json:"battery_discharge_today"`
DiagStatusCode int `json:"-"`
HouseConsumption int32 `json:"house_consumption"`
}

51
main.go
View File

@ -1,16 +1,19 @@
package main package main
import ( import (
"context"
"encoding/json"
"flag" "flag"
"fmt"
"log" "log"
"net" "net"
"os" "os"
"strings" "time"
"git.netflux.io/rob/goodwe-go/command" "git.netflux.io/rob/goodwe-go/inverter"
) )
const commandTimeout = time.Second * 5
func main() { func main() {
var ipAddr string var ipAddr string
flag.StringVar(&ipAddr, "ipaddr", "", "IP address/port") flag.StringVar(&ipAddr, "ipaddr", "", "IP address/port")
@ -21,7 +24,13 @@ func main() {
os.Exit(1) os.Exit(1)
} }
fmt.Println("ipAddr", ipAddr) arg := flag.Arg(0)
if arg != "discover" && arg != "runtime" && arg != "info" {
log.Fatal("missing command: [discover|runtime|info]")
}
ctx, cancel := context.WithTimeout(context.Background(), commandTimeout)
defer cancel()
conn, err := net.Dial("udp", ipAddr) conn, err := net.Dial("udp", ipAddr)
if err != nil { if err != nil {
@ -29,26 +38,32 @@ func main() {
} }
defer conn.Close() defer conn.Close()
infoCmd, err := command.NewAA55("010200", "0182") var (
inverter inverter.ET
output any
)
switch arg {
case "discover":
log.Fatal("not yet implemented")
case "info":
output, err = inverter.DeviceInfo(ctx, conn)
if err != nil { if err != nil {
log.Fatalf("error building command: %s", err) log.Fatalf("error getting device info: %s", err)
} }
case "runtime":
resp, err := command.Send(infoCmd, conn) output, err = inverter.RuntimeData(ctx, conn)
if err != nil { if err != nil {
log.Fatalf("error sending command: %s", err) log.Fatalf("error getting runtime data: %s", err)
}
} }
modelName := strings.TrimSpace(string(resp[12:22])) json, err := json.Marshal(output)
serialNum := string(resp[38:54])
log.Printf("modelName = %q, serialNum = %q\n", modelName, serialNum)
dataCmd := command.NewModbus(command.ModbusCommandTypeRead, 0x891c, 0x007d)
resp, err = command.Send(dataCmd, conn)
if err != nil { if err != nil {
log.Fatalf("error sending command: %s", err) log.Fatalf("error encoding JSON: %s", err)
} }
log.Printf("rcvd modbus resp = %X", resp) if _, err = os.Stdout.Write(json); err != nil {
log.Fatalf("error writing to stdout: %s", err)
}
} }