diff --git a/meter/rct.go b/meter/rct.go index 55a12b577..278ad3706 100644 --- a/meter/rct.go +++ b/meter/rct.go @@ -24,7 +24,37 @@ type RCT struct { conn *rct.Connection // connection with the RCT device usage string // grid, pv, battery externalPower bool // whether to query external power - rSocStrategy *uint8 // remembers overwritten soc strategy value + + mu sync.Mutex + rSocStrategy *uint8 // remembers overwritten soc strategy value +} + +// takeRSocStrategy atomically returns and clears the saved soc strategy, or nil +// if none is saved (e.g. BatteryNormal applied twice in a row, #31471). +func (m *RCT) takeRSocStrategy() *uint8 { + m.mu.Lock() + defer m.mu.Unlock() + + s := m.rSocStrategy + m.rSocStrategy = nil + return s +} + +// setRSocStrategyIfAbsent saves strategy unless a value is already saved. +func (m *RCT) setRSocStrategyIfAbsent(strategy uint8) { + m.mu.Lock() + defer m.mu.Unlock() + + if m.rSocStrategy == nil { + m.rSocStrategy = &strategy + } +} + +// hasRSocStrategy reports whether a soc strategy is currently saved +func (m *RCT) hasRSocStrategy() bool { + m.mu.Lock() + defer m.mu.Unlock() + return m.rSocStrategy != nil } var ( @@ -161,12 +191,12 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL } // read soc strategy to reset afterwards - if m.rSocStrategy == nil { + if !m.hasRSocStrategy() { strategy, err := m.queryUint8(rct.PowerMngSocStrategy) if err != nil { return err } - m.rSocStrategy = &strategy + m.setRSocStrategyIfAbsent(strategy) } } @@ -174,11 +204,11 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL switch mode { case api.BatteryNormal: - eg.Go(func() error { - err := m.conn.Write(rct.PowerMngSocStrategy, []byte{*m.rSocStrategy}) - m.rSocStrategy = nil - return err - }) + if strategy := m.takeRSocStrategy(); strategy != nil { + eg.Go(func() error { + return m.conn.Write(rct.PowerMngSocStrategy, []byte{*strategy}) + }) + } eg.Go(func() error { return m.conn.Write(rct.BatterySoCTargetMin, floatVal(batterySocLimits.MinSoc/100)) @@ -202,7 +232,13 @@ func NewRCT(ctx context.Context, uri, usage string, batterySocLimits batterySocL }) eg.Go(func() error { - return m.conn.Write(rct.BatterySoCTargetMin, floatVal(batterySocLimits.MaxSoc/100)) + // hold at current SoC (not MaxSoc): otherwise the gap between current + // and target SoC lets the inverter charge from the grid (#31280) + soc, err := m.queryFloat(rct.BatterySoC) + if err != nil { + return err + } + return m.conn.Write(rct.BatterySoCTargetMin, floatVal(soc)) }) case api.BatteryCharge: diff --git a/meter/rct_test.go b/meter/rct_test.go new file mode 100644 index 000000000..acc2905d9 --- /dev/null +++ b/meter/rct_test.go @@ -0,0 +1,38 @@ +package meter + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// TestRCTTakeRSocStrategy verifies that a repeated BatteryNormal application +// (no intervening non-normal mode) is a no-op instead of a nil dereference (#31471). +func TestRCTTakeRSocStrategy(t *testing.T) { + m := &RCT{} + + // nothing saved yet + assert.Nil(t, m.takeRSocStrategy()) + + m.setRSocStrategyIfAbsent(4) + strategy := m.takeRSocStrategy() + require.NotNil(t, strategy) + assert.Equal(t, uint8(4), *strategy) + + // second take without an intervening set must not repeat the value or panic + assert.Nil(t, m.takeRSocStrategy()) +} + +// TestRCTSetRSocStrategyIfAbsent verifies the saved value is never overwritten +// while still present, matching the original guard's intent. +func TestRCTSetRSocStrategyIfAbsent(t *testing.T) { + m := &RCT{} + + m.setRSocStrategyIfAbsent(4) + m.setRSocStrategyIfAbsent(7) + + strategy := m.takeRSocStrategy() + require.NotNil(t, strategy) + assert.Equal(t, uint8(4), *strategy) +}