Huawei: clamp battery charge/discharge power to inverter maximum (#30776)

This commit is contained in:
andig 2026-06-13 17:56:40 +02:00 • committed by GitHub
parent 3aac67bdd0
commit eadcd31257
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 225 additions and 18 deletions

99
plugin/cached.go Normal file
View file

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

64
plugin/cached_test.go Normal file
View file

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

View file

@ -313,15 +313,37 @@ render: |
address: 47100 # Forcible charge/discharge address: 47100 # Forcible charge/discharge
type: writesingle type: writesingle
encoding: uint16 encoding: uint16
- source: const # register 47077 is range-checked: a maxdischargepower above the
value: {{ .maxdischargepower }} # inverter rated max (37048) is rejected, so clamp before writing
set: - source: go
source: modbus in:
{{- include "modbus" . | indent 16 }} - name: rated
register: type: int
address: 47077 # Max. Discharge Power config:
type: writemultiple source: cached
encoding: uint32 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" }} {{- if eq .forceaccharging "false" }}
- source: const - source: const
value: 0 # Disable value: 0 # Disable
@ -379,15 +401,37 @@ render: |
address: 47087 # Charge from grid address: 47087 # Charge from grid
type: writesingle type: writesingle
encoding: uint16 encoding: uint16
- source: const # clamp maxchargepower to the inverter rated max (37046) for parity
value: {{ .maxchargepower }} # W # with discharge, avoiding a stale out-of-range value
set: - source: go
source: modbus in:
{{- include "modbus" . | indent 16 }} - name: rated
register: type: int
address: 47247 # Forcible charge power config:
type: writemultiple source: cached
encoding: uint32 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 - source: const
value: 1 # Minute value: 1 # Minute
set: set: