diff --git a/meter/hoymiles-dtu-mbtcp.go b/meter/hoymiles-dtu-mbtcp.go new file mode 100644 index 000000000..9f355d76a --- /dev/null +++ b/meter/hoymiles-dtu-mbtcp.go @@ -0,0 +1,136 @@ +package meter + +import ( + "context" + "encoding/binary" + "fmt" + "time" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/api/implement" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/modbus" +) + +const ( + hoymilesPanelLengthBytes = uint16(0x28) + hoymilesStartRegisterBytes = uint16(0x1000) + hoymilesMaxPanels = 99 +) + +func init() { + registry.AddCtx("hoymiles-dtu-mb", NewHoymilesDTUModbusTcpFromConfig) +} + +type hoymilesDTUValues struct { + power float64 + totalEnergy float64 +} + +type hoymilesDTUModbusTCP struct { + log *util.Logger + conn *modbus.Connection + valuesG func() (hoymilesDTUValues, error) +} + +// NewHoymilesDTUModbusTcpFromConfig creates a Hoymiles DTU meter from generic config +func NewHoymilesDTUModbusTcpFromConfig(ctx context.Context, other map[string]any) (api.Meter, error) { + cc := struct { + modbus.TcpSettings `mapstructure:",squash"` + pvMaxACPower `mapstructure:",squash"` + Cache time.Duration + }{ + TcpSettings: modbus.TcpSettings{ID: 1}, + Cache: 15 * time.Second, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + return newHoymilesDTUModbusTCP(ctx, cc.URI, cc.ID, cc.pvMaxACPower.Decorator(), cc.Cache) +} + +func newHoymilesDTUModbusTCP(ctx context.Context, uri string, id uint8, maxACPower func() float64, cache time.Duration) (api.Meter, error) { + conn, err := modbus.NewConnection(ctx, uri, "", "", 0, modbus.Tcp, id) + if err != nil { + return nil, err + } + + log := util.NewLogger("hoymiles-dtu-mb") + conn.Logger(log.TRACE) + + m := &hoymilesDTUModbusTCP{ + log: log, + conn: conn, + } + + m.valuesG = util.Cached(m.readCurrentValues, cache) + + meter, _ := NewConfigurable(m.CurrentPower) + implement.Has(meter, implement.MeterEnergy(m.TotalEnergy)) + implement.May(meter, implement.MaxACPowerGetter(maxACPower)) + + return meter, nil +} + +func (m *hoymilesDTUModbusTCP) panelPower(panelIndex int) (float64, float64, bool, error) { + // Each panel's data is 0x28 bytes (40 bytes); panel N starts at register 0x1000 + N×0x28. + // Confirmed with Hoymiles DTU PRO S hardware against the Modbus register map. + startRegister := hoymilesStartRegisterBytes + uint16(panelIndex)*hoymilesPanelLengthBytes + results, err := m.conn.ReadHoldingRegisters(startRegister, hoymilesPanelLengthBytes/2) + if err != nil { + return 0, 0, false, fmt.Errorf("failed to read panel %d: %w", panelIndex, err) + } + if len(results) < int(hoymilesPanelLengthBytes) { + return 0, 0, false, fmt.Errorf("panel %d: short Modbus response: got %d bytes, expected %d", panelIndex, len(results), hoymilesPanelLengthBytes) + } + + // inverterSerial: bytes 0x01–0x06 + serialBytes := results[1:7] + inverterSerial := fmt.Sprintf("%X", serialBytes) + // all-zero serial means no more panels; DTU returns zeros for unoccupied slots + serialIsZero := binary.BigEndian.Uint32(serialBytes[:4]) == 0 && + binary.BigEndian.Uint16(serialBytes[4:6]) == 0 + // portNumber: byte 0x07 + portNumber := int(results[7]) + if serialIsZero || portNumber == 0 { + m.log.TRACE.Printf("panel %d: no more panels to read", panelIndex) + return 0, 0, false, nil + } + // power: bytes 0x10–0x11, uint16 / 10 → watts (one decimal place precision) + power := float64(binary.BigEndian.Uint16(results[16:18])) / 10.0 + // totalCumulativeProduction: bytes 0x14–0x17, uint32, in Wh + totalCumulativeProduction := binary.BigEndian.Uint32(results[20:24]) + m.log.TRACE.Printf("panel %d: inverter serial %s, port %d, power %.1f W, cumulative production %d Wh", panelIndex, inverterSerial, portNumber, power, totalCumulativeProduction) + return power, float64(totalCumulativeProduction), true, nil +} + +func (m *hoymilesDTUModbusTCP) readCurrentValues() (hoymilesDTUValues, error) { + var values hoymilesDTUValues + for i := 0; i < hoymilesMaxPanels; i++ { + power, cumulative, found, err := m.panelPower(i) + if err != nil { + return hoymilesDTUValues{}, err + } + if !found { + break + } + values.power += power + values.totalEnergy += cumulative + } + m.log.DEBUG.Printf("total power from all panels: %.2f W, total cumulative production: %.2f Wh", values.power, values.totalEnergy) + return values, nil +} + +// CurrentPower implements the api.Meter interface +func (m *hoymilesDTUModbusTCP) CurrentPower() (float64, error) { + res, err := m.valuesG() + return res.power, err +} + +// TotalEnergy implements the api.MeterEnergy interface +func (m *hoymilesDTUModbusTCP) TotalEnergy() (float64, error) { + res, err := m.valuesG() + return res.totalEnergy / 1e3, err // Wh to kWh +} diff --git a/templates/definition/meter/hoymiles-dtu-mb.yaml b/templates/definition/meter/hoymiles-dtu-mb.yaml new file mode 100644 index 000000000..0e2165b5c --- /dev/null +++ b/templates/definition/meter/hoymiles-dtu-mb.yaml @@ -0,0 +1,30 @@ +template: hoymiles-dtu-mb +products: + - brand: Hoymiles + description: + de: "HMT/HMS-Serie mit DTU PRO S / MODBUS TCP" + en: "HMT/HMS-Series with DTU PRO S / MODBUS TCP" +requirements: + description: + de: | + Benötigt wird ein Hoymiles Wechselrichter der HMT/HMS Serie mit DTU PRO S datalogger mit Modbus TCP aktiviert (verbunden mit LAN) + en: | + Requires a Hoymiles Inverter of the HMT/HMS Series with DTU PRO S datalogger with Modbus TCP enabled (connected with LAN) +params: + - name: usage + choice: ["pv"] + - name: modbus + choice: ["tcpip"] + - name: maxacpower + advanced: true + default: "0" + description: + de: "Gesamtleistung der Wechselrichter" + en: "Total maximum power of the inverters" + help: + de: "Gesamtleistung der Wechselrichter in Watt (z.B. 3000)" + en: "Total maximum power of the inverters in watt (e.g. 3000)" +render: | + type: hoymiles-dtu-mb + {{- include "modbus" . }} + maxacpower: {{ .maxacpower }}