Add Hoymiles HMT/HMS DTU PRO S Modbus TCP (#29281)

This commit is contained in:
Joni Lehtola 2026-06-02 12:11:44 +03:00 • committed by GitHub
parent dc37e7a313
commit 315f94bde1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 166 additions and 0 deletions

136
meter/hoymiles-dtu-mbtcp.go Normal file
View file

@ -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
}

View file

@ -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 }}