From eadcd312574d587b3d6ff410ffffb4e8838d38af Mon Sep 17 00:00:00 2001 From: andig Date: Sat, 13 Jun 2026 17:56:40 +0200 Subject: [PATCH] Huawei: clamp battery charge/discharge power to inverter maximum (#30776) --- plugin/cached.go | 99 +++++++++++++++++++ plugin/cached_test.go | 64 ++++++++++++ .../meter/huawei-sun2000-hybrid.yaml | 80 +++++++++++---- 3 files changed, 225 insertions(+), 18 deletions(-) create mode 100644 plugin/cached.go create mode 100644 plugin/cached_test.go diff --git a/plugin/cached.go b/plugin/cached.go new file mode 100644 index 000000000..94a37aaad --- /dev/null +++ b/plugin/cached.go @@ -0,0 +1,99 @@ +package plugin + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + + "github.com/benbjohnson/clock" + "github.com/evcc-io/evcc/util" +) + +func init() { + registry.AddCtx("cached", NewCachedFromConfig) +} + +// cachedPlugin caches the wrapped reading for the configured duration +type cachedPlugin struct { + ctx context.Context + clock clock.Clock + cache time.Duration + value Config +} + +// NewCachedFromConfig creates cached provider +func NewCachedFromConfig(ctx context.Context, other map[string]any) (Plugin, error) { + var cc struct { + Cache time.Duration + Value Config + } + + if err := util.DecodeOther(other, &cc); err != nil { + return nil, err + } + + if cc.Cache <= 0 { + return nil, errors.New("cache duration is required") + } + + return &cachedPlugin{ + ctx: ctx, + clock: clock.New(), + cache: cc.Cache, + value: cc.Value, + }, nil +} + +func cachedGetter[T any](o *cachedPlugin, valuer func(ctx context.Context) (func() (T, error), error)) (func() (T, error), error) { + value, err := valuer(o.ctx) + if err != nil { + return nil, fmt.Errorf("cached: %w", err) + } + + var mu sync.Mutex + var updated time.Time + var val T + + return func() (T, error) { + mu.Lock() + defer mu.Unlock() + + // refresh on first call and once the cache has expired; failures are not cached + if updated.IsZero() || o.clock.Since(updated) > o.cache { + v, err := value() + if err != nil { + return v, err + } + val = v + updated = o.clock.Now() + } + + return val, nil + }, nil +} + +var _ StringGetter = (*cachedPlugin)(nil) + +func (o *cachedPlugin) StringGetter() (func() (string, error), error) { + return cachedGetter(o, o.value.StringGetter) +} + +var _ FloatGetter = (*cachedPlugin)(nil) + +func (o *cachedPlugin) FloatGetter() (func() (float64, error), error) { + return cachedGetter(o, o.value.FloatGetter) +} + +var _ IntGetter = (*cachedPlugin)(nil) + +func (o *cachedPlugin) IntGetter() (func() (int64, error), error) { + return cachedGetter(o, o.value.IntGetter) +} + +var _ BoolGetter = (*cachedPlugin)(nil) + +func (o *cachedPlugin) BoolGetter() (func() (bool, error), error) { + return cachedGetter(o, o.value.BoolGetter) +} diff --git a/plugin/cached_test.go b/plugin/cached_test.go new file mode 100644 index 000000000..0e27859c0 --- /dev/null +++ b/plugin/cached_test.go @@ -0,0 +1,64 @@ +package plugin + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/benbjohnson/clock" + "github.com/stretchr/testify/require" +) + +func TestCachedHitAndExpiry(t *testing.T) { + c := clock.NewMock() + o := &cachedPlugin{ctx: context.Background(), clock: c, cache: time.Hour} + + var calls int + get, err := cachedGetter(o, func(context.Context) (func() (int64, error), error) { + return func() (int64, error) { + calls++ + return int64(calls), nil + }, nil + }) + require.NoError(t, err) + + // first call reads, subsequent calls within the window are served from cache + v, err := get() + require.NoError(t, err) + require.EqualValues(t, 1, v) + + c.Add(59 * time.Minute) + v, err = get() + require.NoError(t, err) + require.EqualValues(t, 1, v) + require.Equal(t, 1, calls) + + // once the cache expires the next call refreshes + c.Add(2 * time.Minute) + v, err = get() + require.NoError(t, err) + require.EqualValues(t, 2, v) + require.Equal(t, 2, calls) +} + +func TestCachedDoesNotCacheErrors(t *testing.T) { + c := clock.NewMock() + o := &cachedPlugin{ctx: context.Background(), clock: c, cache: time.Hour} + + var calls int + get, err := cachedGetter(o, func(context.Context) (func() (int64, error), error) { + return func() (int64, error) { + calls++ + return 0, errors.New("boom") + }, nil + }) + require.NoError(t, err) + + // failures must not be cached, so every call retries the source + for range 3 { + _, err := get() + require.Error(t, err) + } + require.Equal(t, 3, calls) +} diff --git a/templates/definition/meter/huawei-sun2000-hybrid.yaml b/templates/definition/meter/huawei-sun2000-hybrid.yaml index 16cf1db94..0910b3055 100644 --- a/templates/definition/meter/huawei-sun2000-hybrid.yaml +++ b/templates/definition/meter/huawei-sun2000-hybrid.yaml @@ -313,15 +313,37 @@ render: | address: 47100 # Forcible charge/discharge type: writesingle encoding: uint16 - - source: const - value: {{ .maxdischargepower }} - set: - source: modbus - {{- include "modbus" . | indent 16 }} - register: - address: 47077 # Max. Discharge Power - type: writemultiple - encoding: uint32 + # register 47077 is range-checked: a maxdischargepower above the + # inverter rated max (37048) is rejected, so clamp before writing + - source: go + in: + - name: rated + type: int + config: + source: cached + cache: 1h # rated max is static + value: + source: modbus + {{- include "modbus" . | indent 22 }} + register: + address: 37048 # [ESS] Maximum discharge power (RO) + type: holding + decode: uint32 + out: + - name: power + type: int + config: + source: modbus + {{- include "modbus" . | indent 20 }} + register: + address: 47077 # Max. Discharge Power + type: writemultiple + encoding: uint32 + script: | + limit := {{ .maxdischargepower }} + out := rated + if limit < rated { out = limit } + out {{- if eq .forceaccharging "false" }} - source: const value: 0 # Disable @@ -379,15 +401,37 @@ render: | address: 47087 # Charge from grid type: writesingle encoding: uint16 - - source: const - value: {{ .maxchargepower }} # W - set: - source: modbus - {{- include "modbus" . | indent 16 }} - register: - address: 47247 # Forcible charge power - type: writemultiple - encoding: uint32 + # clamp maxchargepower to the inverter rated max (37046) for parity + # with discharge, avoiding a stale out-of-range value + - source: go + in: + - name: rated + type: int + config: + source: cached + cache: 1h # rated max is static + value: + source: modbus + {{- include "modbus" . | indent 22 }} + register: + address: 37046 # [ESS] Maximum charge power (RO) + type: holding + decode: uint32 + out: + - name: power + type: int + config: + source: modbus + {{- include "modbus" . | indent 20 }} + register: + address: 47247 # Forcible charge power + type: writemultiple + encoding: uint32 + script: | + limit := {{ .maxchargepower }} + out := rated + if limit < rated { out = limit } + out - source: const value: 1 # Minute set: