evcc-io/meter/powerwall.go
2025-02-12 09:49:31 +01:00

225 lines
5.4 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/util"
"github.com/evcc-io/evcc/util/request"
"golang.org/x/oauth2"
)
// PowerWall is the tesla powerwall meter
type PowerWall struct {
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)
}
//go:generate go tool decorate -f decoratePowerWall -b *PowerWall -r api.Meter -t "api.MeterEnergy,TotalEnergy,func() (float64, error)" -t "api.Battery,Soc,func() (float64, error)" -t "api.BatteryCapacity,Capacity,func() float64" -t "api.BatteryController,SetBatteryMode,func(api.BatteryMode) error"
// NewPowerWallFromConfig creates a PowerWall Powerwall Meter from generic config
func NewPowerWallFromConfig(other map[string]interface{}) (api.Meter, error) {
cc := struct {
URI, Usage, User, Password string
Cache time.Duration
RefreshToken string
SiteId int64
battery `mapstructure:",squash"`
}{
Cache: time.Second,
battery: battery{
MinSoc: 20,
MaxSoc: 95,
},
}
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.battery)
}
// NewPowerWall creates a Tesla PowerWall Meter
func NewPowerWall(uri, usage, user, password string, cache time.Duration, refreshToken string, siteId int64, battery battery) (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{
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
}
// decorate api.MeterEnergy
var totalEnergy func() (float64, error)
if m.usage == "load" || m.usage == "solar" {
totalEnergy = m.totalEnergy
}
// decorate api.BatterySoc
var batterySoc func() (float64, error)
var batteryCapacity func() float64
if usage == "battery" {
batterySoc = m.batterySoc
res, err := m.client.GetSystemStatus()
if err != nil {
return nil, err
}
batteryCapacity = func() float64 {
return res.NominalFullPackEnergy / 1e3
}
}
// decorate api.BatteryController
var batModeS func(api.BatteryMode) error
if batteryControl {
batModeS = battery.LimitController(m.socG, func(limit float64) error {
return m.energySite.SetBatteryReserve(uint64(limit))
})
}
return decoratePowerWall(m, totalEnergy, batterySoc, batteryCapacity, batModeS), 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
}
currentSoc := math.Round(ess.PercentageCharged + 0.5) // .5 ensures we round up
return currentSoc, nil
}