evcc-io/meter/powerwall.go

235 lines
5.8 KiB
Go

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
}
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 := struct {
URI, Usage, User, Password string
Cache time.Duration
RefreshToken string
SiteId int64
batterySocLimits `mapstructure:",squash"`
batteryPowerLimits `mapstructure:",squash"`
}{
batterySocLimits: batterySocLimits{
MinSoc: 20,
MaxSoc: 95,
},
batteryPowerLimits: batteryPowerLimits{
MaxChargePower: 4600,
MaxDischargePower: 4600,
},
Cache: time.Second,
}
if err := util.DecodeOther(other, &cc); err != nil {
return nil, err
}
if cc.Usage == "" {
return nil, errors.New("missing usage")
}
if cc.Password == "" {
return nil, errors.New("missing password")
}
// support default meter names
switch strings.ToLower(cc.Usage) {
case "grid":
cc.Usage = "site"
case "pv":
cc.Usage = "solar"
}
return NewPowerWall(cc.URI, cc.Usage, cc.User, cc.Password, cc.Cache, cc.RefreshToken, cc.SiteId, cc.batterySocLimits, cc.batteryPowerLimits)
}
// 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)
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))
if _, err := client.GetStatus(); err != nil {
return nil, err
}
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
}
if m.usage == "load" || m.usage == "solar" {
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
}
if usage == "battery" {
implement.Has(m, implement.Battery(m.batterySoc))
implement.May(m, implement.BatterySocLimiter(batterySocLimits.Decorator()))
implement.May(m, implement.BatteryPowerLimiter(batteryPowerLimits.Decorator()))
res, err := m.client.GetSystemStatus()
if err != nil {
return nil, err
}
implement.Has(m, implement.BatteryCapacity(func() float64 {
return res.NominalFullPackEnergy / 1e3
}))
}
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
}
var _ api.Meter = (*PowerWall)(nil)
// CurrentPower implements the api.Meter interface
func (m *PowerWall) CurrentPower() (float64, error) {
res, err := m.meterG()
if err != nil {
return 0, err
}
if o, ok := res[m.usage]; ok {
return float64(o.InstantPower), nil
}
return 0, fmt.Errorf("invalid usage: %s", m.usage)
}
// totalEnergy implements the api.MeterEnergy interface
func (m *PowerWall) totalEnergy() (float64, error) {
res, err := m.meterG()
if err != nil {
return 0, err
}
if o, ok := res[m.usage]; ok {
switch m.usage {
case "load":
return float64(o.EnergyImported) / 1e3, nil
case "solar":
return float64(o.EnergyExported) / 1e3, nil
}
}
return 0, fmt.Errorf("invalid usage: %s", m.usage)
}
// batterySoc implements the api.Battery interface
func (m *PowerWall) batterySoc() (float64, error) {
res, err := m.client.GetSOE()
if err != nil {
return 0, err
}
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
}