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
|
|
@ -6,16 +6,16 @@ import (
|
||||||
"github.com/AlecAivazis/survey/v2"
|
"github.com/AlecAivazis/survey/v2"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/evcc-io/evcc/util/config"
|
"github.com/evcc-io/evcc/util/config"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/util/request"
|
"github.com/evcc-io/evcc/util/request"
|
||||||
"github.com/evcc-io/evcc/vehicle"
|
|
||||||
"github.com/evcc-io/evcc/vehicle/ford/connect"
|
"github.com/evcc-io/evcc/vehicle/ford/connect"
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
func fordConnectToken(conf config.Named) (*oauth2.Token, error) {
|
func fordConnectToken(conf config.Named) (*oauth2.Token, error) {
|
||||||
var cc struct {
|
var cc struct {
|
||||||
Credentials vehicle.ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
Tokens vehicle.Tokens
|
Tokens oauth.Tokens
|
||||||
Other map[string]any `mapstructure:",remain"`
|
Other map[string]any `mapstructure:",remain"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import (
|
||||||
"github.com/evcc-io/evcc/api/globalconfig"
|
"github.com/evcc-io/evcc/api/globalconfig"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/evcc-io/evcc/util/config"
|
"github.com/evcc-io/evcc/util/config"
|
||||||
"github.com/evcc-io/evcc/vehicle"
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/tronity"
|
"github.com/evcc-io/evcc/vehicle/tronity"
|
||||||
"github.com/samber/lo"
|
"github.com/samber/lo"
|
||||||
"github.com/skratchdot/open-golang/open"
|
"github.com/skratchdot/open-golang/open"
|
||||||
|
|
@ -101,7 +101,7 @@ func tronityAuthorize(addr string, oc *oauth2.Config) (*oauth2.Token, error) {
|
||||||
|
|
||||||
func tronityToken(conf globalconfig.All, vehicleConf config.Named) (*oauth2.Token, error) {
|
func tronityToken(conf globalconfig.All, vehicleConf config.Named) (*oauth2.Token, error) {
|
||||||
var cc struct {
|
var cc struct {
|
||||||
Credentials vehicle.ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
RedirectURI string
|
RedirectURI string
|
||||||
Other map[string]any `mapstructure:",remain"`
|
Other map[string]any `mapstructure:",remain"`
|
||||||
}
|
}
|
||||||
|
|
|
||||||
1
go.mod
1
go.mod
|
|
@ -19,7 +19,6 @@ require (
|
||||||
github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4
|
github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4
|
||||||
github.com/basgys/goxml2json v1.1.0
|
github.com/basgys/goxml2json v1.1.0
|
||||||
github.com/benbjohnson/clock v1.3.5
|
github.com/benbjohnson/clock v1.3.5
|
||||||
github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9
|
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0
|
github.com/cenkalti/backoff/v4 v4.3.0
|
||||||
github.com/cli/browser v1.3.0
|
github.com/cli/browser v1.3.0
|
||||||
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
|
github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
|
||||||
|
|
|
||||||
2
go.sum
2
go.sum
|
|
@ -81,8 +81,6 @@ github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6r
|
||||||
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
github.com/bitly/go-simplejson v0.5.1 h1:xgwPbetQScXt1gh9BmoJ6j9JMr3TElvuIyjR8pgdoow=
|
||||||
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
github.com/bitly/go-simplejson v0.5.1/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q=
|
||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||||
github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9 h1:vAEi0OtVQbyRmnKXz8k350VfyoWYljOrlMDDuU1d/Ug=
|
|
||||||
github.com/bogosj/tesla v1.3.2-0.20250818120641-a31b7b6396c9/go.mod h1:nQrPioMhF8Yf36iCtta01SUbsnGN25VhBYLN4DIMBPU=
|
|
||||||
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
github.com/caarlos0/env/v11 v11.3.1 h1:cArPWC15hWmEt+gWk7YBi7lEXTXCvpaSdCiZE2X5mCA=
|
||||||
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
github.com/caarlos0/env/v11 v11.3.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U=
|
||||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,38 @@
|
||||||
package meter
|
package meter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"math"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/andig/go-powerwall"
|
"github.com/andig/go-powerwall"
|
||||||
"github.com/bogosj/tesla"
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/api/implement"
|
"github.com/evcc-io/evcc/api/implement"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
"github.com/evcc-io/evcc/util/request"
|
"github.com/evcc-io/evcc/util/request"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// PowerWall is the tesla powerwall meter
|
// PowerWall is the tesla powerwall meter
|
||||||
type PowerWall struct {
|
type PowerWall struct {
|
||||||
implement.Caps
|
implement.Caps
|
||||||
usage string
|
usage string
|
||||||
client *powerwall.Client
|
client *powerwall.Client
|
||||||
meterG func() (map[string]powerwall.MeterAggregatesData, error)
|
meterG func() (map[string]powerwall.MeterAggregatesData, error)
|
||||||
energySite *tesla.EnergySite
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func init() {
|
type powerWallConfig struct {
|
||||||
registry.Add("tesla", NewPowerWallFromConfig)
|
URI, Usage, User, Password string
|
||||||
registry.Add("powerwall", NewPowerWallFromConfig)
|
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 defaultPowerWallConfig() powerWallConfig {
|
||||||
func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
return powerWallConfig{
|
||||||
cc := struct {
|
|
||||||
URI, Usage, User, Password string
|
|
||||||
Cache time.Duration
|
|
||||||
RefreshToken string
|
|
||||||
SiteId int64
|
|
||||||
batterySocLimits `mapstructure:",squash"`
|
|
||||||
batteryPowerLimits `mapstructure:",squash"`
|
|
||||||
}{
|
|
||||||
batterySocLimits: batterySocLimits{
|
batterySocLimits: batterySocLimits{
|
||||||
MinSoc: 20,
|
MinSoc: 20,
|
||||||
MaxSoc: 95,
|
MaxSoc: 95,
|
||||||
|
|
@ -53,17 +43,16 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
||||||
},
|
},
|
||||||
Cache: time.Second,
|
Cache: time.Second,
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if err := util.DecodeOther(other, &cc); err != nil {
|
// validate checks required parameters and maps legacy usage names
|
||||||
return nil, err
|
func (cc *powerWallConfig) validate() error {
|
||||||
}
|
|
||||||
|
|
||||||
if cc.Usage == "" {
|
if cc.Usage == "" {
|
||||||
return nil, errors.New("missing usage")
|
return errors.New("missing usage")
|
||||||
}
|
}
|
||||||
|
|
||||||
if cc.Password == "" {
|
if cc.Password == "" {
|
||||||
return nil, errors.New("missing password")
|
return errors.New("missing password")
|
||||||
}
|
}
|
||||||
|
|
||||||
// support default meter names
|
// support default meter names
|
||||||
|
|
@ -74,19 +63,41 @@ func NewPowerWallFromConfig(other map[string]any) (api.Meter, error) {
|
||||||
cc.Usage = "solar"
|
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 init() {
|
||||||
func NewPowerWall(uri, usage, user, password string, cache time.Duration, refreshToken string, siteId int64, batterySocLimits batterySocLimits, batteryPowerLimits batteryPowerLimits) (api.Meter, error) {
|
registry.Add("tesla", NewPowerWallFromConfig)
|
||||||
log := util.NewLogger("powerwall").Redact(user, password, refreshToken)
|
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{
|
httpClient := &http.Client{
|
||||||
Transport: request.NewTripper(log, powerwall.DefaultTransport()),
|
Transport: request.NewTripper(log, powerwall.DefaultTransport()),
|
||||||
Timeout: time.Second * 2, // Timeout after 2 seconds
|
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 {
|
if _, err := client.GetStatus(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
@ -94,62 +105,18 @@ func NewPowerWall(uri, usage, user, password string, cache time.Duration, refres
|
||||||
m := &PowerWall{
|
m := &PowerWall{
|
||||||
Caps: implement.New(),
|
Caps: implement.New(),
|
||||||
client: client,
|
client: client,
|
||||||
usage: strings.ToLower(usage),
|
usage: strings.ToLower(cc.Usage),
|
||||||
meterG: util.Cached(client.GetMetersAggregates, cache),
|
meterG: util.Cached(client.GetMetersAggregates, cc.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" {
|
if m.usage == "load" || m.usage == "solar" {
|
||||||
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
|
implement.Has(m, implement.MeterEnergy(m.totalEnergy))
|
||||||
}
|
}
|
||||||
|
|
||||||
if usage == "battery" {
|
if m.usage == "battery" {
|
||||||
implement.Has(m, implement.Battery(m.batterySoc))
|
implement.Has(m, implement.Battery(m.batterySoc))
|
||||||
implement.May(m, implement.BatterySocLimiter(batterySocLimits.Decorator()))
|
implement.May(m, implement.BatterySocLimiter(cc.batterySocLimits.Decorator()))
|
||||||
implement.May(m, implement.BatteryPowerLimiter(batteryPowerLimits.Decorator()))
|
implement.May(m, implement.BatteryPowerLimiter(cc.batteryPowerLimits.Decorator()))
|
||||||
|
|
||||||
res, err := m.client.GetSystemStatus()
|
res, err := m.client.GetSystemStatus()
|
||||||
if err != nil {
|
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
|
return m, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -221,15 +175,3 @@ func (m *PowerWall) batterySoc() (float64, error) {
|
||||||
|
|
||||||
return res.Percentage, 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
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
70
templates/definition/meter/tesla-powerwall-fleet.yaml
Normal file
70
templates/definition/meter/tesla-powerwall-fleet.yaml
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
template: tesla-powerwall-fleet
|
||||||
|
products:
|
||||||
|
- brand: Tesla
|
||||||
|
description:
|
||||||
|
generic: Powerwall (Fleet API)
|
||||||
|
capabilities: ["battery-control"]
|
||||||
|
requirements:
|
||||||
|
description:
|
||||||
|
de: |
|
||||||
|
Tesla bietet eine offizielle, aber kostenpflichtige Fleet API an.
|
||||||
|
Für private Nutzung kannst du dir einen Tesla Developer Account bei [developer.tesla.com](https://developer.tesla.com/) erstellen und erhältst ein monatliches API-Guthaben von 10 €.
|
||||||
|
|
||||||
|
Die Anleitung von [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) erklärt den Prozess und generiert dir kostenfrei die für evcc benötigten Access- und Refresh-Token.
|
||||||
|
Mit diesem Tokenpaar und deiner im Tesla Developer Account erstellten Client ID kann evcc direkt mit der Tesla Fleet API kommunizieren.
|
||||||
|
en: |
|
||||||
|
Tesla offers an official, but paid Fleet API.
|
||||||
|
For private use, you can create a Tesla Developer Account at [developer.tesla.com](https://developer.tesla.com/) and receive a monthly API credit of $10.
|
||||||
|
|
||||||
|
The [myteslamate.com](https://www.myteslamate.com/tesla-api-application-registration/) guide explains the process and generates a free Access and Refresh Token.
|
||||||
|
With this token pair and your Client ID created in the Tesla Developer Account, evcc can directly communicate with the Tesla Fleet API.
|
||||||
|
params:
|
||||||
|
- name: host
|
||||||
|
- name: password
|
||||||
|
required: true
|
||||||
|
help:
|
||||||
|
en: Password of the user "customer". By default this is the last 5 characters of password stated on the Tesla Gateway.
|
||||||
|
de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts.
|
||||||
|
- name: clientId
|
||||||
|
required: true
|
||||||
|
help:
|
||||||
|
en: from [developer.tesla.com](https://developer.tesla.com/dashboard).
|
||||||
|
de: von [developer.tesla.com](https://developer.tesla.com/dashboard).
|
||||||
|
- name: accessToken
|
||||||
|
required: true
|
||||||
|
help:
|
||||||
|
en: from [myteslamate.com](https://app.myteslamate.com/).
|
||||||
|
de: von [myteslamate.com](https://app.myteslamate.com/).
|
||||||
|
- name: refreshToken
|
||||||
|
required: true
|
||||||
|
help:
|
||||||
|
en: from [myteslamate.com](https://app.myteslamate.com/).
|
||||||
|
de: von [myteslamate.com](https://app.myteslamate.com/).
|
||||||
|
- name: siteId
|
||||||
|
description:
|
||||||
|
generic: Site ID
|
||||||
|
help:
|
||||||
|
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
|
||||||
|
advanced: true
|
||||||
|
- name: maxsoc
|
||||||
|
advanced: true
|
||||||
|
- name: maxchargepower
|
||||||
|
- name: maxdischargepower
|
||||||
|
render: |
|
||||||
|
type: powerwall-fleet
|
||||||
|
uri: {{ .host }}
|
||||||
|
usage: battery
|
||||||
|
user: customer
|
||||||
|
password: {{ .password }} # for user 'customer'
|
||||||
|
credentials:
|
||||||
|
id: {{ .clientId }}
|
||||||
|
tokens:
|
||||||
|
access: {{ .accessToken }}
|
||||||
|
refresh: {{ .refreshToken }}
|
||||||
|
siteId: {{ .siteId }}
|
||||||
|
minsoc: {{ .minsoc }}
|
||||||
|
maxsoc: {{ .maxsoc }}
|
||||||
|
maxchargepower: {{ .maxchargepower }}
|
||||||
|
maxdischargepower: {{ .maxdischargepower }}
|
||||||
|
|
@ -3,23 +3,6 @@ products:
|
||||||
- brand: Tesla
|
- brand: Tesla
|
||||||
description:
|
description:
|
||||||
generic: Powerwall
|
generic: Powerwall
|
||||||
capabilities: ["battery-control"]
|
|
||||||
requirements:
|
|
||||||
description:
|
|
||||||
de: |
|
|
||||||
Um die optionale Entladesteuerung der Battery zu nutzen wird ein `refresh` Token für die Kommunikation mit der Tesla API benötigt.
|
|
||||||
|
|
||||||
Folgende Apps ermöglichen das Erstellen des Tokens:
|
|
||||||
- [Auth app for Tesla (iOS)](https://apps.apple.com/us/app/auth-app-for-tesla/id1552058613#?platform=iphone)
|
|
||||||
- [Tesla Tokens (Android)](https://play.google.com/store/apps/details?id=net.leveugle.teslatokens)
|
|
||||||
- [Tesla Auth (macOS, Linux)](https://github.com/adriankumpf/tesla_auth)
|
|
||||||
en: |
|
|
||||||
To use the optional battery control you need to generate a `refresh` token for communicating with the Tesla API.
|
|
||||||
|
|
||||||
The following apps allow to create the token:
|
|
||||||
- [Auth app for Tesla (iOS)](https://apps.apple.com/us/app/auth-app-for-tesla/id1552058613#?platform=iphone)
|
|
||||||
- [Tesla Tokens (Android)](https://play.google.com/store/apps/details?id=net.leveugle.teslatokens)
|
|
||||||
- [Tesla Auth (macOS, Linux)](https://github.com/adriankumpf/tesla_auth)
|
|
||||||
params:
|
params:
|
||||||
- name: usage
|
- name: usage
|
||||||
choice: ["grid", "pv", "battery"]
|
choice: ["grid", "pv", "battery"]
|
||||||
|
|
@ -30,12 +13,9 @@ params:
|
||||||
en: Password of the user "customer". By default this is the last 5 characters of password stated on the Tesla Gateway.
|
en: Password of the user "customer". By default this is the last 5 characters of password stated on the Tesla Gateway.
|
||||||
de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts.
|
de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts.
|
||||||
- name: refreshToken
|
- name: refreshToken
|
||||||
|
deprecated: true
|
||||||
- name: siteId
|
- name: siteId
|
||||||
description:
|
deprecated: true
|
||||||
generic: Site ID
|
|
||||||
help:
|
|
||||||
en: optional product identifier of the energy site, use to override autodectction
|
|
||||||
de: optionale Product ID dieser Energy Site, zum Übersteuern der automatischen Erkennung
|
|
||||||
- name: minsoc
|
- name: minsoc
|
||||||
advanced: true
|
advanced: true
|
||||||
- name: maxsoc
|
- name: maxsoc
|
||||||
|
|
@ -48,8 +28,6 @@ render: |
|
||||||
usage: {{ .usage }}
|
usage: {{ .usage }}
|
||||||
user: customer
|
user: customer
|
||||||
password: {{ .password }} # for user 'customer'
|
password: {{ .password }} # for user 'customer'
|
||||||
refreshToken: {{ .refreshToken }}
|
|
||||||
siteId: {{ .siteId }}
|
|
||||||
minsoc: {{ .minsoc }}
|
minsoc: {{ .minsoc }}
|
||||||
maxsoc: {{ .maxsoc }}
|
maxsoc: {{ .maxsoc }}
|
||||||
maxchargepower: {{ .maxchargepower }}
|
maxchargepower: {{ .maxchargepower }}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package vehicle
|
package oauth
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/ford/query"
|
"github.com/evcc-io/evcc/vehicle/ford/query"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -25,7 +26,7 @@ func init() {
|
||||||
func NewFordConnectQueryFromConfig(other map[string]any) (api.Vehicle, error) {
|
func NewFordConnectQueryFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Credentials ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
RedirectURI string
|
RedirectURI string
|
||||||
VIN string
|
VIN string
|
||||||
Cache time.Duration
|
Cache time.Duration
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/ford/connect"
|
"github.com/evcc-io/evcc/vehicle/ford/connect"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -24,8 +25,8 @@ func init() {
|
||||||
func NewFordConnectFromConfig(other map[string]any) (api.Vehicle, error) {
|
func NewFordConnectFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Credentials ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
Tokens Tokens
|
Tokens oauth.Tokens
|
||||||
VIN string
|
VIN string
|
||||||
Cache time.Duration
|
Cache time.Duration
|
||||||
}{
|
}{
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/mercedes"
|
"github.com/evcc-io/evcc/vehicle/mercedes"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -22,7 +23,7 @@ func init() {
|
||||||
func NewMercedesFromConfig(other map[string]any) (api.Vehicle, error) {
|
func NewMercedesFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Tokens Tokens
|
Tokens oauth.Tokens
|
||||||
User string
|
User string
|
||||||
Account_ string `mapstructure:"account"` // TODO deprecated
|
Account_ string `mapstructure:"account"` // TODO deprecated
|
||||||
VIN string
|
VIN string
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/psa"
|
"github.com/evcc-io/evcc/vehicle/psa"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -40,7 +41,7 @@ func newPSA(brand, realm string, other map[string]any) (api.Vehicle, error) {
|
||||||
User string
|
User string
|
||||||
Password string `mapstructure:"password"`
|
Password string `mapstructure:"password"`
|
||||||
Country string
|
Country string
|
||||||
Tokens Tokens
|
Tokens oauth.Tokens
|
||||||
Cache time.Duration
|
Cache time.Duration
|
||||||
}{
|
}{
|
||||||
Cache: interval,
|
Cache: interval,
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@ package vehicle
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
|
|
@ -11,7 +10,6 @@ import (
|
||||||
"github.com/evcc-io/evcc/util/transport"
|
"github.com/evcc-io/evcc/util/transport"
|
||||||
"github.com/evcc-io/evcc/vehicle/tesla"
|
"github.com/evcc-io/evcc/vehicle/tesla"
|
||||||
teslaclient "github.com/evcc-io/tesla-proxy-client"
|
teslaclient "github.com/evcc-io/tesla-proxy-client"
|
||||||
"golang.org/x/oauth2"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Tesla is an api.Vehicle implementation for Tesla cars using the official Tesla vehicle-command api.
|
// Tesla is an api.Vehicle implementation for Tesla cars using the official Tesla vehicle-command api.
|
||||||
|
|
@ -28,14 +26,13 @@ func init() {
|
||||||
// NewTeslaFromConfig creates a new vehicle
|
// NewTeslaFromConfig creates a new vehicle
|
||||||
func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) {
|
func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Credentials ClientCredentials
|
tesla.FleetConfig `mapstructure:",squash"`
|
||||||
Tokens Tokens
|
VIN string
|
||||||
VIN string
|
CommandProxy string
|
||||||
CommandProxy string
|
ProxyToken string
|
||||||
ProxyToken string
|
Cache time.Duration
|
||||||
Cache time.Duration
|
Timeout time.Duration
|
||||||
Timeout time.Duration
|
|
||||||
}{
|
}{
|
||||||
CommandProxy: tesla.ProxyBaseUrl,
|
CommandProxy: tesla.ProxyBaseUrl,
|
||||||
Cache: interval,
|
Cache: interval,
|
||||||
|
|
@ -46,12 +43,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
if cc.Credentials.ID == "" {
|
if err := cc.FleetConfig.Validate(); err != nil {
|
||||||
return nil, errors.New("missing client id, see https://docs.evcc.io/en/docs/devices/vehicles#tesla")
|
|
||||||
}
|
|
||||||
|
|
||||||
token, err := cc.Tokens.Token()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -60,28 +52,11 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc.Credentials.ID, cc.Credentials.Secret,
|
cc.Credentials.ID, cc.Credentials.Secret,
|
||||||
)
|
)
|
||||||
|
|
||||||
identity, err := tesla.NewIdentity(log, tesla.OAuth2Config(cc.Credentials.ID, cc.Credentials.Secret), token)
|
fleet, err := cc.FleetConfig.Client(log)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
tc := fleet.Client
|
||||||
hc := request.NewClient(log)
|
|
||||||
hc.Transport = &oauth2.Transport{
|
|
||||||
Source: identity,
|
|
||||||
Base: hc.Transport,
|
|
||||||
}
|
|
||||||
|
|
||||||
tc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(hc))
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// validate base url
|
|
||||||
region, err := tc.UserRegion()
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
tc.SetBaseUrl(region.FleetApiBaseUrl)
|
|
||||||
|
|
||||||
vehicle, err := ensureVehicleEx(
|
vehicle, err := ensureVehicleEx(
|
||||||
cc.VIN, tc.Vehicles,
|
cc.VIN, tc.Vehicles,
|
||||||
|
|
@ -99,7 +74,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
Decorator: transport.DecorateHeaders(map[string]string{
|
Decorator: transport.DecorateHeaders(map[string]string{
|
||||||
"X-Authorization": "Bearer " + cc.ProxyToken,
|
"X-Authorization": "Bearer " + cc.ProxyToken,
|
||||||
}),
|
}),
|
||||||
Base: hc.Transport,
|
Base: fleet.HTTPClient.Transport,
|
||||||
}
|
}
|
||||||
|
|
||||||
tcc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(pc))
|
tcc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(pc))
|
||||||
|
|
|
||||||
72
vehicle/tesla/fleet.go
Normal file
72
vehicle/tesla/fleet.go
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
package tesla
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
|
"github.com/evcc-io/evcc/util/request"
|
||||||
|
teslaclient "github.com/evcc-io/tesla-proxy-client"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FleetConfig contains Tesla Fleet API credentials and tokens
|
||||||
|
type FleetConfig struct {
|
||||||
|
Credentials oauth.ClientCredentials
|
||||||
|
Tokens oauth.Tokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetClient provides authenticated access to the Tesla Fleet API
|
||||||
|
type FleetClient struct {
|
||||||
|
Client *teslaclient.Client
|
||||||
|
HTTPClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate checks that the required Tesla Fleet API credentials are configured
|
||||||
|
func (c FleetConfig) Validate() error {
|
||||||
|
if c.Credentials.ID == "" {
|
||||||
|
return errors.New("missing client id, see https://docs.evcc.io/en/docs/devices/vehicles#tesla")
|
||||||
|
}
|
||||||
|
if c.Tokens.Access == "" || c.Tokens.Refresh == "" {
|
||||||
|
return api.ErrMissingToken
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client creates a Tesla Fleet API client for the configured account
|
||||||
|
func (c FleetConfig) Client(log *util.Logger) (*FleetClient, error) {
|
||||||
|
if err := c.Validate(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
token, err := c.Tokens.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
identity, err := NewIdentity(log, OAuth2Config(c.Credentials.ID, c.Credentials.Secret), token)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create Fleet identity: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
hc := request.NewClient(log)
|
||||||
|
hc.Transport = &oauth2.Transport{Source: identity, Base: hc.Transport}
|
||||||
|
|
||||||
|
tc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(hc))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create Fleet client: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
region, err := tc.UserRegion()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("get Fleet API region: %w", err)
|
||||||
|
}
|
||||||
|
tc.SetBaseUrl(region.FleetApiBaseUrl)
|
||||||
|
|
||||||
|
return &FleetClient{Client: tc, HTTPClient: hc}, nil
|
||||||
|
}
|
||||||
67
vehicle/tesla/fleet_test.go
Normal file
67
vehicle/tesla/fleet_test.go
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
package tesla
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/evcc-io/evcc/api"
|
||||||
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Client must reject an incomplete config before opening any connection
|
||||||
|
func TestFleetClientValidates(t *testing.T) {
|
||||||
|
_, err := FleetConfig{}.Client(util.NewLogger("tesla"))
|
||||||
|
assert.ErrorContains(t, err, "missing client id")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFleetConfigValidate(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
config FleetConfig
|
||||||
|
want string
|
||||||
|
sentinel error
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "missing client id",
|
||||||
|
want: "missing client id",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing tokens",
|
||||||
|
config: FleetConfig{
|
||||||
|
Credentials: oauth.ClientCredentials{ID: "client"},
|
||||||
|
},
|
||||||
|
sentinel: api.ErrMissingToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing refresh token",
|
||||||
|
config: FleetConfig{
|
||||||
|
Credentials: oauth.ClientCredentials{ID: "client"},
|
||||||
|
Tokens: oauth.Tokens{Access: "access"},
|
||||||
|
},
|
||||||
|
sentinel: api.ErrMissingToken,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid",
|
||||||
|
config: FleetConfig{
|
||||||
|
Credentials: oauth.ClientCredentials{ID: "client"},
|
||||||
|
Tokens: oauth.Tokens{Access: "access", Refresh: "refresh"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := tc.config.Validate()
|
||||||
|
if tc.sentinel != nil {
|
||||||
|
assert.ErrorIs(t, err, tc.sentinel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if tc.want != "" {
|
||||||
|
assert.ErrorContains(t, err, tc.want)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -3,25 +3,11 @@ package tesla
|
||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/teslamotors/vehicle-command/pkg/connector/inet"
|
"github.com/teslamotors/vehicle-command/pkg/connector/inet"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
|
||||||
mu sync.Mutex
|
|
||||||
identities = make(map[string]*Identity)
|
|
||||||
)
|
|
||||||
|
|
||||||
func getInstance(subject string) *Identity {
|
|
||||||
return identities[subject]
|
|
||||||
}
|
|
||||||
|
|
||||||
func addInstance(subject string, identity *Identity) {
|
|
||||||
identities[subject] = identity
|
|
||||||
}
|
|
||||||
|
|
||||||
// apiError converts HTTP 408 error to ErrTimeout
|
// apiError converts HTTP 408 error to ErrTimeout
|
||||||
func apiError(err error) error {
|
func apiError(err error) error {
|
||||||
if err != nil && (errors.Is(err, inet.ErrVehicleNotAwake) ||
|
if err != nil && (errors.Is(err, inet.ErrVehicleNotAwake) ||
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,11 @@ import (
|
||||||
"golang.org/x/oauth2"
|
"golang.org/x/oauth2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
identities = make(map[string]*Identity)
|
||||||
|
)
|
||||||
|
|
||||||
// https://auth.tesla.com/oauth2/v3/.well-known/openid-configuration
|
// https://auth.tesla.com/oauth2/v3/.well-known/openid-configuration
|
||||||
|
|
||||||
// OAuth2Config is the OAuth2 configuration for authenticating with the Tesla API.
|
// OAuth2Config is the OAuth2 configuration for authenticating with the Tesla API.
|
||||||
|
|
@ -51,7 +56,7 @@ func NewIdentity(log *util.Logger, oc *oauth2.Config, token *oauth2.Token) (oaut
|
||||||
}
|
}
|
||||||
|
|
||||||
// reuse identity instance
|
// reuse identity instance
|
||||||
if instance := getInstance(claims.Subject); instance != nil {
|
if instance := identities[claims.Subject]; instance != nil {
|
||||||
return instance, nil
|
return instance, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,7 +91,7 @@ func NewIdentity(log *util.Logger, oc *oauth2.Config, token *oauth2.Token) (oaut
|
||||||
v.TokenSource = oauth.RefreshTokenSource(token, v.refreshToken)
|
v.TokenSource = oauth.RefreshTokenSource(token, v.refreshToken)
|
||||||
|
|
||||||
// add instance
|
// add instance
|
||||||
addInstance(claims.Subject, v)
|
identities[claims.Subject] = v
|
||||||
|
|
||||||
return v, nil
|
return v, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import (
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/api/implement"
|
"github.com/evcc-io/evcc/api/implement"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/util/request"
|
"github.com/evcc-io/evcc/util/request"
|
||||||
"github.com/evcc-io/evcc/util/sponsor"
|
"github.com/evcc-io/evcc/util/sponsor"
|
||||||
"github.com/evcc-io/evcc/vehicle/tronity"
|
"github.com/evcc-io/evcc/vehicle/tronity"
|
||||||
|
|
@ -53,8 +54,8 @@ func init() {
|
||||||
func NewTronityFromConfig(other map[string]any) (api.Vehicle, error) {
|
func NewTronityFromConfig(other map[string]any) (api.Vehicle, error) {
|
||||||
cc := struct {
|
cc := struct {
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
Credentials ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
Tokens Tokens
|
Tokens oauth.Tokens
|
||||||
VIN string
|
VIN string
|
||||||
Cache time.Duration
|
Cache time.Duration
|
||||||
}{
|
}{
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import (
|
||||||
|
|
||||||
"github.com/evcc-io/evcc/api"
|
"github.com/evcc-io/evcc/api"
|
||||||
"github.com/evcc-io/evcc/util"
|
"github.com/evcc-io/evcc/util"
|
||||||
|
"github.com/evcc-io/evcc/util/oauth"
|
||||||
"github.com/evcc-io/evcc/vehicle/volvo/connected"
|
"github.com/evcc-io/evcc/vehicle/volvo/connected"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -26,7 +27,7 @@ func NewVolvoConnectedFromConfig(ctx context.Context, other map[string]any) (api
|
||||||
embed `mapstructure:",squash"`
|
embed `mapstructure:",squash"`
|
||||||
VIN string
|
VIN string
|
||||||
VccApiKey string
|
VccApiKey string
|
||||||
Credentials ClientCredentials
|
Credentials oauth.ClientCredentials
|
||||||
RedirectUri string
|
RedirectUri string
|
||||||
Cache time.Duration
|
Cache time.Duration
|
||||||
}{
|
}{
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue