diff --git a/cmd/detect/tasks/modbus.go b/cmd/detect/tasks/modbus.go index ab4d45383..c9bf5e4ca 100644 --- a/cmd/detect/tasks/modbus.go +++ b/cmd/detect/tasks/modbus.go @@ -12,7 +12,6 @@ import ( "github.com/evcc-io/evcc/util/modbus" gridx "github.com/grid-x/modbus" "github.com/volkszaehler/mbmd/meters" - "github.com/volkszaehler/mbmd/meters/rs485" "github.com/volkszaehler/mbmd/meters/sunspec" ) @@ -56,7 +55,7 @@ func ModbusHandlerFactory(conf map[string]interface{}) (TaskHandler, error) { } if handler.Register.Address > 0 { - handler.op, err = modbus.RegisterOperation(handler.Register) + handler.op, err = handler.Register.Operation() } return &handler, err @@ -70,7 +69,7 @@ type ModbusHandler struct { Register modbus.Register `mapstructure:",squash"` Values []int Invalid []int - op rs485.Operation + op modbus.RegisterOperation Timeout time.Duration } @@ -80,9 +79,9 @@ func (h *ModbusHandler) testRegister(_ *util.Logger, conn gridx.Client) bool { switch h.op.FuncCode { case gridx.FuncCodeReadHoldingRegisters: - bytes, err = conn.ReadHoldingRegisters(h.op.OpCode, h.op.ReadLen) + bytes, err = conn.ReadHoldingRegisters(h.op.Addr, h.op.Length) case gridx.FuncCodeReadInputRegisters: - bytes, err = conn.ReadInputRegisters(h.op.OpCode, h.op.ReadLen) + bytes, err = conn.ReadInputRegisters(h.op.Addr, h.op.Length) } if err != nil { @@ -94,7 +93,7 @@ func (h *ModbusHandler) testRegister(_ *util.Logger, conn gridx.Client) bool { } var u uint64 - switch h.op.ReadLen { + switch h.op.Length { case 1: u = uint64(binary.BigEndian.Uint16(bytes)) case 2: @@ -200,7 +199,7 @@ func (h *ModbusHandler) Test(log *util.Logger, in ResultDetails) (res []ResultDe } var ok bool - if h.op.OpCode > 0 { + if h.op.Addr > 0 { // log.DEBUG.Printf("slave id: %d op: %v", slaveID, h.op) ok = h.testRegister(log, conn.ModbusClient()) } else { diff --git a/meter/mbmd.go b/meter/mbmd.go index e869ed6d7..fbff86016 100644 --- a/meter/mbmd.go +++ b/meter/mbmd.go @@ -96,14 +96,16 @@ func NewModbusMbmdFromConfig(other map[string]interface{}) (api.Meter, error) { device: device, } - if err := modbus.ParseOperation(device, cc.Power, &m.opPower); err != nil { + m.opPower, err = modbus.ParseOperation(device, cc.Power) + if err != nil { return nil, fmt.Errorf("invalid measurement for power: %s", cc.Power) } // decorate energy var totalEnergy func() (float64, error) if cc.Energy != "" { - if err := modbus.ParseOperation(device, cc.Energy, &m.opEnergy); err != nil { + m.opEnergy, err = modbus.ParseOperation(device, cc.Energy) + if err != nil { return nil, fmt.Errorf("invalid measurement for energy: %s", cc.Energy) } @@ -131,7 +133,8 @@ func NewModbusMbmdFromConfig(other map[string]interface{}) (api.Meter, error) { // decorate soc var soc func() (float64, error) if cc.Soc != "" { - if err := modbus.ParseOperation(device, cc.Soc, &m.opSoc); err != nil { + m.opSoc, err = modbus.ParseOperation(device, cc.Soc) + if err != nil { return nil, fmt.Errorf("invalid measurement for soc: %s", cc.Soc) } @@ -150,9 +153,8 @@ func (m *ModbusMbmd) buildPhaseProviders(readings []string) (func() (float64, fl phases := make([]func() (float64, error), 0, 3) for idx, reading := range readings { - var opCurrent modbus.Operation - - if err := modbus.ParseOperation(m.device, reading, &opCurrent); err != nil { + opCurrent, err := modbus.ParseOperation(m.device, reading) + if err != nil { return nil, fmt.Errorf("invalid measurement [%d]: %s", idx, reading) } diff --git a/provider/modbus.go b/provider/modbus.go index 6ece5b618..ccd00b870 100644 --- a/provider/modbus.go +++ b/provider/modbus.go @@ -3,7 +3,6 @@ package provider import ( "bytes" "encoding/binary" - "errors" "fmt" "math" "strings" @@ -12,17 +11,14 @@ import ( "github.com/evcc-io/evcc/util" "github.com/evcc-io/evcc/util/modbus" gridx "github.com/grid-x/modbus" - "github.com/volkszaehler/mbmd/meters" - "github.com/volkszaehler/mbmd/meters/sunspec" ) // Modbus implements modbus RTU and TCP access type Modbus struct { - log *util.Logger - conn *modbus.Connection - device meters.Device - op modbus.Operation - scale float64 + log *util.Logger + conn *modbus.Connection + op modbus.RegisterOperation + scale float64 } func init() { @@ -32,10 +28,8 @@ func init() { // NewModbusFromConfig creates Modbus plugin func NewModbusFromConfig(other map[string]interface{}) (Provider, error) { cc := struct { - Model string modbus.Settings `mapstructure:",squash"` Register modbus.Register - Value string Scale float64 Delay time.Duration ConnectDelay time.Duration @@ -48,12 +42,6 @@ func NewModbusFromConfig(other map[string]interface{}) (Provider, error) { return nil, err } - // assume RTU if not set and this is a known RS485 meter model - if cc.RTU == nil { - b := modbus.IsRS485(cc.Model) - cc.RTU = &b - } - conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.ProtocolFromRTU(cc.RTU), cc.ID) if err != nil { return nil, err @@ -77,130 +65,47 @@ func NewModbusFromConfig(other map[string]interface{}) (Provider, error) { log := util.NewLogger("modbus") conn.Logger(log.TRACE) - var device meters.Device - var op modbus.Operation - - if (cc.Value == "") == (cc.Register.Decode == "") { - return nil, errors.New("either value or register required") + if err := cc.Register.Error(); err != nil { + return nil, err } - if cc.Model == "" && cc.Value != "" { - return nil, errors.New("need device model when value configured") - } - - // no registered configured - need device - if cc.Register.Decode == "" { - device, err = modbus.NewDevice(cc.Model, cc.SubDevice) - - // prepare device - if err == nil { - err = device.Initialize(conn) - - // silence KOSTAL implementation errors - if errors.Is(err, meters.ErrPartiallyOpened) { - err = nil - } - } - - if err != nil { - return nil, err - } - } - - // model + value configured - if cc.Value != "" { - if err := modbus.ParseOperation(device, cc.Value, &op); err != nil { - return nil, fmt.Errorf("invalid value %s", cc.Value) - } - } - - // register configured - if cc.Register.Decode != "" { - if op.MBMD, err = modbus.RegisterOperation(cc.Register); err != nil { - return nil, err - } + op, err := cc.Register.Operation() + if err != nil { + return nil, err } mb := &Modbus{ - log: log, - conn: conn, - device: device, - op: op, - scale: cc.Scale, + log: log, + conn: conn, + op: op, + scale: cc.Scale, } return mb, nil } func (m *Modbus) bytesGetter() ([]byte, error) { - if op := m.op.MBMD; op.FuncCode != 0 { - switch op.FuncCode { - case gridx.FuncCodeReadHoldingRegisters: - return m.conn.ReadHoldingRegisters(op.OpCode, op.ReadLen) + switch m.op.FuncCode { + case gridx.FuncCodeReadHoldingRegisters: + return m.conn.ReadHoldingRegisters(m.op.Addr, m.op.Length) - case gridx.FuncCodeReadInputRegisters: - return m.conn.ReadInputRegisters(op.OpCode, op.ReadLen) + case gridx.FuncCodeReadInputRegisters: + return m.conn.ReadInputRegisters(m.op.Addr, m.op.Length) - case gridx.FuncCodeReadCoils: - return m.conn.ReadCoils(op.OpCode, op.ReadLen) + case gridx.FuncCodeReadCoils: + return m.conn.ReadCoils(m.op.Addr, m.op.Length) - default: - return nil, fmt.Errorf("invalid read function code: %d", op.FuncCode) - } + default: + return nil, fmt.Errorf("invalid read function code: %d", m.op.FuncCode) } - - return nil, errors.New("expected rtu reading") } func (m *Modbus) floatGetter() (f float64, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic: %v", r) - } - }() - - var res meters.MeasurementResult - - // if funccode is configured, execute the read directly - if op := m.op.MBMD; op.FuncCode != 0 { - var bytes []byte - if bytes, err = m.bytesGetter(); err != nil { - return 0, fmt.Errorf("read failed: %w", err) - } - - return m.scale * op.Transform(bytes), nil + bytes, err := m.bytesGetter() + if err != nil { + return 0, fmt.Errorf("read failed: %w", err) } - // if funccode is not configured, try find the reading on sunspec - if dev, ok := m.device.(*sunspec.SunSpec); ok { - if m.op.MBMD.IEC61850 != 0 { - res, err = dev.QueryOp(m.conn, m.op.MBMD.IEC61850) - } else { - if res.Value, err = dev.QueryPoint( - m.conn, - m.op.SunSpec.Model, - m.op.SunSpec.Block, - m.op.SunSpec.Point, - ); err != nil { - err = fmt.Errorf("model %d block %d point %s: %w", m.op.SunSpec.Model, m.op.SunSpec.Block, m.op.SunSpec.Point, err) - } - } - } - - // silence NaN reading errors by assuming zero - if err != nil && errors.Is(err, meters.ErrNaN) { - res.Value = 0 - err = nil - } - - if err == nil { - if m.op.MBMD.IEC61850 != 0 { - m.log.TRACE.Printf("%s: %v", m.op.MBMD.IEC61850, res.Value) - } else { - m.log.TRACE.Printf("%d:%d:%s: %v", m.op.SunSpec.Model, m.op.SunSpec.Block, m.op.SunSpec.Point, res.Value) - } - } - - return m.scale * res.Value, err + return m.scale * m.op.Decode(bytes), nil } var _ FloatProvider = (*Modbus)(nil) @@ -236,24 +141,6 @@ func (m *Modbus) StringGetter() (func() (string, error), error) { }, nil } -// UintFromBytes converts byte slice to bigendian uint value -func UintFromBytes(bytes []byte) (u uint64, err error) { - switch l := len(bytes); l { - case 1: - u = uint64(bytes[0]) - case 2: - u = uint64(binary.BigEndian.Uint16(bytes)) - case 4: - u = uint64(binary.BigEndian.Uint32(bytes)) - case 8: - u = binary.BigEndian.Uint64(bytes) - default: - err = fmt.Errorf("unexpected length: %d", l) - } - - return u, err -} - var _ BoolProvider = (*Modbus)(nil) // BoolGetter executes configured modbus read operation and implements IntProvider @@ -271,35 +158,58 @@ func (m *Modbus) BoolGetter() (func() (bool, error), error) { var _ SetFloatProvider = (*Modbus)(nil) -// FloatSetter executes configured modbus write operation and implements SetFloatProvider -func (m *Modbus) FloatSetter(_ string) (func(float64) error, error) { - op := m.op.MBMD - if op.FuncCode == 0 { - return nil, errors.New("modbus plugin does not support writing to sunspec") +func (m *Modbus) writeMultipleRegisters(val uint64) error { + val = m.op.Encode(val) + + var err error + switch m.op.Length { + case 1: + var b [2]byte + binary.BigEndian.PutUint16(b[:], uint16(val)) + _, err = m.conn.WriteMultipleRegisters(m.op.Addr, 1, b[:]) + + case 2: + var b [4]byte + binary.BigEndian.PutUint32(b[:], uint32(val)) + _, err = m.conn.WriteMultipleRegisters(m.op.Addr, 2, b[:]) + + case 4: + var b [8]byte + binary.BigEndian.PutUint64(b[:], val) + _, err = m.conn.WriteMultipleRegisters(m.op.Addr, 4, b[:]) + + default: + err = fmt.Errorf("invalid write length: %d", m.op.Length) } + return err +} + +// FloatSetter executes configured modbus write operation and implements SetFloatProvider +func (m *Modbus) FloatSetter(_ string) (func(float64) error, error) { // need multiple registers for float - if op.FuncCode != gridx.FuncCodeWriteMultipleRegisters { - return nil, fmt.Errorf("invalid write function code: %d", op.FuncCode) + if m.op.FuncCode != gridx.FuncCodeWriteMultipleRegisters { + return nil, fmt.Errorf("invalid write function code: %d", m.op.FuncCode) } return func(val float64) error { val = m.scale * val - var err error - switch op.ReadLen { + var uval uint64 + switch m.op.Length { case 2: - var b [4]byte - binary.BigEndian.PutUint32(b[:], math.Float32bits(float32(val))) - _, err = m.conn.WriteMultipleRegisters(op.OpCode, 2, b[:]) - + uval = uint64(math.Float32bits(float32(val))) case 4: - var b [8]byte - binary.BigEndian.PutUint64(b[:], math.Float64bits(val)) - _, err = m.conn.WriteMultipleRegisters(op.OpCode, 4, b[:]) + uval = math.Float64bits(val) + } + + var err error + switch m.op.FuncCode { + case gridx.FuncCodeWriteMultipleRegisters: + err = m.writeMultipleRegisters(uval) default: - err = fmt.Errorf("invalid write length: %d", op.ReadLen) + err = fmt.Errorf("invalid write function code: %d", m.op.FuncCode) } return err @@ -310,40 +220,16 @@ var _ SetIntProvider = (*Modbus)(nil) // IntSetter executes configured modbus write operation and implements SetIntProvider func (m *Modbus) IntSetter(_ string) (func(int64) error, error) { - op := m.op.MBMD - if op.FuncCode == 0 { - return nil, errors.New("modbus plugin does not support writing to sunspec") - } - return func(val int64) error { ival := int64(m.scale * float64(val)) - // if funccode is configured, execute the read directly var err error - switch op.FuncCode { + switch m.op.FuncCode { case gridx.FuncCodeWriteSingleRegister: - _, err = m.conn.WriteSingleRegister(op.OpCode, uint16(ival)) + _, err = m.conn.WriteSingleRegister(m.op.Addr, uint16(ival)) case gridx.FuncCodeWriteMultipleRegisters: - switch op.ReadLen { - case 1: - var b [2]byte - binary.BigEndian.PutUint16(b[:], uint16(ival)) - _, err = m.conn.WriteMultipleRegisters(op.OpCode, 1, b[:]) - - case 2: - var b [4]byte - binary.BigEndian.PutUint32(b[:], uint32(ival)) - _, err = m.conn.WriteMultipleRegisters(op.OpCode, 2, b[:]) - - case 4: - var b [8]byte - binary.BigEndian.PutUint64(b[:], uint64(ival)) - _, err = m.conn.WriteMultipleRegisters(op.OpCode, 4, b[:]) - - default: - err = fmt.Errorf("invalid write length: %d", op.ReadLen) - } + err = m.writeMultipleRegisters(uint64(ival)) case gridx.FuncCodeWriteSingleCoil: if ival != 0 { @@ -351,10 +237,10 @@ func (m *Modbus) IntSetter(_ string) (func(int64) error, error) { // and 0x0000 for OFF ival = 0xFF00 } - _, err = m.conn.WriteSingleCoil(op.OpCode, uint16(ival)) + _, err = m.conn.WriteSingleCoil(m.op.Addr, uint16(ival)) default: - err = fmt.Errorf("invalid write function code: %d", op.FuncCode) + err = fmt.Errorf("invalid write function code: %d", m.op.FuncCode) } return err diff --git a/provider/modbus_helper.go b/provider/modbus_helper.go new file mode 100644 index 000000000..f48dd2777 --- /dev/null +++ b/provider/modbus_helper.go @@ -0,0 +1,24 @@ +package provider + +import ( + "encoding/binary" + "fmt" +) + +// UintFromBytes converts byte slice to bigendian uint value +func UintFromBytes(bytes []byte) (u uint64, err error) { + switch l := len(bytes); l { + case 1: + u = uint64(bytes[0]) + case 2: + u = uint64(binary.BigEndian.Uint16(bytes)) + case 4: + u = uint64(binary.BigEndian.Uint32(bytes)) + case 8: + u = binary.BigEndian.Uint64(bytes) + default: + err = fmt.Errorf("unexpected length: %d", l) + } + + return u, err +} diff --git a/provider/modbus_sunspec.go b/provider/modbus_sunspec.go new file mode 100644 index 000000000..45f8a99ba --- /dev/null +++ b/provider/modbus_sunspec.go @@ -0,0 +1,242 @@ +package provider + +import ( + "errors" + "fmt" + "math" + "time" + + sunspec "github.com/andig/gosunspec" + "github.com/andig/gosunspec/typelabel" + "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/modbus" + "github.com/volkszaehler/mbmd/meters" + sunsdev "github.com/volkszaehler/mbmd/meters/sunspec" +) + +// ModbusSunspec implements modbus RTU and TCP access +type ModbusSunspec struct { + log *util.Logger + conn *modbus.Connection + device *sunsdev.SunSpec + op modbus.SunSpecOperation + scale float64 +} + +func init() { + registry.Add("sunspec", NewModbusSunspecFromConfig) +} + +// NewModbusSunspecFromConfig creates Modbus plugin +func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error) { + cc := struct { + modbus.Settings `mapstructure:",squash"` + Value string + Scale float64 + Delay time.Duration + ConnectDelay time.Duration + Timeout time.Duration + }{ + Scale: 1, + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + conn, err := modbus.NewConnection(cc.URI, cc.Device, cc.Comset, cc.Baudrate, modbus.Tcp, cc.ID) + if err != nil { + return nil, err + } + + // set non-default timeout + if cc.Timeout > 0 { + conn.Timeout(cc.Timeout) + } + + // set non-default delay + if cc.Delay > 0 { + conn.Delay(cc.Delay) + } + + // set non-default connect delay + if cc.ConnectDelay > 0 { + conn.ConnectDelay(cc.ConnectDelay) + } + + log := util.NewLogger("sunspec") + conn.Logger(log.TRACE) + + if cc.Value == "" { + return nil, errors.New("value is required") + } + + // silence KOSTAL implementation errors + device := sunsdev.NewDevice("sunspec", cc.SubDevice) + if err := device.Initialize(conn); err != nil && !errors.Is(err, meters.ErrPartiallyOpened) { + return nil, err + } + + ops, err := modbus.ParsePoint(cc.Value) + if err != nil { + return nil, fmt.Errorf("invalid sunspec value: %s", cc.Value) + } + + mb := &ModbusSunspec{ + log: log, + conn: conn, + device: device, + scale: cc.Scale, + } + + for _, op := range ops { + if _, _, err := device.QueryPointAny(conn, op.Model, op.Block, op.Point); err == nil { + mb.op = op + return mb, nil + } + } + + return nil, fmt.Errorf("sunspec model not found: %v", ops) +} + +func (m *ModbusSunspec) floatGetter() (f float64, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + res, err := m.device.QueryPoint( + m.conn, + m.op.Model, + m.op.Block, + m.op.Point, + ) + if err != nil { + return 0, fmt.Errorf("model %d block %d point %s: %w", m.op.Model, m.op.Block, m.op.Point, err) + } + + m.log.TRACE.Printf("model %d block %d point %s: %v", m.op.Model, m.op.Block, m.op.Point, res) + + return m.scale * res, nil +} + +var _ FloatProvider = (*Modbus)(nil) + +// FloatGetter executes configured modbus read operation and implements func() (float64, error) +func (m *ModbusSunspec) FloatGetter() (func() (f float64, err error), error) { + return m.floatGetter, nil +} + +var _ IntProvider = (*Modbus)(nil) + +// IntGetter executes configured modbus read operation and implements IntProvider +func (m *ModbusSunspec) IntGetter() (func() (int64, error), error) { + g, err := m.FloatGetter() + + return func() (int64, error) { + res, err := g() + return int64(math.Round(res)), err + }, err +} + +func (m *ModbusSunspec) blockPoint() (block sunspec.Block, point sunspec.Point, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + block, point, err = m.device.QueryPointAny( + m.conn, + m.op.Model, + m.op.Block, + m.op.Point, + ) + if err != nil { + err = fmt.Errorf("model %d block %d point %s: %w", m.op.Model, m.op.Block, m.op.Point, err) + } + + return block, point, err +} + +// TODO scale factors + +var _ SetFloatProvider = (*Modbus)(nil) + +// FloatSetter executes configured modbus write operation and implements SetFloatProvider +func (m *ModbusSunspec) FloatSetter(_ string) (func(float64) error, error) { + block, point, err := m.blockPoint() + if err != nil { + return nil, err + } + + typ := point.Type() + + return func(val float64) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + val = val * m.scale + switch typ { + case typelabel.Float32: + point.SetFloat32(float32(val)) + default: + return fmt.Errorf("invalid point type: %s", typ) + } + + return block.Write(m.op.Point) + }, nil +} + +var _ SetIntProvider = (*Modbus)(nil) + +// IntSetter executes configured modbus write operation and implements SetIntProvider +func (m *ModbusSunspec) IntSetter(_ string) (func(int64) error, error) { + block, point, err := m.blockPoint() + if err != nil { + return nil, err + } + + typ := point.Type() + + return func(val int64) (err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("panic: %v", r) + } + }() + + val = int64(float64(val) * m.scale) + + switch typ { + case typelabel.Bitfield16: + point.SetBitfield16(sunspec.Bitfield16(val)) + case typelabel.Bitfield32: + point.SetBitfield32(sunspec.Bitfield32(val)) + case typelabel.Enum16: + point.SetEnum16(sunspec.Enum16(val)) + case typelabel.Enum32: + point.SetEnum32(sunspec.Enum32(val)) + case typelabel.Int16: + point.SetInt16(int16(val)) + case typelabel.Int32: + point.SetInt32(int32(val)) + case typelabel.Int64: + point.SetInt64(val) + case typelabel.Uint16: + point.SetUint16(uint16(val)) + case typelabel.Uint32: + point.SetUint32(uint32(val)) + case typelabel.Uint64: + point.SetUint64(uint64(val)) + default: + return fmt.Errorf("invalid point type: %s", typ) + } + + return block.Write(m.op.Point) + }, nil +} diff --git a/templates/definition/meter/fronius-gen24.yaml b/templates/definition/meter/fronius-gen24.yaml index cb1f50215..2c2ae464a 100644 --- a/templates/definition/meter/fronius-gen24.yaml +++ b/templates/definition/meter/fronius-gen24.yaml @@ -36,45 +36,34 @@ render: | power: source: calc add: - - source: modbus + - source: sunspec uri: {{ .host }}:{{ .port }} id: 1 - model: sunspec value: 160:1:DCW # mppt 1 - - source: modbus + - source: sunspec uri: {{ .host }}:{{ .port }} id: 1 - model: sunspec value: 160:2:DCW # mppt 2 - energy: - source: modbus - uri: {{ .host }}:{{ .port }} - id: 1 - model: sunspec - value: Export {{- end }} {{- if eq .usage "battery" }} type: custom power: source: calc add: - - source: modbus + - source: sunspec uri: {{ .host }}:{{ .port }} id: 1 - model: sunspec value: 160:3:DCW # mppt 3 charge scale: -1 - - source: modbus + - source: sunspec uri: {{ .host }}:{{ .port }} id: 1 - model: sunspec value: 160:4:DCW # mppt 4 discharge soc: - source: modbus + source: sunspec uri: {{ .host }}:{{ .port }} id: 1 - model: sunspec - value: ChargeState + value: 124:ChaState {{- if .capacity }} capacity: {{ .capacity }} # kWh {{- end }} diff --git a/templates/definition/meter/kostal-plenticore.yaml b/templates/definition/meter/kostal-plenticore.yaml index 00a476c7e..c641b13d7 100644 --- a/templates/definition/meter/kostal-plenticore.yaml +++ b/templates/definition/meter/kostal-plenticore.yaml @@ -13,9 +13,9 @@ linked: requirements: description: de: | - Nur ein System kann und darf auf den Wechselrichter zugreifen! + Nur ein System kann und darf auf den Wechselrichter zugreifen! Für die aktive Batteriesteuerung muss die externe Batteriesteuerung über Modbus mit dem Handwerkerzugang aktiviert sein. en: | - Only a single system may access the inverter! + Only a single system may access the inverter! For active battery control, the external battery control via Modbus must be activated using installer access. params: - name: usage choice: ["pv", "battery"] @@ -50,30 +50,25 @@ render: | power: source: calc add: # The add plugin sums up all string values - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec value: 160:1:DCW # string 1 - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec value: 160:2:DCW # string 2 - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec value: 160:3:DCW # string 3 {{- end }} {{- if eq .usage "battery" }} type: custom power: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} - model: sunspec value: 802:W # 802 battery control soc: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} - model: sunspec value: 802:SoC # 802 battery control limitsoc: source: watchdog diff --git a/templates/definition/meter/solaredge-hybrid.yaml b/templates/definition/meter/solaredge-hybrid.yaml index a8da2664a..e20eaa759 100644 --- a/templates/definition/meter/solaredge-hybrid.yaml +++ b/templates/definition/meter/solaredge-hybrid.yaml @@ -26,10 +26,9 @@ render: | type: custom {{- if eq .usage "grid" }} power: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} timeout: {{ .timeout }} - model: sunspec subdevice: 1 # Metering device value: 203:W scale: -1 @@ -38,11 +37,10 @@ render: | power: source: calc add: - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} timeout: {{ .timeout }} - model: sunspec - value: DCPower + value: 101|103:DCW - source: modbus {{- include "modbus" . | indent 4 }} timeout: {{ .timeout }} diff --git a/templates/definition/meter/solaredge-inverter.yaml b/templates/definition/meter/solaredge-inverter.yaml index 31ea7974c..d3fe7e0f4 100644 --- a/templates/definition/meter/solaredge-inverter.yaml +++ b/templates/definition/meter/solaredge-inverter.yaml @@ -19,24 +19,19 @@ params: - name: timeout render: | type: custom + {{- if eq .usage "grid" }} power: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} timeout: {{ .timeout }} - {{- if eq .usage "grid" }} - model: sunspec subdevice: 1 value: 203:W # sunspec 3-phase meter power reading scale: -1 - {{- else }} - model: sunspec - value: Power {{- end }} {{- if eq .usage "pv" }} - energy: - source: modbus + power: + source: sunspec {{- include "modbus" . | indent 2 }} timeout: {{ .timeout }} - model: sunspec - value: Export + value: 101|103:W # sunspec inverter AC power reading {{- end }} diff --git a/templates/definition/meter/sunspec-hybrid.yaml b/templates/definition/meter/sunspec-hybrid.yaml index 37474a58e..1a4f46df4 100644 --- a/templates/definition/meter/sunspec-hybrid.yaml +++ b/templates/definition/meter/sunspec-hybrid.yaml @@ -65,50 +65,45 @@ render: | power: source: calc add: - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCPowerS1 # mppt 1 - - source: modbus + value: 160:1:DCW # mppt 1 + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCPowerS2 # mppt 2 + value: 160:2:DCW # mppt 2 energy: source: calc add: - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCEnergyS1 # mppt 1 - - source: modbus + value: 160:1:DCWH # mppt 1 + scale: 0.001 + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCEnergyS2 # mppt 2 + value: 160:2:DCWH # mppt 2 + scale: 0.001 {{- end }} {{- if eq .usage "battery" }} type: custom power: source: calc add: - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCPowerS3 # mppt 3 (charge) + value: 160:3:DCW # mppt 3 (charge) scale: -1 - - source: modbus + - source: sunspec {{- include "modbus" . | indent 4 }} - model: sunspec - value: DCPowerS4 # mppt 4 (discharge) + value: 160:4:DCW # mppt 4 (discharge) energy: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} - model: sunspec - value: DCEnergyS4 # mppt 4 (discharge) + value: 160:4:DCWH # mppt 4 (discharge) + scale: 0.001 soc: - source: modbus + source: sunspec {{- include "modbus" . | indent 2 }} - model: sunspec - value: ChargeState + value: 124:ChaState {{- if .capacity }} capacity: {{ .capacity }} # kWh {{- end }} diff --git a/templates/definition/meter/sunspec-inverter.yaml b/templates/definition/meter/sunspec-inverter.yaml index 85d723d36..031227db1 100644 --- a/templates/definition/meter/sunspec-inverter.yaml +++ b/templates/definition/meter/sunspec-inverter.yaml @@ -63,7 +63,7 @@ render: | power: Power energy: Export {{- if eq .usage "battery" }} - soc: ChargeState + soc: 124:ChaState {{- if .capacity }} capacity: {{ .capacity }} # kWh {{- end }} diff --git a/util/modbus/mbmd.go b/util/modbus/mbmd.go new file mode 100644 index 000000000..7ceeece81 --- /dev/null +++ b/util/modbus/mbmd.go @@ -0,0 +1,37 @@ +package modbus + +import ( + "github.com/volkszaehler/mbmd/meters" + "github.com/volkszaehler/mbmd/meters/rs485" +) + +// Operation is a register-based or sunspec modbus operation +type Operation struct { + MBMD rs485.Operation + SunSpec SunSpecOperation +} + +// ParseOperation parses an MBMD measurement or SunsSpec point definition into a modbus operation +func ParseOperation(dev meters.Device, measurement string) (Operation, error) { + var ( + op Operation + err error + ) + + // if measurement cannot be parsed it could be SunSpec model/block/point + op.MBMD.IEC61850, err = meters.MeasurementString(measurement) + if err != nil { + suns, err := ParsePoint(measurement) + if err == nil { + op.SunSpec = suns[0] + } + return op, err + } + + // for RS485 check if producer supports the measurement + if dev, ok := dev.(*rs485.RS485); ok { + op.MBMD, err = RS485FindDeviceOp(dev, op.MBMD.IEC61850) + } + + return op, err +} diff --git a/util/modbus/modbus.go b/util/modbus/modbus.go index 7600be799..c71dbc679 100644 --- a/util/modbus/modbus.go +++ b/util/modbus/modbus.go @@ -3,19 +3,14 @@ package modbus import ( "errors" "fmt" - "math" - "strconv" "strings" "sync" "time" "github.com/evcc-io/evcc/util" - "github.com/grid-x/modbus" - "github.com/volkszaehler/mbmd/encoding" "github.com/volkszaehler/mbmd/meters" "github.com/volkszaehler/mbmd/meters/rs485" "github.com/volkszaehler/mbmd/meters/sunspec" - "golang.org/x/exp/constraints" ) type Protocol int @@ -343,164 +338,3 @@ func RS485FindDeviceOp(device *rs485.RS485, measurement meters.Measurement) (op return op, fmt.Errorf("unsupported measurement: %s", measurement.String()) } - -// Register contains the ModBus register configuration -type Register struct { - Address uint16 // Length uint16 - Type string - Decode string - BitMask string -} - -// asFloat64 creates a function that returns numerics vales as float64 -func asFloat64[T constraints.Signed | constraints.Unsigned | constraints.Float](f func([]byte) T) func([]byte) float64 { - return func(v []byte) float64 { - res := float64(f(v)) - if math.IsNaN(res) || math.IsInf(res, 0) { - res = 0 - } - return res - } -} - -// RegisterOperation creates a read operation from a register definition -func RegisterOperation(r Register) (rs485.Operation, error) { - op := rs485.Operation{ - OpCode: r.Address, - ReadLen: 2, - } - - switch strings.ToLower(r.Type) { - case "holding": - op.FuncCode = modbus.FuncCodeReadHoldingRegisters - case "input": - op.FuncCode = modbus.FuncCodeReadInputRegisters - case "coil": - op.FuncCode = modbus.FuncCodeReadCoils - r.Decode = "bool8" - case "writesingle", "writeholding": - op.FuncCode = modbus.FuncCodeWriteSingleRegister - case "writemultiple", "writeholdings": - op.FuncCode = modbus.FuncCodeWriteMultipleRegisters - case "writecoil": - op.FuncCode = modbus.FuncCodeWriteSingleCoil - r.Decode = "bool8" - default: - return rs485.Operation{}, fmt.Errorf("invalid register type: %s", r.Type) - } - - switch strings.ToLower(r.Decode) { - // 8 bit (coil) - case "bool8": - op.Transform = decodeBool8 - op.ReadLen = 1 - - // 16 bit - case "int16": - op.Transform = asFloat64(encoding.Int16) - op.ReadLen = 1 - case "int16nan": - op.Transform = decodeNaN16(asFloat64(encoding.Int16), 1<<15, 1<<15-1) - op.ReadLen = 1 - case "uint16": - op.Transform = asFloat64(encoding.Uint16) - op.ReadLen = 1 - case "uint16nan": - op.Transform = decodeNaN16(asFloat64(encoding.Uint16), 1<<16-1) - op.ReadLen = 1 - case "bool16": - mask, err := decodeMask(r.BitMask) - if err != nil { - return op, err - } - op.Transform = decodeBool16(mask) - op.ReadLen = 1 - - // 32 bit - case "int32": - op.Transform = asFloat64(encoding.Int32) - case "int32nan": - op.Transform = decodeNaN32(asFloat64(encoding.Int32), 1<<31, 1<<31-1) - case "int32s": - op.Transform = asFloat64(encoding.Int32LswFirst) - case "uint32": - op.Transform = asFloat64(encoding.Uint32) - case "uint32s": - op.Transform = asFloat64(encoding.Uint32LswFirst) - case "uint32nan": - op.Transform = decodeNaN32(asFloat64(encoding.Uint32), 1<<32-1) - case "float32", "ieee754": - op.Transform = asFloat64(encoding.Float32) - case "float32s", "ieee754s": - op.Transform = asFloat64(encoding.Float32LswFirst) - - // 64 bit - case "uint64": - op.Transform = asFloat64(encoding.Uint64) - op.ReadLen = 4 - case "uint64nan": - op.Transform = decodeNaN64(asFloat64(encoding.Uint64), 1<<64-1) - op.ReadLen = 4 - case "float64": - op.Transform = encoding.Float64 - op.ReadLen = 4 - - default: - return rs485.Operation{}, fmt.Errorf("invalid register decoding: %s", r.Decode) - } - - return op, nil -} - -// SunSpecOperation is a sunspec modbus operation -type SunSpecOperation struct { - Model, Block int - Point string -} - -// ParsePoint parses sunspec point from string -func ParsePoint(selector string) (model, block int, point string, err error) { - err = fmt.Errorf("invalid point: %s", selector) - - el := strings.Split(selector, ":") - if len(el) < 2 || len(el) > 3 { - return - } - - if model, err = strconv.Atoi(el[0]); err != nil { - return - } - - if len(el) == 3 { - // block is the middle element - if block, err = strconv.Atoi(el[1]); err != nil { - return - } - } - - point = el[len(el)-1] - - return model, block, point, nil -} - -// Operation is a register-based or sunspec modbus operation -type Operation struct { - MBMD rs485.Operation - SunSpec SunSpecOperation -} - -// ParseOperation parses an MBMD measurement or SunsSpec point definition into a modbus operation -func ParseOperation(dev meters.Device, measurement string, op *Operation) (err error) { - // if measurement cannot be parsed it could be SunSpec model/block/point - if op.MBMD.IEC61850, err = meters.MeasurementString(measurement); err != nil { - op.SunSpec.Model, op.SunSpec.Block, op.SunSpec.Point, err = ParsePoint(measurement) - return err - } - - // for RS485 check if producer supports the measurement - if dev, ok := dev.(*rs485.RS485); ok { - op.MBMD, err = RS485FindDeviceOp(dev, op.MBMD.IEC61850) - } - - return err -} diff --git a/util/modbus/modbus_test.go b/util/modbus/modbus_test.go index c462f4958..bf87c71ec 100644 --- a/util/modbus/modbus_test.go +++ b/util/modbus/modbus_test.go @@ -1,30 +1,26 @@ package modbus -import "testing" +import ( + "testing" + + "github.com/stretchr/testify/require" +) func TestParsePoint(t *testing.T) { tc := []struct { - in string - model, block int - point string - err bool + in string + ops []SunSpecOperation }{ - {"103:W", 103, 0, "W", false}, - {"802:1:V", 802, 1, "V", false}, - {"802::V", 802, 1, "V", true}, + {"103:W", []SunSpecOperation{{103, 0, "W"}}}, + {"802:1:V", []SunSpecOperation{{802, 1, "V"}}}, + {"101|103:DCW", []SunSpecOperation{{101, 0, "DCW"}, {103, 0, "DCW"}}}, } for _, tc := range tc { t.Log(tc) - model, block, point, err := ParsePoint(tc.in) - - if (err != nil) != tc.err { - t.Errorf("unexpected error: %d:%d:%s %v", model, block, point, err) - } - - if !tc.err && (model != tc.model || block != tc.block || point != tc.point) { - t.Errorf("unexpected result: %d:%d:%s", model, block, point) - } + ops, err := ParsePoint(tc.in) + require.NoError(t, err) + require.Equal(t, tc.ops, ops) } } diff --git a/util/modbus/register.go b/util/modbus/register.go new file mode 100644 index 000000000..359215e7b --- /dev/null +++ b/util/modbus/register.go @@ -0,0 +1,180 @@ +package modbus + +import ( + "errors" + "fmt" + "math" + "slices" + "strings" + + "github.com/grid-x/modbus" + "github.com/volkszaehler/mbmd/encoding" + "golang.org/x/exp/constraints" +) + +// Register contains the ModBus register configuration +type Register struct { + Address uint16 // Length uint16 + Type string + Decode string // TODO deprecated, use Encoding + Encoding string + BitMask string +} + +func (r Register) Error() error { + if r.Address == 0 { + return errors.New("address is required") + } + if r.Type == "" { + return errors.New("type is required") + } + if r.Decode == "" && r.Encoding == "" { + return errors.New("encoding is required") + } + if r.Decode != "" && r.Encoding != "" { + return errors.New("must not have decide when encoding is specified") + } + return nil +} + +func (r Register) encoding() string { + if r.Encoding != "" { + return r.Encoding + } + return r.Decode +} + +// Operation creates a modbus operation from a register definition +func (r Register) Operation() (RegisterOperation, error) { + op := RegisterOperation{ + Addr: r.Address, + } + + switch strings.ToLower(r.Type) { + case "holding": + op.FuncCode = modbus.FuncCodeReadHoldingRegisters + case "input": + op.FuncCode = modbus.FuncCodeReadInputRegisters + case "coil": + op.FuncCode = modbus.FuncCodeReadCoils + case "writesingle", "writeholding": + op.FuncCode = modbus.FuncCodeWriteSingleRegister + case "writemultiple", "writeholdings": + op.FuncCode = modbus.FuncCodeWriteMultipleRegisters + case "writecoil": + op.FuncCode = modbus.FuncCodeWriteSingleCoil + default: + return RegisterOperation{}, fmt.Errorf("invalid register type: %s", r.Type) + } + + if op.IsRead() { + switch strings.ToLower(r.encoding()) { + // 8 bit (coil) + case "bool8": + op.Decode = decodeBool8 + op.Length = 1 + + // 16 bit + case "int16": + op.Decode = asFloat64(encoding.Int16) + op.Length = 1 + case "int16nan": + op.Decode = decodeNaN16(asFloat64(encoding.Int16), 1<<15, 1<<15-1) + op.Length = 1 + case "uint16": + op.Decode = asFloat64(encoding.Uint16) + op.Length = 1 + case "uint16nan": + op.Decode = decodeNaN16(asFloat64(encoding.Uint16), 1<<16-1) + op.Length = 1 + case "bool16": + mask, err := decodeMask(r.BitMask) + if err != nil { + return op, err + } + op.Decode = decodeBool16(mask) + op.Length = 1 + + // 32 bit + case "int32": + op.Decode = asFloat64(encoding.Int32) + op.Length = 2 + case "int32nan": + op.Decode = decodeNaN32(asFloat64(encoding.Int32), 1<<31, 1<<31-1) + op.Length = 2 + case "int32s": + op.Decode = asFloat64(encoding.Int32LswFirst) + op.Length = 2 + case "uint32": + op.Decode = asFloat64(encoding.Uint32) + op.Length = 2 + case "uint32s": + op.Decode = asFloat64(encoding.Uint32LswFirst) + op.Length = 2 + case "uint32nan": + op.Decode = decodeNaN32(asFloat64(encoding.Uint32), 1<<32-1) + op.Length = 2 + case "float32", "ieee754": + op.Decode = asFloat64(encoding.Float32) + op.Length = 2 + case "float32s", "ieee754s": + op.Decode = asFloat64(encoding.Float32LswFirst) + op.Length = 2 + + // 64 bit + case "uint64": + op.Decode = asFloat64(encoding.Uint64) + op.Length = 4 + case "uint64nan": + op.Decode = decodeNaN64(asFloat64(encoding.Uint64), 1<<64-1) + op.Length = 4 + case "float64": + op.Decode = encoding.Float64 + op.Length = 4 + + default: + return RegisterOperation{}, fmt.Errorf("invalid register decoding: %s", r.Decode) + } + } else { + switch strings.ToLower(r.encoding()) { + case "int32s", "uint32s", "float32s", "ieee754s": + op.Encode = func(v uint64) uint64 { + return v&0xFFFF<<16 | v&0xFFFF0000>>16 + } + + default: + op.Encode = func(v uint64) uint64 { + return v + } + } + } + + return op, nil +} + +// asFloat64 creates a function that returns numerics vales as float64 +func asFloat64[T constraints.Signed | constraints.Unsigned | constraints.Float](f func([]byte) T) func([]byte) float64 { + return func(v []byte) float64 { + res := float64(f(v)) + if math.IsNaN(res) || math.IsInf(res, 0) { + res = 0 + } + return res + } +} + +type RegisterOperation struct { + FuncCode uint8 + Addr uint16 + Length uint16 + Encode func(uint64) uint64 + Decode func([]byte) float64 +} + +func (op RegisterOperation) IsRead() bool { + return !slices.Contains([]uint8{ + modbus.FuncCodeWriteSingleRegister, + modbus.FuncCodeWriteMultipleRegisters, + modbus.FuncCodeWriteSingleCoil, + }, op.FuncCode) +} diff --git a/util/modbus/sunspec.go b/util/modbus/sunspec.go new file mode 100644 index 000000000..363ef73ca --- /dev/null +++ b/util/modbus/sunspec.go @@ -0,0 +1,51 @@ +package modbus + +import ( + "fmt" + "strconv" + "strings" +) + +// SunSpecOperation is a sunspec modbus operation +type SunSpecOperation struct { + Model, Block int + Point string +} + +// ParsePoint parses sunspec point from string +func ParsePoint(selector string) ([]SunSpecOperation, error) { + el := strings.Split(selector, ":") + if len(el) < 2 || len(el) > 3 { + return nil, fmt.Errorf("invalid sunspec format: %s", selector) + } + + models := strings.Split(el[0], "|") + if len(models) == 0 { + return nil, fmt.Errorf("missing sunspec model: %s", selector) + } + + var res []SunSpecOperation + for _, m := range models { + model, err := strconv.Atoi(m) + if err != nil { + return nil, fmt.Errorf("invalid sunspec model: %s", selector) + } + + var block int + if len(el) == 3 { + // block is the middle element + block, err = strconv.Atoi(el[1]) + if err != nil { + return nil, fmt.Errorf("invalid sunspec block: %s", selector) + } + } + + res = append(res, SunSpecOperation{ + Model: model, + Block: block, + Point: el[len(el)-1], + }) + } + + return res, nil +}