Powerwall: add Fleet API support (BC) (#32410)
This commit is contained in:
parent
28e41bdd09
commit
fdea008b0b
21 changed files with 548 additions and 200 deletions
|
|
@ -1,48 +1,38 @@
|
|||
package meter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/andig/go-powerwall"
|
||||
"github.com/bogosj/tesla"
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/api/implement"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/util/request"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
// PowerWall is the tesla powerwall meter
|
||||
type PowerWall struct {
|
||||
implement.Caps
|
||||
usage string
|
||||
client *powerwall.Client
|
||||
meterG func() (map[string]powerwall.MeterAggregatesData, error)
|
||||
energySite *tesla.EnergySite
|
||||
usage string
|
||||
client *powerwall.Client
|
||||
meterG func() (map[string]powerwall.MeterAggregatesData, error)
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Add("tesla", NewPowerWallFromConfig)
|
||||
registry.Add("powerwall", NewPowerWallFromConfig)
|
||||
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"`
|
||||
}
|
||||
|
||||
// NewPowerWallFromConfig creates a PowerWall Powerwall Meter from generic config
|
||||
func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
||||
cc := struct {
|
||||
URI, Usage, User, Password string
|
||||
Cache time.Duration
|
||||
RefreshToken string
|
||||
SiteId int64
|
||||
batterySocLimits `mapstructure:",squash"`
|
||||
batteryPowerLimits `mapstructure:",squash"`
|
||||
}{
|
||||
func defaultPowerWallConfig() powerWallConfig {
|
||||
return powerWallConfig{
|
||||
batterySocLimits: batterySocLimits{
|
||||
MinSoc: 20,
|
||||
MaxSoc: 95,
|
||||
|
|
@ -53,17 +43,16 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
|||
},
|
||||
Cache: time.Second,
|
||||
}
|
||||
}
|
||||
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// validate checks required parameters and maps legacy usage names
|
||||
func (cc *powerWallConfig) validate() error {
|
||||
if cc.Usage == "" {
|
||||
return nil, errors.New("missing usage")
|
||||
return errors.New("missing usage")
|
||||
}
|
||||
|
||||
if cc.Password == "" {
|
||||
return nil, errors.New("missing password")
|
||||
return errors.New("missing password")
|
||||
}
|
||||
|
||||
// support default meter names
|
||||
|
|
@ -74,19 +63,41 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
|||
cc.Usage = "solar"
|
||||
}
|
||||
|
||||
return NewPowerWall(cc.URI, cc.Usage, cc.User, cc.Password, cc.Cache, cc.RefreshToken, cc.SiteId, cc.batterySocLimits, cc.batteryPowerLimits)
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewPowerWall creates a Tesla PowerWall Meter
|
||||
func NewPowerWall(uri, usage, user, password string, cache time.Duration, refreshToken string, siteId int64, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits) (api.Meter, error) {
|
||||
log := util.NewLogger("powerwall").Redact(user, password, refreshToken)
|
||||
func init() {
|
||||
registry.Add("tesla", NewPowerWallFromConfig)
|
||||
registry.Add("powerwall", NewPowerWallFromConfig)
|
||||
}
|
||||
|
||||
// NewPowerWallFromConfig creates a PowerWall Powerwall Meter from generic config
|
||||
func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
||||
cc := defaultPowerWallConfig()
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cc.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("powerwall").Redact(cc.User, cc.Password)
|
||||
|
||||
if cc.RefreshToken_ != "" {
|
||||
log.WARN.Println("refreshToken is deprecated, use the Powerwall (Fleet API) template for battery control")
|
||||
}
|
||||
|
||||
return newPowerWall(log, cc)
|
||||
}
|
||||
|
||||
func newPowerWall(log *util.Logger, cc powerWallConfig) (*PowerWall, error) {
|
||||
httpClient := &http.Client{
|
||||
Transport: request.NewTripper(log, powerwall.DefaultTransport()),
|
||||
Timeout: time.Second * 2, // Timeout after 2 seconds
|
||||
}
|
||||
|
||||
client := powerwall.NewClient(uri, user, password, powerwall.WithHttpClient(httpClient))
|
||||
client := powerwall.NewClient(cc.URI, cc.User, cc.Password, powerwall.WithHttpClient(httpClient))
|
||||
if _, err := client.GetStatus(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -94,62 +105,18 @@ func NewPowerWall(uri, usage, user, password string, cache time.Duration, refres
|
|||
m := &PowerWall{
|
||||
Caps: implement.New(),
|
||||
client: client,
|
||||
usage: strings.ToLower(usage),
|
||||
meterG: util.Cached(client.GetMetersAggregates, cache),
|
||||
}
|
||||
|
||||
var batteryControl bool
|
||||
if refreshToken != "" || siteId != 0 {
|
||||
if refreshToken == "" {
|
||||
return nil, errors.New("missing refresh token")
|
||||
}
|
||||
batteryControl = true
|
||||
}
|
||||
|
||||
if batteryControl {
|
||||
ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
|
||||
|
||||
options := []tesla.ClientOption{tesla.WithToken(&oauth2.Token{
|
||||
RefreshToken: refreshToken,
|
||||
Expiry: time.Now(),
|
||||
})}
|
||||
|
||||
cloudClient, err := tesla.NewClient(ctx, options...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if siteId == 0 {
|
||||
// auto detect energy site ID, picking first
|
||||
products, err := cloudClient.Products()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, p := range products {
|
||||
if p.EnergySiteId != 0 {
|
||||
siteId = p.EnergySiteId
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log.Redact(strconv.FormatInt(siteId, 10))
|
||||
energySite, err := cloudClient.EnergySite(siteId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m.energySite = energySite
|
||||
usage: strings.ToLower(cc.Usage),
|
||||
meterG: util.Cached(client.GetMetersAggregates, cc.Cache),
|
||||
}
|
||||
|
||||
if m.usage == "load" || m.usage == "solar" {
|
||||
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
|
||||
}
|
||||
|
||||
if usage == "battery" {
|
||||
if m.usage == "battery" {
|
||||
implement.Has(m, implement.Battery(m.batterySoc))
|
||||
implement.May(m, implement.BatterySocLimiter(batterySocLimits.Decorator()))
|
||||
implement.May(m, implement.BatteryPowerLimiter(batteryPowerLimits.Decorator()))
|
||||
implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator()))
|
||||
implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator()))
|
||||
|
||||
res, err := m.client.GetSystemStatus()
|
||||
if err != nil {
|
||||
|
|
@ -161,19 +128,6 @@ func NewPowerWall(uri, usage, user, password string, cache time.Duration, refres
|
|||
}))
|
||||
}
|
||||
|
||||
if batteryControl {
|
||||
implement.May(m, implement.BatteryController(batterySocLimits.LimitController(m.socG, func(limit float64) error {
|
||||
// Handle Tesla firmware 25.18.4 restrictions:
|
||||
// Values between 81-99% are not allowed, only ≤80% or exactly 100%
|
||||
limitUint := uint64(limit)
|
||||
if limitUint > 80 && limitUint < 100 {
|
||||
// Adjust to maximum allowed (80%)
|
||||
limitUint = 80
|
||||
}
|
||||
return m.energySite.SetBatteryReserve(limitUint)
|
||||
})))
|
||||
}
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
|
|
@ -221,15 +175,3 @@ func (m *PowerWall) batterySoc() (float64, error) {
|
|||
|
||||
return res.Percentage, err
|
||||
}
|
||||
|
||||
// decorate soc
|
||||
func (m *PowerWall) socG() (float64, error) {
|
||||
ess, err := m.energySite.EnergySiteStatus()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Fix for Tesla firmware 25.18.4: Remove the problematic +0.5 rounding logic
|
||||
// that was interfering with exact 100% reserve settings. Simply return the
|
||||
// actual current SOC rounded to nearest integer.
|
||||
return math.Round(ess.PercentageCharged), nil
|
||||
}
|
||||
|
|
|
|||
114
meter/powerwall_fleet.go
Normal file
114
meter/powerwall_fleet.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package meter
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/api/implement"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/evcc-io/evcc/vehicle/tesla"
|
||||
teslaclient "github.com/evcc-io/tesla-proxy-client"
|
||||
)
|
||||
|
||||
type fleetConfig struct {
|
||||
powerWallConfig `mapstructure:",squash"`
|
||||
tesla.FleetConfig `mapstructure:",squash"`
|
||||
}
|
||||
|
||||
func init() {
|
||||
registry.Add("powerwall-fleet", NewPowerWallFleetFromConfig)
|
||||
}
|
||||
|
||||
// NewPowerWallFleetFromConfig creates a PowerWall meter with Fleet API battery control
|
||||
func NewPowerWallFleetFromConfig(other map[string]any) (api.Meter, error) {
|
||||
cc := fleetConfig{powerWallConfig: defaultPowerWallConfig()}
|
||||
if err := util.DecodeOther(other, &cc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cc.validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cc.FleetConfig.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
log := util.NewLogger("powerwall").Redact(
|
||||
cc.User,
|
||||
cc.Password,
|
||||
cc.Credentials.ID,
|
||||
cc.Credentials.Secret,
|
||||
cc.Tokens.Access,
|
||||
cc.Tokens.Refresh,
|
||||
)
|
||||
m, err := newPowerWall(log, cc.powerWallConfig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
energySite, err := teslaEnergySite(log, cc.FleetConfig, cc.SiteId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
implement.May(m, implement.BatteryController(cc.batterySocLimits.LimitController(func() (float64, error) {
|
||||
ess, err := energySite.EnergySiteStatus()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("get energy site status: %w", err)
|
||||
}
|
||||
|
||||
return math.Round(ess.PercentageCharged), nil
|
||||
}, func(limit float64) error {
|
||||
return energySite.SetBatteryReserve(teslaReserveLimit(limit))
|
||||
})))
|
||||
|
||||
return m, nil
|
||||
}
|
||||
|
||||
// teslaReserveLimit clamps the reserve to values accepted by Tesla firmware: up to 80 or exactly 100
|
||||
func teslaReserveLimit(limit float64) uint64 {
|
||||
switch {
|
||||
case limit >= 100:
|
||||
return 100
|
||||
case limit > 80:
|
||||
return 80
|
||||
case limit > 0:
|
||||
return uint64(limit)
|
||||
default: // negative or NaN
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func teslaEnergySite(log *util.Logger, config tesla.FleetConfig, siteId int64) (*teslaclient.EnergySite, error) {
|
||||
fleet, err := config.Client(log)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tc := fleet.Client
|
||||
|
||||
if siteId == 0 {
|
||||
products, err := tc.Products()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover energy sites: %w", err)
|
||||
}
|
||||
|
||||
for _, p := range products {
|
||||
if p.EnergySiteId != 0 {
|
||||
siteId = p.EnergySiteId
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if siteId == 0 {
|
||||
return nil, errors.New("no energy site found")
|
||||
}
|
||||
}
|
||||
|
||||
log.Redact(strconv.FormatInt(siteId, 10))
|
||||
|
||||
return tc.EnergySite(siteId)
|
||||
}
|
||||
136
meter/powerwall_test.go
Normal file
136
meter/powerwall_test.go
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
package meter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/evcc-io/evcc/api"
|
||||
"github.com/evcc-io/evcc/util"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewPowerWallFromConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "missing usage",
|
||||
config: map[string]any{"password": "secret"},
|
||||
want: "missing usage",
|
||||
},
|
||||
{
|
||||
name: "missing password",
|
||||
config: map[string]any{"usage": "battery"},
|
||||
want: "missing password",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewPowerWallFromConfig(tc.config)
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// decodePowerWallConfig mirrors the decoding done by the meter constructors
|
||||
func decodePowerWallConfig(t *testing.T, other map[string]any) powerWallConfig {
|
||||
t.Helper()
|
||||
|
||||
cc := defaultPowerWallConfig()
|
||||
require.NoError(t, util.DecodeOther(other, &cc))
|
||||
require.NoError(t, cc.validate())
|
||||
|
||||
return cc
|
||||
}
|
||||
|
||||
func TestPowerWallConfigLegacyUsage(t *testing.T) {
|
||||
tests := []struct {
|
||||
usage string
|
||||
want string
|
||||
}{
|
||||
{usage: "grid", want: "site"},
|
||||
{usage: "pv", want: "solar"},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.usage, func(t *testing.T) {
|
||||
cc := decodePowerWallConfig(t, map[string]any{
|
||||
"usage": tc.usage,
|
||||
"password": "secret",
|
||||
})
|
||||
assert.Equal(t, tc.want, cc.Usage)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPowerWallConfigDeprecatedParams(t *testing.T) {
|
||||
decodePowerWallConfig(t, map[string]any{
|
||||
"usage": "battery", "password": "secret",
|
||||
"refreshToken": "token", "siteId": 123,
|
||||
})
|
||||
}
|
||||
|
||||
func TestNewPowerWallFleetFromConfigValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config map[string]any
|
||||
want string
|
||||
wantSentinel error
|
||||
}{
|
||||
{
|
||||
name: "missing client id",
|
||||
config: map[string]any{
|
||||
"usage": "battery",
|
||||
"password": "secret",
|
||||
},
|
||||
want: "missing client id",
|
||||
},
|
||||
{
|
||||
name: "missing tokens",
|
||||
config: map[string]any{
|
||||
"usage": "battery",
|
||||
"password": "secret",
|
||||
"credentials": map[string]any{
|
||||
"id": "client",
|
||||
},
|
||||
},
|
||||
wantSentinel: api.ErrMissingToken,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := NewPowerWallFleetFromConfig(tc.config)
|
||||
if tc.wantSentinel != nil {
|
||||
assert.ErrorIs(t, err, tc.wantSentinel)
|
||||
return
|
||||
}
|
||||
assert.ErrorContains(t, err, tc.want)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTeslaReserveLimit(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
limit float64
|
||||
want uint64
|
||||
}{
|
||||
{name: "negative", limit: -1, want: 0},
|
||||
{name: "below cap", limit: 79.9, want: 79},
|
||||
{name: "at cap", limit: 80, want: 80},
|
||||
{name: "unsupported range start", limit: 81, want: 80},
|
||||
{name: "unsupported range end", limit: 99.9, want: 80},
|
||||
{name: "full reserve", limit: 100, want: 100},
|
||||
{name: "above full reserve", limit: 101, want: 100},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
assert.Equal(t, tc.want, teslaReserveLimit(tc.limit))
|
||||
})
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue