sunspec: add bool getter (#31482)

This commit is contained in:
andig 2026-07-05 12:07:10 +02:00 • committed by GitHub
parent 6cce4b789f
commit c3692046b9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 195 additions and 23 deletions

View file

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

113
plugin/sunspec_test.go Normal file
View file

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