diff --git a/plugin/sunspec.go b/plugin/sunspec.go index 301cb56e9..a5b64ff4a 100644 --- a/plugin/sunspec.go +++ b/plugin/sunspec.go @@ -22,6 +22,7 @@ type ModbusSunspec struct { device *sunsdev.SunSpec op modbus.SunSpecOperation scale float64 + mask uint64 } func init() { @@ -34,6 +35,7 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug modbus.Settings `mapstructure:",squash"` Value []string Scale float64 + BitMask string Delay time.Duration ConnectDelay time.Duration Timeout time.Duration @@ -45,6 +47,14 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug return nil, err } + var mask uint64 + if cc.BitMask != "" { + var err error + if mask, err = modbus.DecodeMask(cc.BitMask); err != nil { + return nil, err + } + } + modbus.Lock() defer modbus.Unlock() @@ -104,6 +114,7 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug conn: conn, device: device, scale: cc.Scale, + mask: mask, } for _, op := range ops { @@ -116,12 +127,15 @@ func NewModbusSunspecFromConfig(ctx context.Context, other map[string]any) (Plug return nil, fmt.Errorf("sunspec model not found: %v", ops) } +// recoverToError converts a panic into *err, for sunspec point access that panics on type mismatch. +func recoverToError(err *error) { + if r := recover(); r != nil { + *err = fmt.Errorf("panic: %v", r) + } +} + func (m *ModbusSunspec) floatGetter() (f float64, err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic: %v", r) - } - }() + defer recoverToError(&err) res, err := m.device.QueryPoint( m.conn, @@ -157,12 +171,65 @@ func (m *ModbusSunspec) IntGetter() (func() (int64, error), error) { }, 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) +var _ BoolGetter = (*ModbusSunspec)(nil) + +// BoolGetter treats any non-zero raw value as true, ANDing an optional bitmask in first. +// Reads the point directly since FloatGetter's ScaledValue panics on enum/bitfield points. +func (m *ModbusSunspec) BoolGetter() (func() (bool, error), error) { + return func() (res bool, err error) { + defer recoverToError(&err) + + _, point, err := m.blockPoint() + if err != nil { + return false, err } - }() + + val, err := pointInt64(point) + if err != nil { + return false, fmt.Errorf("model %d block %d point %s: %w", m.op.Model, m.op.Block, m.op.Point, err) + } + + return sunspecBool(val, m.mask), nil + }, nil +} + +// pointInt64 reads a sunspec point's raw (unscaled) value as int64. +func pointInt64(point sunspec.Point) (int64, error) { + switch point.Type() { + case typelabel.Bitfield16: + return int64(point.Bitfield16()), nil + case typelabel.Bitfield32: + return int64(point.Bitfield32()), nil + case typelabel.Enum16: + return int64(point.Enum16()), nil + case typelabel.Enum32: + return int64(point.Enum32()), nil + case typelabel.Int16: + return int64(point.Int16()), nil + case typelabel.Int32: + return int64(point.Int32()), nil + case typelabel.Int64: + return point.Int64(), nil + case typelabel.Uint16: + return int64(point.Uint16()), nil + case typelabel.Uint32: + return int64(point.Uint32()), nil + case typelabel.Uint64: + return int64(point.Uint64()), nil + default: + return 0, fmt.Errorf("unsupported type: %s", point.Type()) + } +} + +func sunspecBool(val int64, mask uint64) bool { + if mask != 0 { + return uint64(val)&mask != 0 + } + return val != 0 +} + +func (m *ModbusSunspec) blockPoint() (block sunspec.Block, point sunspec.Point, err error) { + defer recoverToError(&err) block, point, err = m.device.QueryPointAny( m.conn, @@ -189,11 +256,7 @@ func (m *ModbusSunspec) FloatSetter(_ string) (func(float64) error, error) { typ := point.Type() return func(val float64) (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic: %v", r) - } - }() + defer recoverToError(&err) val = val * m.scale switch typ { @@ -219,11 +282,7 @@ func (m *ModbusSunspec) IntSetter(_ string) (func(int64) error, error) { typ := point.Type() return func(val int64) (err error) { - defer func() { - if r := recover(); r != nil { - err = fmt.Errorf("panic: %v", r) - } - }() + defer recoverToError(&err) val = int64(float64(val) * m.scale) diff --git a/plugin/sunspec_test.go b/plugin/sunspec_test.go new file mode 100644 index 000000000..72288d10c --- /dev/null +++ b/plugin/sunspec_test.go @@ -0,0 +1,113 @@ +package plugin + +import ( + "testing" + + sunspec "github.com/andig/gosunspec" + "github.com/andig/gosunspec/memory" + "github.com/andig/gosunspec/models/model704" + "github.com/evcc-io/evcc/util/modbus" + "github.com/stretchr/testify/require" + sunsdev "github.com/volkszaehler/mbmd/meters/sunspec" +) + +func TestSunspecBool(t *testing.T) { + cases := []struct { + res int64 + mask uint64 + want bool + }{ + {0, 0, false}, + {1, 0, true}, + {2, 0, true}, + {0b0100, 0b0010, false}, // masked-out bit + {0b0110, 0b0010, true}, // masked-in bit + } + + for _, c := range cases { + if got := sunspecBool(c.res, c.mask); got != c.want { + t.Errorf("sunspecBool(%v, %v) = %v, want %v", c.res, c.mask, got, c.want) + } + } +} + +// newSunspecTestDevice builds an in-memory SunSpec model 704 (DER AC Controls) +// device; mbmd never touches the modbus.Client argument, so no connection is needed. +func newSunspecTestDevice(t *testing.T) (*sunsdev.SunSpec, sunspec.Block) { + t.Helper() + + slab, err := memory.NewSlabBuilder().AddModel(model704.ModelID).Build() + require.NoError(t, err) + + arr, err := memory.Open(slab) + require.NoError(t, err) + + devices := arr.Collect(sunspec.AllDevices) + require.NotEmpty(t, devices) + + block := devices[0].MustModel(sunspec.ModelId(model704.ModelID)).MustBlock(0) + + dev := sunsdev.NewDevice("test") + require.NoError(t, dev.InitializeWithTree(devices)) + + return dev, block +} + +// TestSunspecBoolGetterEnum exercises BoolGetter against a real enum16 point +// (WMaxLimPctEna, SunSpec 704), the curtailment-enabled flag. +func TestSunspecBoolGetterEnum(t *testing.T) { + dev, block := newSunspecTestDevice(t) + + for _, c := range []struct { + val sunspec.Enum16 + mask uint64 + want bool + }{ + {0, 0, false}, + {1, 0, true}, + {0b11, 0b10, true}, // masked-in bit + {0b11, 0b100, false}, // masked-out bit + } { + block.MustPoint(model704.WMaxLimPctEna).SetEnum16(c.val) + require.NoError(t, block.Write(model704.WMaxLimPctEna)) + + mb := &ModbusSunspec{ + device: dev, + op: modbus.SunSpecOperation{Model: model704.ModelID, Point: model704.WMaxLimPctEna}, + mask: c.mask, + } + + g, err := mb.BoolGetter() + require.NoError(t, err) + + got, err := g() + require.NoError(t, err) + require.Equal(t, c.want, got, "value %v mask %v", c.val, c.mask) + } +} + +// TestSunspecBoolGetterInt exercises BoolGetter against a real scaled int-like +// point (WMaxLimPct, a uint16 with a SunSpec scale factor). +func TestSunspecBoolGetterInt(t *testing.T) { + dev, block := newSunspecTestDevice(t) + + mb := &ModbusSunspec{ + device: dev, + op: modbus.SunSpecOperation{Model: model704.ModelID, Point: model704.WMaxLimPct}, + } + + g, err := mb.BoolGetter() + require.NoError(t, err) + + block.MustPoint(model704.WMaxLimPct).SetUint16(0) + require.NoError(t, block.Write(model704.WMaxLimPct)) + got, err := g() + require.NoError(t, err) + require.False(t, got) + + block.MustPoint(model704.WMaxLimPct).SetUint16(50) + require.NoError(t, block.Write(model704.WMaxLimPct)) + got, err = g() + require.NoError(t, err) + require.True(t, got) +} diff --git a/util/modbus/functions.go b/util/modbus/functions.go index 2c35ef420..867ad8746 100644 --- a/util/modbus/functions.go +++ b/util/modbus/functions.go @@ -17,8 +17,8 @@ func Backoff() *backoff.ExponentialBackOff { return backoff.NewExponentialBackOff(backoff.WithInitialInterval(20*time.Millisecond), backoff.WithMaxElapsedTime(10*time.Second)) } -// decodeMask converts a bit mask in decimal or hex format to uint64 -func decodeMask(mask string) (uint64, error) { +// DecodeMask converts a bit mask in decimal or hex format to uint64 +func DecodeMask(mask string) (uint64, error) { mask = strings.ToLower(mask) if mask == "" { diff --git a/util/modbus/register.go b/util/modbus/register.go index 96e36f055..676fc2f82 100644 --- a/util/modbus/register.go +++ b/util/modbus/register.go @@ -92,7 +92,7 @@ func (r Register) DecodeFunc() (func([]byte) float64, error) { case "uint16nan": return decodeNaN16(asFloat64(encoding.Uint16), 1<<16-1), nil case "bool16": - mask, err := decodeMask(r.BitMask) + mask, err := DecodeMask(r.BitMask) if err != nil { return nil, err }