diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..31f50e4 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,14 @@ +--- +kind: pipeline +type: kubernetes +name: default + +steps: +- name: backend + image: golang:1.18 + commands: + - go install honnef.co/go/tools/cmd/staticcheck@latest + - go build ./... + - go vet ./... + - staticcheck ./... + - go test -race -cover ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..345de5a --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# goodwe-go + +A small project to read data from a Goodwe solar inverter using Go. Based on https://pypi.org/project/goodwe/. diff --git a/go.mod b/go.mod index b948570..f571435 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,14 @@ module git.netflux.io/rob/goodwe-go go 1.18 + +require ( + github.com/stretchr/testify v1.8.0 + golang.org/x/exp v0.0.0-20220706164943-b4a6d9510983 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..c490715 --- /dev/null +++ b/go.sum @@ -0,0 +1,17 @@ +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +golang.org/x/exp v0.0.0-20220706164943-b4a6d9510983 h1:sUweFwmLOje8KNfXAVqGGAsmgJ/F8jJ6wBLJDt4BTKY= +golang.org/x/exp v0.0.0-20220706164943-b4a6d9510983/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/inverter/et.go b/inverter/et.go index fde92d8..fd4b931 100644 --- a/inverter/et.go +++ b/inverter/et.go @@ -8,15 +8,23 @@ import ( "io" "math" "strings" + "time" "git.netflux.io/rob/goodwe-go/command" ) +// The timezone used to parse timestamps. +const locationName = "Europe/Madrid" + type ET struct { SerialNumber string ModelName string } +func (inv ET) isSinglePhase() bool { + return strings.Contains(inv.SerialNumber, "EHU") +} + // Unexported struct used for parsing binary data only. type etDeviceInfo struct { ModbusVersion uint16 @@ -53,8 +61,20 @@ func (info *etDeviceInfo) toDeviceInfo() *DeviceInfo { } // Unexported struct used for parsing binary data only. +// +// Raw types are based partly on the the PyPI library, and partly on the +// third-party online documentation: +// +// https://github.com/marcelblijleven/goodwe/blob/327c7803e8415baeb4b6252431db91e1fc6f2fb3 +// https://github.com/tkubec/GoodWe/wiki/ET-Series-Registers +// +// It's especially unclear whether fields should be parsed signed or unsigned. +// Handling differs in the above two sources. In most cases, overflowing a +// uint16 max value is unlikely but it may have an impact on handling negative +// values. To allow for the latter case, signed types are mostly preferred +// below. type etRuntimeData struct { - _ [6]byte + Timestamp [6]byte PV1Voltage int16 PV1Current int16 PV1Power int32 @@ -117,8 +137,8 @@ type etRuntimeData struct { WorkMode int32 OperationCode int16 ErrorCodes int16 - PVGenerationTotal int32 - PVGenerationToday int32 + EnergyGenerationTotal int32 + EnergyGenerationToday int32 EnergyExportTotal int32 EnergyExportTotalHours int32 EnergyExportToday int16 @@ -134,94 +154,118 @@ type etRuntimeData struct { DiagStatusCode int32 } +func filterSinglePhase[T numeric](v T, singlePhase bool) T { + if singlePhase { + return 0 + } + return v +} + +// toRuntimeData panics if the `locationName` constant cannot be resolved to a +// time.Location. func (data *etRuntimeData) toRuntimeData(singlePhase bool) *ETRuntimeData { - filterSinglePhase := func(i int) int { - if singlePhase { - return 0 - } - return i + yr := data.Timestamp[0] + mon := data.Timestamp[1] + day := data.Timestamp[2] + hr := data.Timestamp[3] + min := data.Timestamp[4] + sec := data.Timestamp[5] + loc, err := time.LoadLocation(locationName) + if err != nil { + panic(fmt.Sprintf("unknown location: %s", locationName)) } 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), + Timestamp: time.Date(2000+int(yr), time.Month(mon), int(day), int(hr), int(min), int(sec), 0, loc), + PV1Voltage: newVoltage(data.PV1Voltage), + PV1Current: newCurrent(data.PV1Current), + PV1Power: newPower(data.PV1Power), + PV2Voltage: newVoltage(data.PV2Voltage), + PV2Current: newCurrent(data.PV2Current), + PV2Power: newPower(data.PV2Power), + PVPower: newPower(data.PV1Power + 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)), + OnGridL1Voltage: newVoltage(data.OnGridL1Voltage), + OnGridL1Current: newCurrent(data.OnGridL1Current), + OnGridL1Frequency: newFrequency(data.OnGridL1Frequency), + OnGridL1Power: newPower(data.OnGridL1Power), + OnGridL2Voltage: newVoltage(filterSinglePhase(data.OnGridL2Voltage, singlePhase)), + OnGridL2Current: newCurrent(filterSinglePhase(data.OnGridL2Current, singlePhase)), + OnGridL2Frequency: newFrequency(filterSinglePhase(data.OnGridL2Frequency, singlePhase)), + OnGridL2Power: newPower(filterSinglePhase(data.OnGridL2Power, singlePhase)), + OnGridL3Voltage: newVoltage(filterSinglePhase(data.OnGridL3Voltage, singlePhase)), + OnGridL3Current: newCurrent(filterSinglePhase(data.OnGridL3Current, singlePhase)), + OnGridL3Frequency: newFrequency(filterSinglePhase(data.OnGridL3Frequency, singlePhase)), + OnGridL3Power: newPower(filterSinglePhase(data.OnGridL3Power, singlePhase)), GridMode: int(data.GridMode), - TotalInverterPower: int(data.TotalInverterPower), - ActivePower: int(data.ActivePower), + TotalInverterPower: newPower(data.TotalInverterPower), + ActivePower: newPower(data.ActivePower), ReactivePower: int(data.ReactivePower), ApparentPower: int(data.ApparentPower), - BackupL1Voltage: int(data.BackupL1Voltage), - BackupL1Current: int(data.BackupL1Current), - BackupL1Frequency: int(data.BackupL1Frequency), + BackupL1Voltage: newVoltage(data.BackupL1Voltage), + BackupL1Current: newCurrent(data.BackupL1Current), + BackupL1Frequency: newFrequency(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), + BackupL1Power: newPower(data.BackupL1Power), + BackupL2Voltage: newVoltage(filterSinglePhase(data.BackupL2Voltage, singlePhase)), + BackupL2Current: newCurrent(filterSinglePhase(data.BackupL2Current, singlePhase)), + BackupL2Frequency: newFrequency(filterSinglePhase(data.BackupL2Frequency, singlePhase)), + LoadModeL2: int(filterSinglePhase(data.LoadModeL2, singlePhase)), + BackupL2Power: newPower(filterSinglePhase(data.BackupL2Power, singlePhase)), + BackupL3Voltage: newVoltage(filterSinglePhase(data.BackupL3Voltage, singlePhase)), + BackupL3Current: newCurrent(filterSinglePhase(data.BackupL3Current, singlePhase)), + BackupL3Frequency: newFrequency(filterSinglePhase(data.BackupL3Frequency, singlePhase)), + LoadModeL3: int(filterSinglePhase(data.LoadModeL3, singlePhase)), + BackupL3Power: newPower(filterSinglePhase(data.BackupL3Power, singlePhase)), + LoadL1: newPower(data.LoadL1), + LoadL2: newPower(filterSinglePhase(data.LoadL2, singlePhase)), + LoadL3: newPower(filterSinglePhase(data.LoadL3, singlePhase)), + BackupLoad: newPower(data.BackupLoad), + Load: newPower(data.Load), UPSLoad: int(data.UPSLoad), - TemperatureAir: int(data.TemperatureAir), - TemperatureModule: int(data.TemperatureModule), - Temperature: int(data.Temperature), + TemperatureAir: newTemp(data.TemperatureAir), + TemperatureModule: newTemp(data.TemperatureModule), + Temperature: newTemp(data.Temperature), FunctionBit: int(data.FunctionBit), - BusVoltage: int(data.BusVoltage), - NBusVoltage: int(data.NBusVoltage), - BatteryVoltage: int(data.BatteryVoltage), - BatteryCurrent: int(data.BatteryCurrent), + BusVoltage: newVoltage(data.BusVoltage), + NBusVoltage: newVoltage(data.NBusVoltage), + BatteryVoltage: newVoltage(data.BatteryVoltage), + BatteryCurrent: newCurrent(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), + EnergyGenerationTotal: newEnergy(data.EnergyGenerationTotal), + EnergyGenerationToday: newEnergy(data.EnergyGenerationToday), + EnergyExportTotal: newEnergy(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), + EnergyExportToday: newEnergy(data.EnergyExportToday), + EnergyImportTotal: newEnergy(data.EnergyImportTotal), + EnergyImportToday: newEnergy(data.EnergyImportToday), + EnergyLoadTotal: newEnergy(data.EnergyLoadTotal), + EnergyLoadDay: newEnergy(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)), + HouseConsumption: Power(int32(float64(data.PV1Power) + float64(data.PV2Power) + math.Round(float64(data.BatteryVoltage)*float64(data.BatteryCurrent)) - float64(data.ActivePower))), } } +func (inv ET) DecodeRuntimeData(p []byte) (*ETRuntimeData, error) { + var runtimeData etRuntimeData + if err := binary.Read(bytes.NewReader(p), binary.BigEndian, &runtimeData); err != nil { + return nil, fmt.Errorf("error parsing response: %s", err) + } + + return runtimeData.toRuntimeData(inv.isSinglePhase()), nil +} + +// DEPRECATED 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 { @@ -236,6 +280,7 @@ func (inv ET) DeviceInfo(ctx context.Context, conn io.ReadWriter) (*DeviceInfo, return deviceInfo.toDeviceInfo(), nil } +// DEPRECATED func (inv ET) RuntimeData(ctx context.Context, conn io.ReadWriter) (*ETRuntimeData, error) { deviceInfo, err := inv.DeviceInfo(ctx, conn) if err != nil { diff --git a/inverter/et_test.go b/inverter/et_test.go new file mode 100644 index 0000000..4857352 --- /dev/null +++ b/inverter/et_test.go @@ -0,0 +1,141 @@ +package inverter_test + +import ( + "testing" + "time" + + "git.netflux.io/rob/goodwe-go/inverter" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeDeviceInfo(t *testing.T) { + inBytes := []byte{22, 7, 13, 10, 35, 1, 12, 92, 0, 32, 0, 0, 3, 244, 8, 224, 0, 83, 0, 0, 7, 89, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 2, 2, 9, 63, 0, 123, 19, 135, 0, 0, 11, 129, 255, 255, 255, 255, 255, 255, 127, 255, 255, 255, 255, 255, 255, 255, 255, 255, 127, 255, 255, 255, 0, 1, 0, 0, 11, 129, 0, 0, 3, 237, 127, 255, 255, 255, 255, 255, 255, 255, 9, 62, 0, 5, 19, 135, 0, 1, 0, 0, 0, 0, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 0, 0, 7, 148, 128, 0, 0, 0, 128, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 148, 0, 2, 2, 119, 127, 255, 1, 148, 1, 0, 14, 118, 255, 255, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 33, 0, 1, 255, 255, 0, 0, 0, 0, 0, 0, 30, 19, 0, 0, 0, 44, 0, 0, 30, 19, 0, 0, 1, 15, 0, 46, 0, 0, 0, 0, 0, 0, 0, 0, 11, 195, 0, 48, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 64, 0, 0, 0, 2, 8, 64, 71, 0, 3, 0, 0, 255, 255} + + t.Run("with multi-phase inverter", func(t *testing.T) { + inv := inverter.ET{SerialNumber: "foo"} + runtimeData, err := inv.DecodeRuntimeData(inBytes) + require.NoError(t, err) + + wantLoc, _ := time.LoadLocation("Europe/Madrid") + assert.Equal(t, time.Date(2022, 7, 13, 10, 35, 1, 0, wantLoc), runtimeData.Timestamp) + + assert.Equal(t, inverter.Voltage(316.4), runtimeData.PV1Voltage) + assert.Equal(t, inverter.Current(3.2), runtimeData.PV1Current) + assert.Equal(t, inverter.Power(1012), runtimeData.PV1Power) + assert.Equal(t, inverter.Voltage(227.2), runtimeData.PV2Voltage) + assert.Equal(t, inverter.Current(8.3), runtimeData.PV2Current) + assert.Equal(t, inverter.Power(1881), runtimeData.PV2Power) + assert.Equal(t, inverter.Power(2893), runtimeData.PVPower) + assert.Equal(t, inverter.Voltage(236.7), runtimeData.OnGridL1Voltage) + assert.Equal(t, inverter.Current(12.3), runtimeData.OnGridL1Current) + assert.Equal(t, inverter.Frequency(49.99), runtimeData.OnGridL1Frequency) + assert.Equal(t, inverter.Power(2945), runtimeData.OnGridL1Power) + assert.Equal(t, inverter.Voltage(-0.1), runtimeData.OnGridL2Voltage) + assert.Equal(t, inverter.Current(-0.1), runtimeData.OnGridL2Current) + assert.Equal(t, inverter.Frequency(-0.01), runtimeData.OnGridL2Frequency) + assert.Equal(t, inverter.Power(2.147483647e+09), runtimeData.OnGridL2Power) + assert.Equal(t, inverter.Voltage(-0.1), runtimeData.OnGridL3Voltage) + assert.Equal(t, inverter.Current(-0.1), runtimeData.OnGridL3Current) + assert.Equal(t, inverter.Frequency(-0.01), runtimeData.OnGridL3Frequency) + assert.Equal(t, inverter.Power(2.147483647e+09), runtimeData.OnGridL3Power) + assert.Equal(t, 1, runtimeData.GridMode) + assert.Equal(t, inverter.Power(2945), runtimeData.TotalInverterPower) + assert.Equal(t, inverter.Power(1005), runtimeData.ActivePower) + assert.Equal(t, 2147483647, runtimeData.ReactivePower) + assert.Equal(t, -1, runtimeData.ApparentPower) + assert.Equal(t, inverter.Voltage(236.6), runtimeData.BackupL1Voltage) + assert.Equal(t, inverter.Current(0.5), runtimeData.BackupL1Current) + assert.Equal(t, inverter.Frequency(49.99), runtimeData.BackupL1Frequency) + assert.Equal(t, 1, runtimeData.LoadModeL1) + assert.Equal(t, inverter.Power(0), runtimeData.BackupL1Power) + assert.Equal(t, inverter.Voltage(-0.1), runtimeData.BackupL2Voltage) + assert.Equal(t, inverter.Current(-0.1), runtimeData.BackupL2Current) + assert.Equal(t, inverter.Frequency(-0.01), runtimeData.BackupL2Frequency) + assert.Equal(t, -1, runtimeData.LoadModeL2) + assert.Equal(t, inverter.Power(-1), runtimeData.BackupL2Power) + assert.Equal(t, inverter.Voltage(-0.1), runtimeData.BackupL3Voltage) + assert.Equal(t, inverter.Current(-0.1), runtimeData.BackupL3Current) + assert.Equal(t, inverter.Frequency(-0.01), runtimeData.BackupL3Frequency) + assert.Equal(t, -1, runtimeData.LoadModeL3) + assert.Equal(t, inverter.Power(-1), runtimeData.BackupL3Power) + assert.Equal(t, inverter.Power(1940), runtimeData.LoadL1) + assert.Equal(t, inverter.Power(-2.147483648e+09), runtimeData.LoadL2) + assert.Equal(t, inverter.Power(-2.147483648e+09), runtimeData.LoadL3) + assert.Equal(t, inverter.Power(0), runtimeData.BackupLoad) + assert.Equal(t, inverter.Power(1940), runtimeData.Load) + assert.Equal(t, 2, runtimeData.UPSLoad) + assert.Equal(t, inverter.Temp(63.1), runtimeData.TemperatureAir) + assert.Equal(t, inverter.Temp(3276.7), runtimeData.TemperatureModule) + assert.Equal(t, inverter.Temp(40.4), runtimeData.Temperature) + assert.Equal(t, 256, runtimeData.FunctionBit) + assert.Equal(t, inverter.Voltage(370.2), runtimeData.BusVoltage) + assert.Equal(t, inverter.Voltage(-0.1), runtimeData.NBusVoltage) + assert.Equal(t, inverter.Voltage(0), runtimeData.BatteryVoltage) + assert.Equal(t, inverter.Current(0), runtimeData.BatteryCurrent) + assert.Equal(t, 0, runtimeData.BatteryMode) + assert.Equal(t, 0, runtimeData.WarningCode) + assert.Equal(t, 33, runtimeData.SafetyCountryCode) + assert.Equal(t, 131071, runtimeData.WorkMode) + assert.Equal(t, 0, runtimeData.OperationCode) + assert.Equal(t, 0, runtimeData.ErrorCodes) + assert.Equal(t, inverter.Energy(769.9), runtimeData.EnergyGenerationTotal) + assert.Equal(t, inverter.Energy(4.4), runtimeData.EnergyGenerationToday) + assert.Equal(t, inverter.Energy(769.9), runtimeData.EnergyExportTotal) + assert.Equal(t, 271, runtimeData.EnergyExportTotalHours) + assert.Equal(t, inverter.Energy(4.6), runtimeData.EnergyExportToday) + assert.Equal(t, inverter.Energy(0), runtimeData.EnergyImportTotal) + assert.Equal(t, inverter.Energy(0), runtimeData.EnergyImportToday) + assert.Equal(t, inverter.Energy(301.1), runtimeData.EnergyLoadTotal) + assert.Equal(t, inverter.Energy(4.8), runtimeData.EnergyLoadDay) + assert.Equal(t, 0, runtimeData.BatteryChargeTotal) + assert.Equal(t, 0, runtimeData.BatteryChargeToday) + assert.Equal(t, 0, runtimeData.BatteryDischargeTotal) + assert.Equal(t, 0, runtimeData.BatteryDischargeToday) + assert.Equal(t, 34095175, runtimeData.DiagStatusCode) + assert.Equal(t, inverter.Power(1888), runtimeData.HouseConsumption) + }) + + t.Run("with single-phase inverter", func(t *testing.T) { + inv := inverter.ET{SerialNumber: "EHUfoo"} + runtimeData, err := inv.DecodeRuntimeData(inBytes) + require.NoError(t, err) + + assert.Equal(t, inverter.Voltage(316.4), runtimeData.PV1Voltage) + assert.Equal(t, inverter.Current(3.2), runtimeData.PV1Current) + assert.Equal(t, inverter.Power(1012), runtimeData.PV1Power) + assert.Equal(t, inverter.Voltage(227.2), runtimeData.PV2Voltage) + assert.Equal(t, inverter.Current(8.3), runtimeData.PV2Current) + assert.Equal(t, inverter.Power(1881), runtimeData.PV2Power) + assert.Equal(t, inverter.Power(2893), runtimeData.PVPower) + assert.Equal(t, inverter.Voltage(236.7), runtimeData.OnGridL1Voltage) + assert.Equal(t, inverter.Current(12.3), runtimeData.OnGridL1Current) + assert.Equal(t, inverter.Frequency(49.99), runtimeData.OnGridL1Frequency) + assert.Equal(t, inverter.Power(2945), runtimeData.OnGridL1Power) + assert.Equal(t, inverter.Voltage(0), runtimeData.OnGridL2Voltage) + assert.Equal(t, inverter.Current(0), runtimeData.OnGridL2Current) + assert.Equal(t, inverter.Frequency(0), runtimeData.OnGridL2Frequency) + assert.Equal(t, inverter.Power(0), runtimeData.OnGridL2Power) + assert.Equal(t, inverter.Voltage(0), runtimeData.OnGridL3Voltage) + assert.Equal(t, inverter.Current(0), runtimeData.OnGridL3Current) + assert.Equal(t, inverter.Frequency(0), runtimeData.OnGridL3Frequency) + assert.Equal(t, inverter.Power(0), runtimeData.OnGridL3Power) + assert.Equal(t, inverter.Voltage(236.6), runtimeData.BackupL1Voltage) + assert.Equal(t, inverter.Current(0.5), runtimeData.BackupL1Current) + assert.Equal(t, inverter.Frequency(49.99), runtimeData.BackupL1Frequency) + assert.Equal(t, 1, runtimeData.LoadModeL1) + assert.Equal(t, inverter.Power(0), runtimeData.BackupL1Power) + assert.Equal(t, inverter.Voltage(0), runtimeData.BackupL2Voltage) + assert.Equal(t, inverter.Current(0), runtimeData.BackupL2Current) + assert.Equal(t, inverter.Frequency(0), runtimeData.BackupL2Frequency) + assert.Equal(t, 0, runtimeData.LoadModeL2) + assert.Equal(t, inverter.Power(0), runtimeData.BackupL2Power) + assert.Equal(t, inverter.Voltage(0), runtimeData.BackupL3Voltage) + assert.Equal(t, inverter.Current(0), runtimeData.BackupL3Current) + assert.Equal(t, inverter.Frequency(0), runtimeData.BackupL3Frequency) + assert.Equal(t, 0, runtimeData.LoadModeL3) + assert.Equal(t, inverter.Power(1940), runtimeData.LoadL1) + assert.Equal(t, inverter.Power(0), runtimeData.LoadL2) + assert.Equal(t, inverter.Power(0), runtimeData.LoadL3) + }) +} diff --git a/inverter/types.go b/inverter/types.go index 1d82eb4..761979c 100644 --- a/inverter/types.go +++ b/inverter/types.go @@ -1,5 +1,45 @@ package inverter +import ( + "fmt" + "time" + + "golang.org/x/exp/constraints" +) + +type numeric interface { + constraints.Integer | constraints.Float +} + +type ( + Power float64 + Voltage float64 + Current float64 + Energy float64 + Frequency float64 + Temp float64 +) + +func newPower[T numeric](v T) Power { return Power(float64(v)) } +func newVoltage[T numeric](v T) Voltage { return Voltage(float64(v) / 10.0) } +func newCurrent[T numeric](v T) Current { return Current(float64(v) / 10.0) } +func newFrequency[T numeric](v T) Frequency { return Frequency(float64(v) / 100.0) } +func newTemp(v int16) Temp { return Temp(float64(v) / 10.0) } +func newEnergy[T numeric](v T) Energy { + f := float64(v) + if f == -1 { + return 0 + } + return Energy(f / 10.0) +} + +func (v Power) String() string { return fmt.Sprintf("%f W", v) } +func (v Voltage) String() string { return fmt.Sprintf("%f V", v) } +func (v Current) String() string { return fmt.Sprintf("%f A", v) } +func (v Energy) String() string { return fmt.Sprintf("%f kWh", v) } +func (v Frequency) String() string { return fmt.Sprintf("%f Hz", v) } +func (v Temp) String() string { return fmt.Sprintf("%f C", v) } + // DeviceInfo holds the static information about an inverter. type DeviceInfo struct { ModbusVersion int `json:"modbus_version"` @@ -14,84 +54,85 @@ type DeviceInfo struct { ArmSVNVersion int `json:"arm_svn_version"` SoftwareVersion string `json:"software_version"` ArmVersion string `json:"arm_version"` - SinglePhase bool + SinglePhase bool `json:"single_phase"` } 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"` + Timestamp time.Time `json:"timestamp"` + PV1Voltage Voltage `json:"pv1_voltage"` + PV1Current Current `json:"pv1_current"` + PV1Power Power `json:"pv1_power"` + PV2Voltage Voltage `json:"pv2_voltage"` + PV2Current Current `json:"pv2_current"` + PV2Power Power `json:"pv2_power"` + PVPower Power `json:"pv_power"` + PV2Mode byte `json:"pv2_mode"` + PV1Mode byte `json:"pv1_mode"` + OnGridL1Voltage Voltage `json:"on_grid_l1_voltage"` + OnGridL1Current Current `json:"on_grid_l1_current"` + OnGridL1Frequency Frequency `json:"on_grid_l1_frequency"` + OnGridL1Power Power `json:"on_grid_l1_power"` + OnGridL2Voltage Voltage `json:"on_grid_l2_voltage"` + OnGridL2Current Current `json:"on_grid_l2_current"` + OnGridL2Frequency Frequency `json:"on_grid_l2_frequency"` + OnGridL2Power Power `json:"on_grid_l2_power"` + OnGridL3Voltage Voltage `json:"on_grid_l3_voltage"` + OnGridL3Current Current `json:"on_grid_l3_current"` + OnGridL3Frequency Frequency `json:"on_grid_l3_frequency"` + OnGridL3Power Power `json:"on_grid_l3_power"` + GridMode int `json:"grid_mode"` + TotalInverterPower Power `json:"total_inverter_power"` + ActivePower Power `json:"active_power"` + ReactivePower int `json:"reactive_power"` + ApparentPower int `json:"apparent_power"` + BackupL1Voltage Voltage `json:"backup_l1_voltage"` + BackupL1Current Current `json:"backup_l1_current"` + BackupL1Frequency Frequency `json:"backup_l1_frequency"` + LoadModeL1 int `json:"load_mode_l1"` + BackupL1Power Power `json:"backup_l1_power"` + BackupL2Voltage Voltage `json:"backup_l2_voltage"` + BackupL2Current Current `json:"backup_l2_current"` + BackupL2Frequency Frequency `json:"backup_l2_frequency"` + LoadModeL2 int `json:"load_mode_l2"` + BackupL2Power Power `json:"backup_l2_power"` + BackupL3Voltage Voltage `json:"backup_l3_voltage"` + BackupL3Current Current `json:"backup_l3_current"` + BackupL3Frequency Frequency `json:"backup_l3_frequency"` + LoadModeL3 int `json:"load_mode_l3"` + BackupL3Power Power `json:"backup_l3_power"` + LoadL1 Power `json:"load_l1"` + LoadL2 Power `json:"load_l2"` + LoadL3 Power `json:"load_l3"` + BackupLoad Power `json:"backup_load"` + Load Power `json:"load"` + UPSLoad int `json:"ups_load"` + TemperatureAir Temp `json:"temperature_air"` + TemperatureModule Temp `json:"temperature_module"` + Temperature Temp `json:"temperature"` + FunctionBit int `json:"-"` + BusVoltage Voltage `json:"bus_voltage"` + NBusVoltage Voltage `json:"nbus_voltage"` + BatteryVoltage Voltage `json:"battery_voltage"` + BatteryCurrent Current `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:"-"` + EnergyGenerationTotal Energy `json:"energy_generation_total"` + EnergyGenerationToday Energy `json:"energy_generation_today"` + EnergyExportTotal Energy `json:"energy_export_total"` + EnergyExportTotalHours int `json:"energy_export_total_hours"` + EnergyExportToday Energy `json:"energy_export_today"` + EnergyImportTotal Energy `json:"energy_import_total"` + EnergyImportToday Energy `json:"energy_import_today"` + EnergyLoadTotal Energy `json:"energy_load_total"` + EnergyLoadDay Energy `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 Power `json:"house_consumption"` }