Modbus plugin: split modbus and sunspec (BC) (#11663)

This commit is contained in:
andig 2024-01-28 16:30:11 +01:00 • committed by GitHub
parent 2ea818a977
commit 9517277157
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 673 additions and 450 deletions

View file

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

View file

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

View file

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

24
provider/modbus_helper.go Normal file
View file

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

242
provider/modbus_sunspec.go Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -63,7 +63,7 @@ render: |
power: Power
energy: Export
{{- if eq .usage "battery" }}
soc: ChargeState
soc: 124:ChaState
{{- if .capacity }}
capacity: {{ .capacity }} # kWh
{{- end }}

37
util/modbus/mbmd.go Normal file
View file

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

View file

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

View file

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

180
util/modbus/register.go Normal file
View file

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

51
util/modbus/sunspec.go Normal file
View file

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