package inverter import ( "bytes" "context" "encoding/binary" "fmt" "math" "strings" "time" "git.netflux.io/rob/solar-toolkit/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 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 etMeterData struct { ComMode int16 RSSI int16 ManufactureCode int16 MeterTestStatus int16 MeterCommStatus int16 ActivePowerL1 int16 ActivePowerL2 int16 ActivePowerL3 int16 ActivePowerTotal int16 ReactivePowerTotal int16 MeterPowerFactor1 int16 MeterPowerFactor2 int16 MeterPowerFactor3 int16 MeterPowerFactor int16 MeterFrequency int16 EnergyExportTotal float32 EnergyImportTotal float32 MeterActivePower1 int32 MeterActivePower2 int32 MeterActivePower3 int32 MeterActivePowerTotal int32 MeterReactivePower1 int32 MeterReactivePower2 int32 MeterReactivePower3 int32 MeterReactivePowerTotal int32 MeterApparentPower1 int32 MeterApparentPower2 int32 MeterApparentPower3 int32 MeterApparentPowerTotal int32 MeterType int16 MeterSoftwareVersion int16 } func (data *etMeterData) toMeterData(singlePhase bool) *ETMeterData { return &ETMeterData{ ComMode: int(data.ComMode), RSSI: int(data.RSSI), ManufactureCode: int(data.ManufactureCode), MeterTestStatus: int(data.MeterTestStatus), MeterCommStatus: int(data.MeterCommStatus), ActivePowerL1: newPower(data.ActivePowerL1), ActivePowerL2: newPower(data.ActivePowerL2), ActivePowerL3: newPower(data.ActivePowerL3), ActivePowerTotal: newPower(data.ActivePowerTotal), ReactivePowerTotal: int(data.ReactivePowerTotal), MeterPowerFactor1: float64(data.MeterPowerFactor1) / 1000.0, MeterPowerFactor2: float64(filterSinglePhase(data.MeterPowerFactor2, singlePhase)) / 1000.0, MeterPowerFactor3: float64(filterSinglePhase(data.MeterPowerFactor3, singlePhase)) / 1000.0, MeterPowerFactor: float64(data.MeterPowerFactor) / 1000.0, MeterFrequency: newFrequency(data.MeterFrequency), EnergyExportTotal: newPower(data.EnergyExportTotal), EnergyImportTotal: newPower(data.EnergyImportTotal), MeterActivePower1: newPower(data.MeterActivePower1), MeterActivePower2: newPower(data.MeterActivePower2), MeterActivePower3: newPower(data.MeterActivePower3), MeterActivePowerTotal: newPower(data.MeterActivePowerTotal), MeterReactivePower1: int(data.MeterReactivePower1), MeterReactivePower2: int(data.MeterReactivePower2), MeterReactivePower3: int(data.MeterReactivePower3), MeterReactivePowerTotal: int(data.MeterReactivePowerTotal), MeterApparentPower1: int(data.MeterApparentPower1), MeterApparentPower2: int(data.MeterApparentPower2), MeterApparentPower3: int(data.MeterApparentPower3), MeterApparentPowerTotal: int(data.MeterApparentPowerTotal), MeterType: int(data.MeterType), MeterSoftwareVersion: int(data.MeterSoftwareVersion), } } // 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 { Timestamp [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 EnergyGenerationTotal int32 EnergyGenerationToday 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 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 { 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{ 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: 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: newPower(data.TotalInverterPower), ActivePower: newPower(data.ActivePower), ReactivePower: int(data.ReactivePower), ApparentPower: int(data.ApparentPower), BackupL1Voltage: newVoltage(data.BackupL1Voltage), BackupL1Current: newCurrent(data.BackupL1Current), BackupL1Frequency: newFrequency(data.BackupL1Frequency), LoadModeL1: int(data.LoadModeL1), 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: newTemp(data.TemperatureAir), TemperatureModule: newTemp(data.TemperatureModule), Temperature: newTemp(data.Temperature), FunctionBit: int(data.FunctionBit), 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), EnergyGenerationTotal: newEnergy(data.EnergyGenerationTotal), EnergyGenerationToday: newEnergy(data.EnergyGenerationToday), EnergyExportTotal: newEnergy(data.EnergyExportTotal), EnergyExportTotalHours: int(data.EnergyExportTotalHours), 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: 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 } func (inv ET) DecodeMeterData(p []byte) (*ETMeterData, error) { var meterData etMeterData if err := binary.Read(bytes.NewReader(p), binary.BigEndian, &meterData); err != nil { return nil, fmt.Errorf("error parsing response: %s", err) } return meterData.toMeterData(inv.isSinglePhase()), nil } // DEPRECATED func (inv ET) DeviceInfo(ctx context.Context, conn command.Conn) (*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 } // DEPRECATED func (inv ET) RuntimeData(ctx context.Context, conn command.Conn) (*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 } // DEPRECATED func (inv ET) MeterData(ctx context.Context, conn command.Conn) (*ETMeterData, error) { resp, err := command.Send(command.NewModbus(command.ModbusCommandTypeRead, 0x8ca0, 0x2d), conn) if err != nil { return nil, fmt.Errorf("error sending command: %s", err) } var meterData etMeterData if err := binary.Read(bytes.NewReader(resp), binary.BigEndian, &meterData); err != nil { return nil, fmt.Errorf("error parsing response: %s", err) } // TODO: wire in single phase: return meterData.toMeterData(true), nil }