diff --git a/meter/powerwall.go b/meter/powerwall.go index aba9f78e7..6a20d26a4 100644 --- a/meter/powerwall.go +++ b/meter/powerwall.go @@ -25,23 +25,18 @@ type PowerWall struct { type powerWallConfig struct { URI, Usage, User, Password string Cache time.Duration - SiteId int64 // Fleet API only, deprecated for the local meter - RefreshToken_ string `mapstructure:"refreshToken"` // TODO deprecated - batterySocLimits `mapstructure:",squash"` - batteryPowerLimits `mapstructure:",squash"` + SiteId int64 // Fleet API only, deprecated for the local meter + RefreshToken_ string `mapstructure:"refreshToken"` // TODO deprecated + MinSoc float64 // Fleet API only, backup reserve restored in normal mode + MaxSoc_ any `mapstructure:"maxsoc"` // TODO deprecated + MaxChargePower_ any `mapstructure:"maxchargepower"` // TODO deprecated + MaxDischargePower_ any `mapstructure:"maxdischargepower"` // TODO deprecated } func defaultPowerWallConfig() powerWallConfig { return powerWallConfig{ - batterySocLimits: batterySocLimits{ - MinSoc: 20, - MaxSoc: 95, - }, - batteryPowerLimits: batteryPowerLimits{ - MaxChargePower: 4600, - MaxDischargePower: 4600, - }, - Cache: time.Second, + Cache: time.Second, + MinSoc: 20, } } @@ -114,18 +109,36 @@ func newPowerWall(log *util.Logger, cc powerWallConfig) (*PowerWall, error) { } if m.usage == "battery" { - implement.Has(m, implement.Battery(m.batterySoc)) - implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator())) - implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator())) + opG := util.Cached(client.GetOperation, cc.Cache) - res, err := m.client.GetSystemStatus() + // capacity and power limits are static, reading them validates connectivity + status, err := client.GetSystemStatus() if err != nil { return nil, err } + implement.Has(m, implement.Battery(m.batterySoc)) + implement.Has(m, implement.BatteryCapacity(func() float64 { - return res.NominalFullPackEnergy / 1e3 + return status.NominalFullPackEnergy / 1e3 })) + + // backup reserve is the lower discharge limit, the powerwall has no upper soc limit + implement.Has(m, implement.BatterySocLimiter(func() (float64, float64) { + op, err := opG() + if err != nil { + log.ERROR.Println("battery soc limits:", err) + return 0, 100 + } + return op.BackupReservePercent, 100 + })) + + if status.MaxApparentPower > 0 { + // inverter apparent power applies to charging and discharging alike + implement.Has(m, implement.BatteryPowerLimiter(func() (float64, float64) { + return status.MaxApparentPower, status.MaxApparentPower + })) + } } return m, nil diff --git a/meter/powerwall_fleet.go b/meter/powerwall_fleet.go index 2174e40b2..b21007c5c 100644 --- a/meter/powerwall_fleet.go +++ b/meter/powerwall_fleet.go @@ -55,7 +55,10 @@ func NewPowerWallFleetFromConfig(other map[string]any) (api.Meter, error) { return nil, err } - implement.May(m, implement.BatteryController(cc.batterySocLimits.LimitController(func() (float64, error) { + // charge mode raises the reserve to full, normal mode restores the configured minimum + limits := batterySocLimits{MinSoc: cc.MinSoc, MaxSoc: 100} + + implement.Has(m, implement.BatteryController(limits.LimitController(func() (float64, error) { ess, err := energySite.EnergySiteStatus() if err != nil { return 0, fmt.Errorf("get energy site status: %w", err) diff --git a/meter/powerwall_test.go b/meter/powerwall_test.go index a2e8516f8..74d9aeaf8 100644 --- a/meter/powerwall_test.go +++ b/meter/powerwall_test.go @@ -70,9 +70,38 @@ func TestPowerWallConfigDeprecatedParams(t *testing.T) { decodePowerWallConfig(t, map[string]any{ "usage": "battery", "password": "secret", "refreshToken": "token", "siteId": 123, + "maxsoc": 95, "maxchargepower": 4600, "maxdischargepower": 4600, }) } +// battery control must fall back to the default reserve, not to zero +func TestPowerWallFleetReserve(t *testing.T) { + cc := decodePowerWallConfig(t, map[string]any{"usage": "battery", "password": "secret"}) + limits := batterySocLimits{MinSoc: cc.MinSoc, MaxSoc: 100} + + var reserve float64 + ctrl := limits.LimitController( + func() (float64, error) { return 50, nil }, + func(limit float64) error { reserve = limit; return nil }, + ) + + tests := []struct { + mode api.BatteryMode + want float64 + }{ + {mode: api.BatteryNormal, want: 20}, + {mode: api.BatteryHold, want: 50}, + {mode: api.BatteryCharge, want: 100}, + } + + for _, tc := range tests { + t.Run(tc.mode.String(), func(t *testing.T) { + require.NoError(t, ctrl(tc.mode)) + assert.Equal(t, tc.want, reserve) + }) + } +} + func TestNewPowerWallFleetFromConfigValidation(t *testing.T) { tests := []struct { name string diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml index 8a9cc4edf..92f806fe5 100644 --- a/templates/definition/meter/tesla-powerwall-fleet.yaml +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -47,11 +47,17 @@ params: en: optional product identifier of the energy site, use to override autodetection de: optionale Product ID dieser Energy Site, zum Übersteuern der automatischen Erkennung - name: minsoc + default: 20 advanced: true + help: + de: Backup-Reserve, die evcc im Normalbetrieb wiederherstellt + en: Backup reserve evcc restores in normal operation - name: maxsoc - advanced: true + deprecated: true - name: maxchargepower + deprecated: true - name: maxdischargepower + deprecated: true render: | type: powerwall-fleet uri: {{ .host }} @@ -65,6 +71,3 @@ render: | refresh: {{ .refreshToken }} siteId: {{ .siteId }} minsoc: {{ .minsoc }} - maxsoc: {{ .maxsoc }} - maxchargepower: {{ .maxchargepower }} - maxdischargepower: {{ .maxdischargepower }} diff --git a/templates/definition/meter/tesla-powerwall.yaml b/templates/definition/meter/tesla-powerwall.yaml index 99f72dc2c..0b909d2f4 100644 --- a/templates/definition/meter/tesla-powerwall.yaml +++ b/templates/definition/meter/tesla-powerwall.yaml @@ -17,18 +17,16 @@ params: - name: siteId deprecated: true - name: minsoc - advanced: true + deprecated: true - name: maxsoc - advanced: true + deprecated: true - name: maxchargepower + deprecated: true - name: maxdischargepower + deprecated: true render: | type: powerwall uri: {{ .host }} usage: {{ .usage }} user: customer password: {{ .password }} # for user 'customer' - minsoc: {{ .minsoc }} - maxsoc: {{ .maxsoc }} - maxchargepower: {{ .maxchargepower }} - maxdischargepower: {{ .maxdischargepower }}