diff --git a/cmd/dumper.go b/cmd/dumper.go index 4fd084071..2cb26ec3b 100644 --- a/cmd/dumper.go +++ b/cmd/dumper.go @@ -163,8 +163,8 @@ func (d *dumper) Dump(name string, v any) { if v, ok := api.Cap[api.BatteryPowerLimiter](v); ok { charge, discharge := v.GetPowerLimits() - fmt.Fprintf(w, "Charge power:\t%.0fW\t\t\n", charge) - fmt.Fprintf(w, "Discharge power:\t%.0fW\t\t\n", discharge) + fmt.Fprintf(w, "Max charge power:\t%.0fW\t\t\n", charge) + fmt.Fprintf(w, "Max discharge power:\t%.0fW\t\t\n", discharge) } if v, ok := api.Cap[api.MaxACPowerGetter](v); ok { diff --git a/meter/meter.go b/meter/meter.go index 8cfc61381..0a21ef4e3 100644 --- a/meter/meter.go +++ b/meter/meter.go @@ -27,23 +27,26 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M pvMaxACPower `mapstructure:",squash"` // battery - batteryCapacity `mapstructure:",squash"` - batterySocLimits `mapstructure:",squash"` - batteryPowerLimits `mapstructure:",squash"` - Soc *plugin.Config // optional - LimitSoc *plugin.Config // optional - BatteryMode *plugin.Config // optional - }{ - batterySocLimits: batterySocLimits{ - MinSoc: 20, - MaxSoc: 95, - }, - } + batteryCapacityCtx `mapstructure:",squash"` + batterySocLimitsCtx `mapstructure:",squash"` + batteryPowerLimitsCtx `mapstructure:",squash"` + Soc *plugin.Config // optional + LimitSoc *plugin.Config // optional + BatteryMode *plugin.Config // optional + }{} if err := util.DecodeOther(other, &cc); err != nil { return nil, err } + // default soc limits (nil-preset avoids mapstructure coercing plugin config into the default's type) + if cc.batterySocLimitsCtx.MinSoc == nil { + cc.batterySocLimitsCtx.MinSoc = 20 + } + if cc.batterySocLimitsCtx.MaxSoc == nil { + cc.batterySocLimitsCtx.MaxSoc = 95 + } + powerG, energyG, returnG, err := cc.Energy.Configure(ctx) if err != nil { return nil, err @@ -68,10 +71,25 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M } if socG != nil { + capacity, err := cc.batteryCapacityCtx.Decorator(ctx) + if err != nil { + return nil, err + } + + socLimiter, err := cc.batterySocLimitsCtx.Decorator(ctx) + if err != nil { + return nil, err + } + + powerLimiter, err := cc.batteryPowerLimitsCtx.Decorator(ctx) + if err != nil { + return nil, err + } + implement.Has(m, implement.Battery(socG)) - implement.May(m, implement.BatteryCapacity(cc.batteryCapacity.Decorator())) - implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator())) - implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator())) + implement.May(m, implement.BatteryCapacity(capacity)) + implement.May(m, implement.BatterySocLimiter(socLimiter)) + implement.May(m, implement.BatteryPowerLimiter(powerLimiter)) switch { case cc.Soc != nil && cc.LimitSoc != nil: @@ -80,7 +98,12 @@ func NewConfigurableFromConfig(ctx context.Context, other map[string]any) (api.M return nil, fmt.Errorf("battery limit soc: %w", err) } - implement.Has(m, implement.BatteryController(cc.batterySocLimits.LimitController(socG, limitSocS))) + limitController, err := cc.batterySocLimitsCtx.LimitController(ctx, socG, limitSocS) + if err != nil { + return nil, err + } + + implement.Has(m, implement.BatteryController(limitController)) case cc.BatteryMode != nil: modeS, err := cc.BatteryMode.IntSetter(ctx, "batteryMode") diff --git a/meter/usage_battery.go b/meter/usage_battery.go index aaadc3872..213ca6428 100644 --- a/meter/usage_battery.go +++ b/meter/usage_battery.go @@ -1,6 +1,12 @@ package meter -import "github.com/evcc-io/evcc/api" +import ( + "context" + + "github.com/evcc-io/evcc/api" + "github.com/evcc-io/evcc/plugin" + "github.com/evcc-io/evcc/util" +) type batteryCapacity struct { Capacity float64 @@ -18,6 +24,64 @@ func (m *batteryCapacity) Decorator() func() float64 { } } +type batteryCapacityCtx struct { + Capacity any // static kWh value or float plugin +} + +// var _ api.BatteryCapacity = (*batteryCapacityCtx)(nil) + +// Decorator returns an api.BatteryCapacity decorator. Capacity may be a static +// number or a float plugin config; nil/zero means not configured. +func (m *batteryCapacityCtx) Decorator(ctx context.Context) (func() float64, error) { + return resolveFloat(ctx, m.Capacity) +} + +// resolveFloat resolves a static number or float plugin config to a getter. +// nil/zero static returns a nil getter (not configured). +func resolveFloat(ctx context.Context, v any) (func() float64, error) { + switch v := v.(type) { + case nil: + return nil, nil + case int: + return staticCapacity(float64(v)), nil + case int64: + return staticCapacity(float64(v)), nil + case float64: + return staticCapacity(v), nil + default: + var cfg plugin.Config + if err := util.DecodeOther(v, &cfg); err != nil { + return nil, err + } + get, err := cfg.FloatGetter(ctx) + if err != nil { + return nil, err + } + return func() float64 { + f, err := get() + if err != nil { + return 0 // ponytail: treat plugin error as unknown value + } + return f + }, nil + } +} + +func staticCapacity(f float64) func() float64 { + if f == 0 { + return nil + } + return func() float64 { return f } +} + +// floatOr0 evaluates g, returning 0 for a nil (unconfigured) getter. +func floatOr0(g func() float64) float64 { + if g == nil { + return 0 + } + return g() +} + type batteryPowerLimits struct { MaxChargePower float64 MaxDischargePower float64 @@ -35,6 +99,32 @@ func (m *batteryPowerLimits) Decorator() func() (float64, float64) { } } +type batteryPowerLimitsCtx struct { + MaxChargePower any // static W value or float plugin + MaxDischargePower any // static W value or float plugin +} + +// var _ api.BatteryPowerLimiter = (*batteryPowerLimitsCtx)(nil) + +// Decorator returns an api.BatteryPowerLimiter decorator. Each limit may be a +// static number or a float plugin config; either unset means not configured. +func (m *batteryPowerLimitsCtx) Decorator(ctx context.Context) (func() (float64, float64), error) { + charge, err := resolveFloat(ctx, m.MaxChargePower) + if err != nil { + return nil, err + } + discharge, err := resolveFloat(ctx, m.MaxDischargePower) + if err != nil { + return nil, err + } + if charge == nil || discharge == nil { + return nil, nil + } + return func() (float64, float64) { + return charge(), discharge() + }, nil +} + type batterySocLimits struct { MinSoc, MaxSoc float64 } @@ -74,3 +164,64 @@ func (m *batterySocLimits) LimitController(socG func() (float64, error), limitSo } } } + +type batterySocLimitsCtx struct { + MinSoc, MaxSoc any // static % value or float plugin +} + +// var _ api.BatterySocLimiter = (*batterySocLimitsCtx)(nil) + +func (m *batterySocLimitsCtx) getters(ctx context.Context) (func() float64, func() float64, error) { + minG, err := resolveFloat(ctx, m.MinSoc) + if err != nil { + return nil, nil, err + } + maxG, err := resolveFloat(ctx, m.MaxSoc) + if err != nil { + return nil, nil, err + } + return minG, maxG, nil +} + +// Decorator returns an api.BatterySocLimiter decorator. Each limit may be a +// static number or a float plugin config; both unset means not configured. +func (m *batterySocLimitsCtx) Decorator(ctx context.Context) (func() (float64, float64), error) { + minG, maxG, err := m.getters(ctx) + if err != nil { + return nil, err + } + if minG == nil && maxG == nil { + return nil, nil + } + return func() (float64, float64) { + return floatOr0(minG), floatOr0(maxG) + }, nil +} + +// LimitController returns an api.BatteryController decorator +func (m *batterySocLimitsCtx) LimitController(ctx context.Context, socG func() (float64, error), limitSocS func(float64) error) (func(api.BatteryMode) error, error) { + minG, maxG, err := m.getters(ctx) + if err != nil { + return nil, err + } + return func(mode api.BatteryMode) error { + switch mode { + case api.BatteryNormal: + return limitSocS(floatOr0(minG)) + + case api.BatteryHold: + soc, err := socG() + if err != nil { + return err + } + return limitSocS(min(100, max(soc, floatOr0(minG)))) + + case api.BatteryCharge: + return limitSocS(floatOr0(maxG)) + + // BatteryHoldCharge not implementable via limit soc + default: + return api.ErrNotAvailable + } + }, nil +} diff --git a/meter/usage_battery_test.go b/meter/usage_battery_test.go index 7590b5afd..932a58ad2 100644 --- a/meter/usage_battery_test.go +++ b/meter/usage_battery_test.go @@ -1,12 +1,59 @@ package meter import ( + "context" "testing" "github.com/evcc-io/evcc/util" "github.com/stretchr/testify/require" ) +func TestBatteryCapacity(t *testing.T) { + ctx := context.TODO() + + // static value + { + var cc batteryCapacityCtx + require.NoError(t, util.DecodeOther(map[string]any{"capacity": 10}, &cc)) + g, err := cc.Decorator(ctx) + require.NoError(t, err) + require.NotNil(t, g) + require.Equal(t, 10.0, g()) + } + + // zero value is treated as not configured + { + var cc batteryCapacityCtx + require.NoError(t, util.DecodeOther(map[string]any{"capacity": 0}, &cc)) + g, err := cc.Decorator(ctx) + require.NoError(t, err) + require.Nil(t, g) + } + + // unset is not configured + { + var cc batteryCapacityCtx + g, err := cc.Decorator(ctx) + require.NoError(t, err) + require.Nil(t, g) + } + + // float plugin + { + var cc batteryCapacityCtx + require.NoError(t, util.DecodeOther(map[string]any{ + "capacity": map[string]any{ + "source": "const", + "value": "12.5", + }, + }, &cc)) + g, err := cc.Decorator(ctx) + require.NoError(t, err) + require.NotNil(t, g) + require.Equal(t, 12.5, g()) + } +} + func TestBatterySocLimits(t *testing.T) { other := map[string]any{ "minsoc": 1, diff --git a/templates/definition/meter/zendure-solarflow-ac.yaml b/templates/definition/meter/zendure-solarflow-ac.yaml index a74a0ed49..ba40da751 100644 --- a/templates/definition/meter/zendure-solarflow-ac.yaml +++ b/templates/definition/meter/zendure-solarflow-ac.yaml @@ -8,7 +8,15 @@ params: - name: usage choice: ["battery"] - name: host - - preset: battery-params + - preset: battery-capacity + - name: minsoc + deprecated: true + - name: maxsoc + deprecated: true + - name: maxchargepower + deprecated: true + - name: maxdischargepower + deprecated: true - name: cache default: 1s render: | @@ -23,4 +31,26 @@ render: | uri: http://{{ .host }}/properties/report jq: .properties.electricLevel cache: {{ .cache }} - {{- include "battery-params" . }} + minsoc: + source: http + uri: http://{{ .host }}/properties/report + jq: .properties.minSoc + scale: 0.1 + cache: {{ .cache }} + maxsoc: + source: http + uri: http://{{ .host }}/properties/report + jq: .properties.socSet + scale: 0.1 + cache: {{ .cache }} + maxchargepower: + source: http + uri: http://{{ .host }}/properties/report + jq: .properties.chargeMaxLimit + cache: {{ .cache }} + maxdischargepower: + source: http + uri: http://{{ .host }}/properties/report + jq: .properties.inverseMaxPower + cache: {{ .cache }} + {{- include "battery-capacity" . }} diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml index 929474887..13971430a 100644 --- a/util/templates/defaults.yaml +++ b/util/templates/defaults.yaml @@ -824,6 +824,17 @@ presets: - name: maxchargepower - name: maxdischargepower + battery-capacity: + - name: capacity + + battery-minmaxsoc: + - name: minsoc + - name: maxsoc + + battery-power: + - name: maxchargepower + - name: maxdischargepower + modbus: interfaces: rs485: ["rs485serial", "rs485tcpip"] diff --git a/util/templates/includes/battery-params.tpl b/util/templates/includes/battery-params.tpl index a7747d1e6..4467af10e 100644 --- a/util/templates/includes/battery-params.tpl +++ b/util/templates/includes/battery-params.tpl @@ -1,7 +1,19 @@ -{{ define "battery-params" }} +{{ define "battery-capacity" }} capacity: {{ .capacity }} # kWh +{{- end }} + +{{ define "battery-minmaxsoc" }} minsoc: {{ .minsoc }} # % maxsoc: {{ .maxsoc }} # % +{{- end }} + +{{ define "battery-power" }} maxchargepower: {{ .maxchargepower }} # W maxdischargepower: {{ .maxdischargepower }} # W {{- end }} + +{{ define "battery-params" }} +{{- include "battery-capacity" . }} +{{- include "battery-minmaxsoc" . }} +{{- include "battery-power" . }} +{{- end }}