Huawei: clamp battery charge/discharge power to inverter maximum (#30776)
This commit is contained in:
parent
3aac67bdd0
commit
eadcd31257
3 changed files with 225 additions and 18 deletions
99
plugin/cached.go
Normal file
99
plugin/cached.go
Normal 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
64
plugin/cached_test.go
Normal 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)
|
||||
}
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue