From e467102cfe2dfd22794af93c02a03c8a11ba6cf6 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Mon, 11 Jul 2022 20:01:28 +0200 Subject: [PATCH] Implement DeviceInfo and RuntimeData commands --- command/aa55.go | 13 +-- command/command.go | 16 ++- command/modbus.go | 4 +- inverter/et.go | 256 +++++++++++++++++++++++++++++++++++++++++++++ inverter/types.go | 97 +++++++++++++++++ main.go | 55 ++++++---- 6 files changed, 403 insertions(+), 38 deletions(-) create mode 100644 inverter/et.go create mode 100644 inverter/types.go diff --git a/command/aa55.go b/command/aa55.go index d40212d..ea6b7a3 100644 --- a/command/aa55.go +++ b/command/aa55.go @@ -41,19 +41,19 @@ func aa55Checksum(payload []byte) []byte { 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 { - return fmt.Errorf("response truncated") + return nil, fmt.Errorf("response truncated") } expectedLen := int(p[aa55ResponseLengthIndex] + aa55ResponseLengthOffset) 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]) 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 @@ -62,8 +62,9 @@ func (cmd AA55Command) validateResponse(p []byte) error { } expSum := binary.BigEndian.Uint16(p[len(p)-2:]) 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 } diff --git a/command/command.go b/command/command.go index ecd4d41..b2c6adc 100644 --- a/command/command.go +++ b/command/command.go @@ -4,33 +4,29 @@ import ( "bufio" "fmt" "io" - "log" ) type command interface { 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) { _, err := fmt.Fprint(conn, cmd.String()) if err != nil { return nil, fmt.Errorf("error writing to socket: %s", err) } - log.Printf("sent data to socket: %X", cmd) - p := make([]byte, 4_096) r := bufio.NewReader(conn) n, err := r.Read(p) if err != nil { return nil, fmt.Errorf("error reading from socket: %s", err) } - p = p[:n] - if err := cmd.validateResponse(p); err != nil { - return nil, fmt.Errorf("error validating response: %s", err) - } - - return p, nil + return cmd.validateResponse(p[:n]) } diff --git a/command/modbus.go b/command/modbus.go index 7c3babf..7ca8c54 100644 --- a/command/modbus.go +++ b/command/modbus.go @@ -58,6 +58,6 @@ func modbusChecksum(b []byte) uint16 { func (cmd ModbusCommand) String() string { return string(cmd.payload) } -func (cmd ModbusCommand) validateResponse(p []byte) error { - return nil +func (cmd ModbusCommand) validateResponse(p []byte) ([]byte, error) { + return p[5 : len(p)-2], nil } diff --git a/inverter/et.go b/inverter/et.go new file mode 100644 index 0000000..fde92d8 --- /dev/null +++ b/inverter/et.go @@ -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 +} diff --git a/inverter/types.go b/inverter/types.go new file mode 100644 index 0000000..1d82eb4 --- /dev/null +++ b/inverter/types.go @@ -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"` +} diff --git a/main.go b/main.go index 17e8720..a2215ad 100644 --- a/main.go +++ b/main.go @@ -1,16 +1,19 @@ package main import ( + "context" + "encoding/json" "flag" - "fmt" "log" "net" "os" - "strings" + "time" - "git.netflux.io/rob/goodwe-go/command" + "git.netflux.io/rob/goodwe-go/inverter" ) +const commandTimeout = time.Second * 5 + func main() { var ipAddr string flag.StringVar(&ipAddr, "ipaddr", "", "IP address/port") @@ -21,7 +24,13 @@ func main() { 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) if err != nil { @@ -29,26 +38,32 @@ func main() { } defer conn.Close() - infoCmd, err := command.NewAA55("010200", "0182") - if err != nil { - log.Fatalf("error building command: %s", err) + 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 { + log.Fatalf("error getting device info: %s", err) + } + case "runtime": + output, err = inverter.RuntimeData(ctx, conn) + if err != nil { + log.Fatalf("error getting runtime data: %s", err) + } } - resp, err := command.Send(infoCmd, conn) + json, err := json.Marshal(output) if err != nil { - log.Fatalf("error sending command: %s", err) + log.Fatalf("error encoding JSON: %s", err) } - modelName := strings.TrimSpace(string(resp[12:22])) - 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 { - log.Fatalf("error sending command: %s", err) + if _, err = os.Stdout.Write(json); err != nil { + log.Fatalf("error writing to stdout: %s", err) } - - log.Printf("rcvd modbus resp = %X", resp) }