From fdea008b0bf3bf8fe075278e09c10d9f89d3ca68 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20He=C3=9F?= Date: Sun, 9 Aug 2026 13:17:48 +0200 Subject: [PATCH] Powerwall: add Fleet API support (BC) (#32410) --- cmd/token_ford-connect.go | 6 +- cmd/token_tronity.go | 4 +- go.mod | 1 - go.sum | 2 - meter/powerwall.go | 156 ++++++------------ meter/powerwall_fleet.go | 114 +++++++++++++ meter/powerwall_test.go | 136 +++++++++++++++ .../meter/tesla-powerwall-fleet.yaml | 70 ++++++++ .../definition/meter/tesla-powerwall.yaml | 26 +-- vehicle/types.go => util/oauth/config.go | 2 +- vehicle/ford-connect-query.go | 3 +- vehicle/ford-connect.go | 5 +- vehicle/mercedes.go | 3 +- vehicle/psa.go | 3 +- vehicle/tesla.go | 47 ++---- vehicle/tesla/fleet.go | 72 ++++++++ vehicle/tesla/fleet_test.go | 67 ++++++++ vehicle/tesla/helper.go | 14 -- vehicle/tesla/identity.go | 9 +- vehicle/tronity.go | 5 +- vehicle/volvo-connected.go | 3 +- 21 files changed, 548 insertions(+), 200 deletions(-) create mode 100644 meter/powerwall_fleet.go create mode 100644 meter/powerwall_test.go create mode 100644 templates/definition/meter/tesla-powerwall-fleet.yaml rename vehicle/types.go => util/oauth/config.go (98%) create mode 100644 vehicle/tesla/fleet.go create mode 100644 vehicle/tesla/fleet_test.go diff --git a/cmd/token_ford-connect.go b/cmd/token_ford-connect.go index 22a94c13b..843dbad6b 100644 --- a/cmd/token_ford-connect.go +++ b/cmd/token_ford-connect.go @@ -6,16 +6,16 @@ import ( "github.com/AlecAivazis/survey/v2" "github.com/evcc-io/evcc/util" "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/vehicle" "github.com/evcc-io/evcc/vehicle/ford/connect" "golang.org/x/oauth2" ) func fordConnectToken(conf config.Named) (*oauth2.Token, error) { var cc struct { - Credentials vehicle.ClientCredentials - Tokens vehicle.Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens Other map[string]any `mapstructure:",remain"` } diff --git a/cmd/token_tronity.go b/cmd/token_tronity.go index ed339e9f5..586a175b6 100644 --- a/cmd/token_tronity.go +++ b/cmd/token_tronity.go @@ -13,7 +13,7 @@ import ( "github.com/evcc-io/evcc/api/globalconfig" "github.com/evcc-io/evcc/util" "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/samber/lo" "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) { var cc struct { - Credentials vehicle.ClientCredentials + Credentials oauth.ClientCredentials RedirectURI string Other map[string]any `mapstructure:",remain"` } diff --git a/go.mod b/go.mod index d89270a01..03b91db15 100644 --- a/go.mod +++ b/go.mod @@ -19,7 +19,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/cognitoidentity v1.36.4 github.com/basgys/goxml2json v1.1.0 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/cli/browser v1.3.0 github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21 diff --git a/go.sum b/go.sum index 259dfba03..01ce8bcc1 100644 --- a/go.sum +++ b/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/go.mod h1:YOPVLzCfwK14b4Sff3oP1AmGhI9T9Vsg84etUnlyp+Q= 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/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= diff --git a/meter/powerwall.go b/meter/powerwall.go index c84d1e2df..aba9f78e7 100644 --- a/meter/powerwall.go +++ b/meter/powerwall.go @@ -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 -} diff --git a/meter/powerwall_fleet.go b/meter/powerwall_fleet.go new file mode 100644 index 000000000..2174e40b2 --- /dev/null +++ b/meter/powerwall_fleet.go @@ -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) +} diff --git a/meter/powerwall_test.go b/meter/powerwall_test.go new file mode 100644 index 000000000..a2e8516f8 --- /dev/null +++ b/meter/powerwall_test.go @@ -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)) + }) + } +} diff --git a/templates/definition/meter/tesla-powerwall-fleet.yaml b/templates/definition/meter/tesla-powerwall-fleet.yaml new file mode 100644 index 000000000..8a9cc4edf --- /dev/null +++ b/templates/definition/meter/tesla-powerwall-fleet.yaml @@ -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 }} diff --git a/templates/definition/meter/tesla-powerwall.yaml b/templates/definition/meter/tesla-powerwall.yaml index cddf39003..99f72dc2c 100644 --- a/templates/definition/meter/tesla-powerwall.yaml +++ b/templates/definition/meter/tesla-powerwall.yaml @@ -3,23 +3,6 @@ products: - brand: Tesla description: 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: - name: usage 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. de: Passwort des Benutzers "Kunde". Default sind die letzten 5 Zeichen des auf dem Tesla Gateway genannten Passworts. - name: refreshToken + deprecated: true - name: siteId - description: - 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 + deprecated: true - name: minsoc advanced: true - name: maxsoc @@ -48,8 +28,6 @@ render: | usage: {{ .usage }} user: customer password: {{ .password }} # for user 'customer' - refreshToken: {{ .refreshToken }} - siteId: {{ .siteId }} minsoc: {{ .minsoc }} maxsoc: {{ .maxsoc }} maxchargepower: {{ .maxchargepower }} diff --git a/vehicle/types.go b/util/oauth/config.go similarity index 98% rename from vehicle/types.go rename to util/oauth/config.go index 717996123..2286ac5b4 100644 --- a/vehicle/types.go +++ b/util/oauth/config.go @@ -1,4 +1,4 @@ -package vehicle +package oauth import ( "errors" diff --git a/vehicle/ford-connect-query.go b/vehicle/ford-connect-query.go index a29a4fa01..7ea2b5ad7 100644 --- a/vehicle/ford-connect-query.go +++ b/vehicle/ford-connect-query.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/ford/query" ) @@ -25,7 +26,7 @@ func init() { func NewFordConnectQueryFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials + Credentials oauth.ClientCredentials RedirectURI string VIN string Cache time.Duration diff --git a/vehicle/ford-connect.go b/vehicle/ford-connect.go index f0fb158c6..5e223de5e 100644 --- a/vehicle/ford-connect.go +++ b/vehicle/ford-connect.go @@ -5,6 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/ford/connect" ) @@ -24,8 +25,8 @@ func init() { func NewFordConnectFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens VIN string Cache time.Duration }{ diff --git a/vehicle/mercedes.go b/vehicle/mercedes.go index f23b6c547..a2df85883 100644 --- a/vehicle/mercedes.go +++ b/vehicle/mercedes.go @@ -5,6 +5,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "github.com/evcc-io/evcc/vehicle/mercedes" ) @@ -22,7 +23,7 @@ func init() { func NewMercedesFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Tokens Tokens + Tokens oauth.Tokens User string Account_ string `mapstructure:"account"` // TODO deprecated VIN string diff --git a/vehicle/psa.go b/vehicle/psa.go index add433085..729513acd 100644 --- a/vehicle/psa.go +++ b/vehicle/psa.go @@ -6,6 +6,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "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 Password string `mapstructure:"password"` Country string - Tokens Tokens + Tokens oauth.Tokens Cache time.Duration }{ Cache: interval, diff --git a/vehicle/tesla.go b/vehicle/tesla.go index 05ec3cdc6..0e6dd5a01 100644 --- a/vehicle/tesla.go +++ b/vehicle/tesla.go @@ -2,7 +2,6 @@ package vehicle import ( "context" - "errors" "time" "github.com/evcc-io/evcc/api" @@ -11,7 +10,6 @@ import ( "github.com/evcc-io/evcc/util/transport" "github.com/evcc-io/evcc/vehicle/tesla" 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. @@ -28,14 +26,13 @@ func init() { // NewTeslaFromConfig creates a new vehicle func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { - embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens - VIN string - CommandProxy string - ProxyToken string - Cache time.Duration - Timeout time.Duration + embed `mapstructure:",squash"` + tesla.FleetConfig `mapstructure:",squash"` + VIN string + CommandProxy string + ProxyToken string + Cache time.Duration + Timeout time.Duration }{ CommandProxy: tesla.ProxyBaseUrl, Cache: interval, @@ -46,12 +43,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { return nil, err } - if cc.Credentials.ID == "" { - 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 { + if err := cc.FleetConfig.Validate(); err != nil { return nil, err } @@ -60,28 +52,11 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { 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 { return nil, 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, err - } - - // validate base url - region, err := tc.UserRegion() - if err != nil { - return nil, err - } - tc.SetBaseUrl(region.FleetApiBaseUrl) + tc := fleet.Client vehicle, err := ensureVehicleEx( cc.VIN, tc.Vehicles, @@ -99,7 +74,7 @@ func NewTeslaFromConfig(other map[string]any) (api.Vehicle, error) { Decorator: transport.DecorateHeaders(map[string]string{ "X-Authorization": "Bearer " + cc.ProxyToken, }), - Base: hc.Transport, + Base: fleet.HTTPClient.Transport, } tcc, err := teslaclient.NewClient(context.Background(), teslaclient.WithClient(pc)) diff --git a/vehicle/tesla/fleet.go b/vehicle/tesla/fleet.go new file mode 100644 index 000000000..fad9858c3 --- /dev/null +++ b/vehicle/tesla/fleet.go @@ -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 +} diff --git a/vehicle/tesla/fleet_test.go b/vehicle/tesla/fleet_test.go new file mode 100644 index 000000000..098e21a1e --- /dev/null +++ b/vehicle/tesla/fleet_test.go @@ -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) + }) + } +} diff --git a/vehicle/tesla/helper.go b/vehicle/tesla/helper.go index c35ef5fb1..77b308025 100644 --- a/vehicle/tesla/helper.go +++ b/vehicle/tesla/helper.go @@ -3,25 +3,11 @@ package tesla import ( "errors" "strings" - "sync" "github.com/evcc-io/evcc/api" "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 func apiError(err error) error { if err != nil && (errors.Is(err, inet.ErrVehicleNotAwake) || diff --git a/vehicle/tesla/identity.go b/vehicle/tesla/identity.go index b36977b97..cc16dbc73 100644 --- a/vehicle/tesla/identity.go +++ b/vehicle/tesla/identity.go @@ -14,6 +14,11 @@ import ( "golang.org/x/oauth2" ) +var ( + mu sync.Mutex + identities = make(map[string]*Identity) +) + // https://auth.tesla.com/oauth2/v3/.well-known/openid-configuration // 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 - if instance := getInstance(claims.Subject); instance != nil { + if instance := identities[claims.Subject]; 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) // add instance - addInstance(claims.Subject, v) + identities[claims.Subject] = v return v, nil } diff --git a/vehicle/tronity.go b/vehicle/tronity.go index 50776447d..a620370ba 100644 --- a/vehicle/tronity.go +++ b/vehicle/tronity.go @@ -28,6 +28,7 @@ import ( "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/oauth" "github.com/evcc-io/evcc/util/request" "github.com/evcc-io/evcc/util/sponsor" "github.com/evcc-io/evcc/vehicle/tronity" @@ -53,8 +54,8 @@ func init() { func NewTronityFromConfig(other map[string]any) (api.Vehicle, error) { cc := struct { embed `mapstructure:",squash"` - Credentials ClientCredentials - Tokens Tokens + Credentials oauth.ClientCredentials + Tokens oauth.Tokens VIN string Cache time.Duration }{ diff --git a/vehicle/volvo-connected.go b/vehicle/volvo-connected.go index 171aeb042..7bf6aa7cb 100644 --- a/vehicle/volvo-connected.go +++ b/vehicle/volvo-connected.go @@ -7,6 +7,7 @@ import ( "github.com/evcc-io/evcc/api" "github.com/evcc-io/evcc/util" + "github.com/evcc-io/evcc/util/oauth" "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"` VIN string VccApiKey string - Credentials ClientCredentials + Credentials oauth.ClientCredentials RedirectUri string Cache time.Duration }{