Repository: evcc-io/evcc
Pull request #3005 (2022-03-26 09:22:05)

Add TP-Link Tapo Charger (P100,P110)
Charger for [TP-Link Tapo smarthome plugs](https://www.tapo.com/de/product/smart-plug/) P100 and P110.

It behaves like the other smarthome plug chargers FritzDect, Tasmota, Shelly, TP-Link. 

evcc.yaml config:
```yaml
chargers:
- type: tapo
  uri: xxx.xxx.xxx.xxx
  user: dummy@mail.com
  password: tapopwd
  standbypower: 15
  name: "tapo"
```

Log charger output:
```
[main  ] INFO 2022/03/26 09:55:15 evcc 0.86 (52ecd823)
[main  ] INFO 2022/03/26 09:55:15 using config file ./evcc_tapotest.yaml
Power:         0W
Charge status: B
Enabled:       false
Charged:       0.0kWh
```

Todos:
- Improve error handling in case the plug is not connected
- Use of DoJson in Tapo handshake function


Patch:
diff --git a/charger/tapo.go b/charger/tapo.go
new file mode 100644
index 00000000..b9b71f2f
--- /dev/null
+++ b/charger/tapo.go
@@ -0,0 +1,170 @@
+package charger
+
+import (
+	"errors"
+	"fmt"
+	"strings"
+	"time"
+
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/charger/tapo"
+	"github.com/evcc-io/evcc/util"
+)
+
+// TP-Link Tapo charger implementation
+type Tapo struct {
+	conn            *tapo.Connection
+	standbypower    float64
+	updated         time.Time
+	lasttodayenergy int64
+	energy          int64
+}
+
+func init() {
+	registry.Add("tapo", NewTapoFromConfig)
+}
+
+// NewTapoFromConfig creates a Tapo charger from generic config
+func NewTapoFromConfig(other map[string]interface{}) (api.Charger, error) {
+	cc := struct {
+		URI          string
+		User         string
+		Password     string
+		StandbyPower float64
+	}{}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	if cc.URI == "" {
+		return nil, errors.New("missing uri")
+	}
+
+	return NewTapo(cc.URI, cc.User, cc.Password, cc.StandbyPower)
+}
+
+// NewTapo creates Tapo charger
+func NewTapo(uri, user, password string, standbypower float64) (*Tapo, error) {
+	for _, suffix := range []string{"/", "/app"} {
+		uri = strings.TrimSuffix(uri, suffix)
+	}
+
+	conn := tapo.NewConnection(uri, user, password)
+
+	tapo := &Tapo{
+		conn:         conn,
+		standbypower: standbypower,
+	}
+
+	if user == "" || password == "" {
+		return tapo, fmt.Errorf("missing user/password")
+	}
+
+	return tapo, nil
+}
+
+// Enabled implements the api.Charger interface
+func (c *Tapo) Enabled() (bool, error) {
+	resp, err := c.execTapoCmd("get_device_info", false)
+	if err != nil {
+		return false, err
+	}
+	return resp.Result.DeviceON, nil
+}
+
+// Enable implements the api.Charger interface
+func (c *Tapo) Enable(enable bool) error {
+	_, err := c.execTapoCmd("set_device_info", enable)
+	return err
+}
+
+// MaxCurrent implements the api.Charger interface
+func (c *Tapo) MaxCurrent(current int64) error {
+	return nil
+}
+
+// Status implements the api.Charger interface
+func (c *Tapo) Status() (api.ChargeStatus, error) {
+	res := api.StatusB
+	on, err := c.Enabled()
+	if err != nil {
+		return res, err
+	}
+
+	power, err := c.CurrentPower()
+	if err != nil {
+		return res, err
+	}
+
+	// static mode || standby power mode condition
+	if on && (c.standbypower < 0 || power > c.standbypower) {
+		res = api.StatusC
+	}
+
+	return res, nil
+}
+
+var _ api.Meter = (*Tapo)(nil)
+
+// CurrentPower implements the api.Meter interface
+func (c *Tapo) CurrentPower() (float64, error) {
+	resp, err := c.execTapoCmd("get_energy_usage", false)
+	if err != nil {
+		return 0, err
+	}
+
+	power := float64(resp.Result.Current_Power) / 1000
+
+	// ignore power in standby mode
+	if c.standbypower >= 0 && power <= c.standbypower {
+		power = 0
+	}
+
+	// set fix static power in static mode
+	if c.standbypower < 0 {
+		on, err := c.Enabled()
+		if err != nil {
+			return 0, err
+		}
+		if on {
+			power = c.standbypower * -1
+		} else {
+			power = 0
+		}
+	}
+
+	return power, nil
+}
+
+var _ api.ChargeRater = (*Vestel)(nil)
+
+// ChargedEnergy implements the api.ChargeRater interface
+func (c *Tapo) ChargedEnergy() (float64, error) {
+	resp, err := c.execTapoCmd("get_energy_usage", false)
+	if err != nil {
+		return 0, err
+	}
+
+	if resp.Result.Today_Energy > c.lasttodayenergy {
+		c.energy = c.energy + (resp.Result.Today_Energy - c.lasttodayenergy)
+	}
+	c.lasttodayenergy = resp.Result.Today_Energy
+
+	return float64(c.energy) / 1000, nil
+}
+
+// execTapoCmd executes a Tapo api command and provides the response
+func (c *Tapo) execTapoCmd(method string, enable bool) (*tapo.DeviceResponse, error) {
+	// refresh Tapo session id
+	if time.Since(c.updated) >= 600*time.Minute {
+		err := c.conn.Login()
+		if err != nil {
+			return nil, err
+		}
+		// update session timestamp
+		c.updated = time.Now()
+	}
+
+	return c.conn.ExecMethod(method, enable)
+}
diff --git a/charger/tapo/connection.go b/charger/tapo/connection.go
new file mode 100644
index 00000000..30a8dde1
--- /dev/null
+++ b/charger/tapo/connection.go
@@ -0,0 +1,352 @@
+package tapo
+
+import (
+	"bytes"
+	"crypto/aes"
+	"crypto/cipher"
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/sha1"
+	"crypto/x509"
+	"encoding/base64"
+	"encoding/hex"
+	"encoding/json"
+	"encoding/pem"
+	"errors"
+	"fmt"
+	"net/http"
+	"strings"
+	"time"
+
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+	"github.com/mergermarket/go-pkcs7"
+)
+
+// Tapo homepage + api reverse engineering results
+// https://www.tapo.com/de/
+// Credits to & inspired by:
+// https://k4czp3r.xyz/reverse-engineering/tp-link/tapo/2020/10/15/reverse-engineering-tp-link-tapo.html
+// https://github.com/fishbigger/TapoP100
+// https://github.com/artemvang/p100-go
+
+const Timeout = time.Second * 15
+
+// Connection is the Tapo connection
+type Connection struct {
+	*request.Helper
+	log             *util.Logger
+	URI             string
+	EncodedUser     string
+	EncodedPassword string
+	Cipher          *ConnectionCipher
+	SessionID       string
+	Token           string
+	TerminalUUID    string
+}
+
+// NewConnection creates a new Tapo device connection.
+// User is encoded by using MessageDigest of SHA1 which is afterwards B64 encoded.
+// Password is directly B64 encoded.
+func NewConnection(uri, user, password string) *Connection {
+	log := util.NewLogger("tapo")
+
+	//lint:ignore
+	h := sha1.New()
+	_, _ = h.Write([]byte(user))
+	userhash := hex.EncodeToString(h.Sum(nil))
+
+	conn := &Connection{
+		log:             log,
+		Helper:          request.NewHelper(log),
+		URI:             fmt.Sprintf("%s/app", util.DefaultScheme(uri, "http")),
+		EncodedUser:     base64.StdEncoding.EncodeToString([]byte(userhash)),
+		EncodedPassword: base64.StdEncoding.EncodeToString([]byte(password)),
+	}
+
+	conn.Client.Timeout = Timeout
+
+	return conn
+}
+
+// Login provides the Tapo device session token and MAC address (TerminalUUID).
+func (d *Connection) Login() error {
+	err := d.Handshake()
+	if err != nil {
+		return err
+	}
+
+	req := map[string]interface{}{
+		"method": "login_device",
+		"params": map[string]interface{}{
+			"username": d.EncodedUser,
+			"password": d.EncodedPassword,
+		},
+	}
+
+	res, err := d.DoSecureRequest(d.URI, req)
+	if err != nil {
+		return err
+	}
+
+	d.Token = res.Result.Token
+
+	deviceResponse, err := d.ExecMethod("get_device_info", false)
+	if err != nil {
+		return err
+	}
+
+	d.TerminalUUID = deviceResponse.Result.MAC
+
+	return nil
+}
+
+// Handshake provides the Tapo device session cookie and encryption cipher.
+func (d *Connection) Handshake() error {
+	privKey, pubKey, err := GenerateRSAKeys()
+	if err != nil {
+		return err
+	}
+
+	pubPEM, err := DumpRSAPEM(pubKey)
+	if err != nil {
+		return err
+	}
+
+	req, err := json.Marshal(map[string]interface{}{
+		"method": "handshake",
+		"params": map[string]interface{}{
+			"key":             string(pubPEM),
+			"requestTimeMils": 0,
+		},
+	})
+	if err != nil {
+		return err
+	}
+
+	resp, err := http.Post(d.URI, "application/json", bytes.NewBuffer(req))
+	if err != nil {
+		return err
+	}
+	defer resp.Body.Close()
+
+	var res DeviceResponse
+	if err = json.NewDecoder(resp.Body).Decode(&res); err != nil {
+		return err
+	}
+
+	if err = d.CheckErrorCode(res.ErrorCode); err != nil {
+		return err
+	}
+
+	encryptedEncryptionKey, err := base64.StdEncoding.DecodeString(res.Result.Key)
+	if err != nil {
+		return err
+	}
+
+	encryptionKey, err := rsa.DecryptPKCS1v15(rand.Reader, privKey, encryptedEncryptionKey)
+	if err != nil {
+		return err
+	}
+
+	d.Cipher = &ConnectionCipher{
+		Key: encryptionKey[:16],
+		Iv:  encryptionKey[16:],
+	}
+
+	cookie := strings.Split(resp.Header.Get("Set-Cookie"), ";")
+	if len(cookie) == 0 {
+		return errors.New("missing session cookie")
+	}
+
+	d.SessionID = cookie[0]
+
+	return nil
+}
+
+// ExecMethod executes a Tapo device command method and provides the corresponding response.
+func (d *Connection) ExecMethod(method string, deviceOn bool) (*DeviceResponse, error) {
+	var req map[string]interface{}
+
+	switch method {
+	case "set_device_info":
+		req = map[string]interface{}{
+			"method": method,
+			"params": map[string]interface{}{
+				"device_on": deviceOn,
+			},
+			"requestTimeMils": int(time.Now().Unix() * 1000),
+			"terminalUUID":    d.TerminalUUID,
+		}
+	default:
+		req = map[string]interface{}{
+			"method":          method,
+			"requestTimeMils": int(time.Now().Unix() * 1000),
+		}
+	}
+
+	res, err := d.DoSecureRequest(fmt.Sprintf("%s?token=%s", d.URI, d.Token), req)
+	if err != nil {
+		return nil, err
+	}
+
+	if method == "get_device_info" {
+		res.Result.Nickname, err = base64Decode(res.Result.Nickname)
+		if err != nil {
+			return nil, err
+		}
+
+		res.Result.SSID, err = base64Decode(res.Result.SSID)
+		if err != nil {
+			return nil, err
+		}
+	}
+
+	return res, nil
+}
+
+// DoSecureRequest executes a Tapo device request by encding the request and decoding its response.
+func (d *Connection) DoSecureRequest(uri string, taporequest map[string]interface{}) (*DeviceResponse, error) {
+	treq, err := json.Marshal(taporequest)
+	if err != nil {
+		return nil, err
+	}
+
+	d.log.TRACE.Printf("request: %s\n", string(treq))
+
+	encryptedRequest, err := d.Cipher.Encrypt(treq)
+	if err != nil {
+		return nil, err
+	}
+
+	securedReq := map[string]interface{}{
+		"method": "securePassthrough",
+		"params": map[string]interface{}{
+			"request": base64.StdEncoding.EncodeToString(encryptedRequest),
+		},
+	}
+
+	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(securedReq), map[string]string{
+		"Cookie": d.SessionID,
+	})
+	if err != nil {
+		return nil, err
+	}
+
+	var res *DeviceResponse
+	if err = d.DoJSON(req, &res); err != nil {
+		return nil, err
+	}
+
+	if err = d.CheckErrorCode(res.ErrorCode); err != nil {
+		return nil, err
+	}
+
+	b64decodedResp, err := base64.StdEncoding.DecodeString(res.Result.Response)
+	if err != nil {
+		return nil, err
+	}
+
+	decryptedResponse, err := d.Cipher.Decrypt(b64decodedResp)
+	if err != nil {
+		return nil, err
+	}
+
+	d.log.TRACE.Printf("decrypted result: %v\n", string(decryptedResponse))
+
+	var deviceResp *DeviceResponse
+	if err = json.Unmarshal(decryptedResponse, &deviceResp); err != nil {
+		return deviceResp, err
+	}
+
+	return deviceResp, nil
+}
+
+// Tapo helper functions
+
+func (d *Connection) CheckErrorCode(errorCode int) error {
+	errorDesc := map[int]string{
+		0:     "Success",
+		-1002: "Incorrect Request/Method",
+		-1003: "JSON formatting error ",
+		-1010: "Invalid Public Key Length",
+		-1012: "Invalid terminalUUID",
+		-1501: "Invalid Request or Credentials",
+	}
+
+	if errorCode != 0 {
+		return errors.New(fmt.Sprintf("Tapo error %d: %s", errorCode, errorDesc[errorCode]))
+	}
+
+	return nil
+}
+
+func (c *ConnectionCipher) Encrypt(payload []byte) ([]byte, error) {
+	paddedPayload, err := pkcs7.Pad(payload, aes.BlockSize)
+	if err != nil {
+		return nil, err
+	}
+
+	block, err := aes.NewCipher(c.Key)
+	if err != nil {
+		return nil, err
+	}
+	encrypter := cipher.NewCBCEncrypter(block, c.Iv)
+	encryptedPayload := make([]byte, len(paddedPayload))
+	encrypter.CryptBlocks(encryptedPayload, paddedPayload)
+
+	return encryptedPayload, nil
+}
+
+func (c *ConnectionCipher) Decrypt(payload []byte) ([]byte, error) {
+	block, err := aes.NewCipher(c.Key)
+	if err != nil {
+		return nil, err
+	}
+
+	encrypter := cipher.NewCBCDecrypter(block, c.Iv)
+	decryptedPayload := make([]byte, len(payload))
+
+	encrypter.CryptBlocks(decryptedPayload, payload)
+
+	unpaddedPayload, err := pkcs7.Unpad(decryptedPayload, aes.BlockSize)
+	if err != nil {
+		return nil, err
+	}
+
+	return unpaddedPayload, nil
+}
+
+func DumpRSAPEM(pubKey *rsa.PublicKey) ([]byte, error) {
+	pubKeyPKIX, err := x509.MarshalPKIXPublicKey(pubKey)
+	if err != nil {
+		return nil, err
+	}
+
+	pubPEM := pem.EncodeToMemory(
+		&pem.Block{
+			Type:  "PUBLIC KEY",
+			Bytes: pubKeyPKIX,
+		},
+	)
+
+	return pubPEM, nil
+}
+
+func GenerateRSAKeys() (*rsa.PrivateKey, *rsa.PublicKey, error) {
+	key, err := rsa.GenerateKey(rand.Reader, 1024)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return key, key.Public().(*rsa.PublicKey), nil
+}
+
+func base64Decode(base64String string) (string, error) {
+	decodedString, err := base64.StdEncoding.DecodeString(base64String)
+	if err != nil {
+		return "", err
+	}
+
+	return string(decodedString), nil
+}
diff --git a/charger/tapo/types.go b/charger/tapo/types.go
new file mode 100644
index 00000000..4a8027ed
--- /dev/null
+++ b/charger/tapo/types.go
@@ -0,0 +1,52 @@
+package tapo
+
+// Tapo homepage + api reverse engineering results
+// https://www.tapo.com/de/
+// Credits to & inspired by:
+// https://k4czp3r.xyz/reverse-engineering/tp-link/tapo/2020/10/15/reverse-engineering-tp-link-tapo.html
+// https://github.com/fishbigger/TapoP100
+// https://github.com/artemvang/p100-go
+
+// Tapo connection cipher
+type ConnectionCipher struct {
+	Key []byte
+	Iv  []byte
+}
+
+// Tapo device response
+type DeviceResponse struct {
+	Result struct {
+		DeviceID           string `json:"device_id"`
+		FWVersion          string `json:"fw_ver"`
+		HWVersion          string `json:"hw_ver"`
+		Type               string `json:"type"`
+		Model              string `json:"model"`
+		MAC                string `json:"mac"`
+		HWID               string `json:"hw_id"`
+		FWID               string `json:"fw_id"`
+		OEMID              string `json:"oem_id"`
+		Specs              string `json:"specs"`
+		DeviceON           bool   `json:"device_on"`
+		OnTime             int64  `json:"on_time"`
+		OverHeated         bool   `json:"overheated"`
+		Nickname           string `json:"nickname"`
+		Location           string `json:"location"`
+		Avatar             string `json:"avatar"`
+		Longitude          int64  `json:"longitude"`
+		Latitude           int64  `json:"latitude"`
+		HasSetLocationInfo bool   `json:"has_set_location_info"`
+		IP                 string `json:"ip"`
+		SSID               string `json:"ssid"`
+		SignalLevel        int64  `json:"signal_level"`
+		RSSI               int64  `json:"rssi"`
+		Region             string `json:"Europe/Kiev"`
+		TimeDiff           int64  `json:"time_diff"`
+		Lang               string `json:"lang"`
+		Key                string `json:"key"`
+		Response           string `json:"response"`
+		Token              string `json:"token"`
+		Current_Power      int64  `json:"current_power"`
+		Today_Energy       int64  `json:"today_energy"`
+	} `json:"result"`
+	ErrorCode int `json:"error_code"`
+}
diff --git a/cmd/token_tesla.go b/cmd/token_tesla.go
index b4698312..aef92657 100644
--- a/cmd/token_tesla.go
+++ b/cmd/token_tesla.go
@@ -118,7 +118,7 @@ func teslaToken() (*oauth2.Token, error) {
 		return nil, err
 	}
 
-	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewHelper(log).Client)
+	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
 	client, err := tesla.NewClient(
 		ctx,
 		tesla.WithMFAHandler(codePrompt),
diff --git a/core/helper.go b/core/helper.go
index ed31f703..e27f40e1 100644
--- a/core/helper.go
+++ b/core/helper.go
@@ -2,6 +2,7 @@ package core
 
 import (
 	"github.com/avast/retry-go/v3"
+	"github.com/evcc-io/evcc/util"
 )
 
 var (
@@ -25,13 +26,14 @@ func powerToCurrent(power float64, phases int) float64 {
 
 // sitePower returns the available delta power that the charger might additionally consume
 // negative value: available power (grid export), positive value: grid import
-func sitePower(grid, battery, residual float64) float64 {
+func sitePower(log *util.Logger, maxGrid, grid, battery, residual float64) float64 {
 	// For hybrid inverters, battery can be charged from DC power in excess of
-	// inverter AC rating. This must be offset by the grid consumption when calculating
-	// available site power.
-	// (https://github.com/evcc-io/evcc/issues/2734)
-	if grid > 0 && battery < 0 && grid-battery > 50 {
+	// inverter AC rating. This battery charge must not be counted as available for AC consumption.
+	// https://github.com/evcc-io/evcc/issues/2734, https://github.com/evcc-io/evcc/issues/2986
+	if maxGrid > 0 && grid > maxGrid && battery < 0 {
+		log.TRACE.Printf("ignoring excess DC charging due to grid consumption: %.0fW > %.0fW", grid, maxGrid)
 		battery = 0
 	}
+
 	return grid + battery + residual
 }
diff --git a/core/site.go b/core/site.go
index 509aca3f..8adfca12 100644
--- a/core/site.go
+++ b/core/site.go
@@ -31,12 +31,13 @@ type Site struct {
 	log *util.Logger
 
 	// configuration
-	Title         string       `mapstructure:"title"`         // UI title
-	Voltage       float64      `mapstructure:"voltage"`       // Operating voltage. 230V for Germany.
-	ResidualPower float64      `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
-	Meters        MetersConfig // Meter references
-	PrioritySoC   float64      `mapstructure:"prioritySoC"` // prefer battery up to this SoC
-	BufferSoC     float64      `mapstructure:"bufferSoC"`   // ignore battery above this SoC
+	Title                             string       `mapstructure:"title"`         // UI title
+	Voltage                           float64      `mapstructure:"voltage"`       // Operating voltage. 230V for Germany.
+	ResidualPower                     float64      `mapstructure:"residualPower"` // PV meter only: household usage. Grid meter: household safety margin
+	Meters                            MetersConfig // Meter references
+	PrioritySoC                       float64      `mapstructure:"prioritySoC"`                       // prefer battery up to this SoC
+	BufferSoC                         float64      `mapstructure:"bufferSoC"`                         // ignore battery above this SoC
+	MaxGridSupplyWhileBatteryCharging float64      `mapstructure:"maxGridSupplyWhileBatteryCharging"` // ignore battery charging if AC consumption is above this value
 
 	// meters
 	gridMeter     api.Meter   // Grid usage meter
@@ -376,7 +377,8 @@ func (site *Site) sitePower(totalChargePower float64) (float64, error) {
 		site.batteryBuffered = batteryPower > 0 && site.BufferSoC > 0 && socs > site.BufferSoC
 	}
 
-	sitePower := sitePower(site.gridPower, batteryPower, site.ResidualPower)
+	sitePower := sitePower(site.log, site.MaxGridSupplyWhileBatteryCharging, site.gridPower, batteryPower, site.ResidualPower)
+
 	site.log.DEBUG.Printf("site power: %.0fW", sitePower)
 
 	return sitePower, nil
diff --git a/go.mod b/go.mod
index f02c7815..2b727081 100644
--- a/go.mod
+++ b/go.mod
@@ -17,7 +17,7 @@ require (
 	github.com/cjrd/allocate v0.0.0-20191115010018-022b87fe59fc
 	github.com/cloudfoundry/jibber_jabber v0.0.0-20151120183258-bcc4c8345a21
 	github.com/containrrr/shoutrrr v0.5.2
-	github.com/coreos/go-oidc v2.2.1+incompatible
+	github.com/coreos/go-oidc/v3 v3.1.1-0.20220324025715-2d47dd951527
 	github.com/denisbrodbeck/machineid v1.0.1
 	github.com/dustin/go-humanize v1.0.0
 	github.com/dylanmei/iso8601 v0.1.0
@@ -74,12 +74,12 @@ require (
 	github.com/volkszaehler/mbmd v0.0.0-20220208145932-d2d3cba909f5
 	github.com/writeas/go-strip-markdown v2.0.1+incompatible
 	gitlab.com/bboehmke/sunny v0.15.1-0.20211022160056-2fba1c86ade6
-	golang.org/x/net v0.0.0-20220225172249-27dd8689420f
+	golang.org/x/net v0.0.0-20220325170049-de3da57026de
 	golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a
 	golang.org/x/text v0.3.7
 	google.golang.org/api v0.73.0
 	google.golang.org/grpc v1.45.0
-	google.golang.org/protobuf v1.27.1
+	google.golang.org/protobuf v1.28.0
 	gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
 )
 
@@ -122,6 +122,7 @@ require (
 	github.com/mattn/go-isatty v0.0.14 // indirect
 	github.com/mattn/go-runewidth v0.0.13 // indirect
 	github.com/matttproud/golang_protobuf_extensions v1.0.1 // indirect
+	github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9
 	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
 	github.com/miekg/dns v1.1.45 // indirect
 	github.com/mitchellh/copystructure v1.2.0 // indirect
@@ -134,7 +135,6 @@ require (
 	github.com/pelletier/go-toml/v2 v2.0.0-beta.6 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/pmezard/go-difflib v1.0.0 // indirect
-	github.com/pquerna/cachecontrol v0.1.0 // indirect
 	github.com/prometheus/client_model v0.2.0 // indirect
 	github.com/prometheus/common v0.32.1 // indirect
 	github.com/prometheus/procfs v0.7.3 // indirect
@@ -149,7 +149,7 @@ require (
 	github.com/vmihailenco/msgpack/v5 v5.3.5 // indirect
 	github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect
 	go.opencensus.io v0.23.0 // indirect
-	golang.org/x/crypto v0.0.0-20220214200702-86341886e292 // indirect
+	golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 // indirect
 	golang.org/x/mod v0.5.1 // indirect
 	golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect
 	golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5 // indirect
diff --git a/go.sum b/go.sum
index eb744e4f..ef2f1ad6 100644
--- a/go.sum
+++ b/go.sum
@@ -162,8 +162,8 @@ github.com/containrrr/shoutrrr v0.5.2/go.mod h1:XSU8tOIZ1JG8m6OuPozfGLpj6Ed+S8Zr
 github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
 github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
 github.com/coreos/etcd v3.3.13+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
-github.com/coreos/go-oidc v2.2.1+incompatible h1:mh48q/BqXqgjVHpy2ZY7WnWAbenxRjsz9N1i1YxjHAk=
-github.com/coreos/go-oidc v2.2.1+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc=
+github.com/coreos/go-oidc/v3 v3.1.1-0.20220324025715-2d47dd951527 h1:hK/MZLOLhRU3q4j5l9j+mgNgfUDJJiqLqUcJD7LVaQg=
+github.com/coreos/go-oidc/v3 v3.1.1-0.20220324025715-2d47dd951527/go.mod h1:rEJ/idjfUyfkBit1eI1fvyr+64/g9dcKpAm8MJMesvo=
 github.com/coreos/go-semver v0.2.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-semver v0.3.0/go.mod h1:nnelYz7RCh+5ahJtPPxZlU+153eP4D4r3EedlOD2RNk=
 github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4=
@@ -586,6 +586,8 @@ github.com/mattn/go-runewidth v0.0.13 h1:lTGmDsbAYt5DmK6OnoV7EuIF1wEIFAcxld6ypU4
 github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 github.com/matttproud/golang_protobuf_extensions v1.0.1 h1:4hp9jkHxhMHkqkrB3Ix0jegS5sx/RkqARlsWZ6pIwiU=
 github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
+github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9 h1:j6boLfPkcFlRVaKbc0hf5PVh3jJrdHv9n6SIPOdVKaU=
+github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9/go.mod h1:GH7jtq102ZiRB7LEKgqP54akN7GOVaNpCJrDWTeWSMY=
 github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
 github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
@@ -677,8 +679,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
 github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
-github.com/pquerna/cachecontrol v0.1.0 h1:yJMy84ti9h/+OEWa752kBTKv4XC30OtVVHYv/8cTqKc=
-github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI=
 github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
 github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDft0ttaMvbicHlPoso=
 github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
@@ -871,8 +871,8 @@ golang.org/x/crypto v0.0.0-20210711020723-a769d52b0f97/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/crypto v0.0.0-20211108221036-ceb1ce70b4fa/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.0.0-20220214200702-86341886e292 h1:f+lwQ+GtmgoY+A2YaQxlSOnDjXcQ7ZRLWOHbC6HtRqE=
-golang.org/x/crypto v0.0.0-20220214200702-86341886e292/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
+golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064 h1:S25/rfnfsMVgORT4/J61MJ7rdyseOZOyvLIrZEZ7s6s=
+golang.org/x/crypto v0.0.0-20220321153916-2c7772ba3064/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
@@ -943,6 +943,7 @@ golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLL
 golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200425230154-ff2c4b7c35a0/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200501053045-e0ff5e5a1de5/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
+golang.org/x/net v0.0.0-20200505041828-1ed23360d12c/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200513185701-a91f0712d120/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
 golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
@@ -975,8 +976,9 @@ golang.org/x/net v0.0.0-20211111160137-58aab5ef257a/go.mod h1:9nx3DQGgdP8bBQD5qx
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
-golang.org/x/net v0.0.0-20220225172249-27dd8689420f h1:oA4XRj0qtSt8Yo1Zms0CUlsT3KG69V2UGQWPBxujDmc=
 golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de h1:pZB1TWnKi+o4bENlbzAgLrEbY4RMYmUIRobMcSmfeYc=
+golang.org/x/net v0.0.0-20220325170049-de3da57026de/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@@ -1362,8 +1364,9 @@ google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGj
 google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
 google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
-google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
 google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
+google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw=
+google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
 gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -1389,6 +1392,7 @@ gopkg.in/readline.v1 v1.0.0-20160726135117-62c6fe619375/go.mod h1:lNEQeAhU009zbR
 gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
 gopkg.in/sourcemap.v1 v1.0.5 h1:inv58fC9f9J3TK2Y2R1NPntXEn3/wjWHkonhIUODNTI=
 gopkg.in/sourcemap.v1 v1.0.5/go.mod h1:2RlvNNSMglmRrcvhfuzp4hQHwOtjxlbjX7UPY/GXb78=
+gopkg.in/square/go-jose.v2 v2.5.1/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/square/go-jose.v2 v2.6.0 h1:NGk74WTnPKBNUhNzQX7PYcTLUjoq7mzKk2OKbvwk2iI=
 gopkg.in/square/go-jose.v2 v2.6.0/go.mod h1:M9dMgbHiYLoDGQrXy7OpJDJWiKiU//h+vD76mk0e1AI=
 gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
diff --git a/templates/definition/charger/tapo.yaml b/templates/definition/charger/tapo.yaml
new file mode 100644
index 00000000..d364ad75
--- /dev/null
+++ b/templates/definition/charger/tapo.yaml
@@ -0,0 +1,22 @@
+template: tapo
+products:
+- brand: TP-Link
+  description:
+    generic: Tapo
+group: switchsockets
+params:
+- name: uri
+  required: true
+- name: user
+  required: true
+- name: password
+  required: true
+  mask: true
+- name: standbypower
+  default: 15
+render: |
+  type: tapo
+  uri: {{ .uri }}
+  user: {{ .user }}
+  password: {{ .password }}
+  standbypower: {{ .standbypower }}  # treat as charging above this power
diff --git a/templates/docs/charger/tapo_0.yaml b/templates/docs/charger/tapo_0.yaml
new file mode 100644
index 00000000..392ca4b0
--- /dev/null
+++ b/templates/docs/charger/tapo_0.yaml
@@ -0,0 +1,13 @@
+product:
+  brand: TP-Link
+  description: Tapo
+  group: Schaltbare Steckdosen
+
+render:
+- default: |
+    type: template
+    template: tapo
+    uri:
+    user: # Benutzername um auf das Gerät zuzugreifen 
+    password: # Passwort des Benutzerkontos 
+    standbypower: 15  # Leistung oberhalb des angegebenen Wertes wird als Ladeleistung gewertet  # Optional
\ No newline at end of file
diff --git a/util/request/helper.go b/util/request/helper.go
index 41f4f112..423c5e47 100644
--- a/util/request/helper.go
+++ b/util/request/helper.go
@@ -17,16 +17,19 @@ type Helper struct {
 	*http.Client
 }
 
+// NewClient creates http client with default transport
+func NewClient(log *util.Logger) *http.Client {
+	return &http.Client{
+		Timeout:   Timeout,
+		Transport: NewTripper(log, transport.Default()),
+	}
+}
+
 // NewHelper creates http helper for simplified PUT GET logic
 func NewHelper(log *util.Logger) *Helper {
-	r := &Helper{
-		Client: &http.Client{
-			Timeout:   Timeout,
-			Transport: NewTripper(log, transport.Default()),
-		},
+	return &Helper{
+		Client: NewClient(log),
 	}
-
-	return r
 }
 
 // DoBody executes HTTP request and returns the response body
diff --git a/vehicle/audi-etron.go b/vehicle/audi-etron.go
index b766b2a5..d418b6b3 100644
--- a/vehicle/audi-etron.go
+++ b/vehicle/audi-etron.go
@@ -7,11 +7,10 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/vehicle/audi/etron"
-	"github.com/evcc-io/evcc/vehicle/id"
-	"github.com/evcc-io/evcc/vehicle/vag"
 	"github.com/evcc-io/evcc/vehicle/vag/idkproxy"
 	"github.com/evcc-io/evcc/vehicle/vag/service"
 	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
+	"github.com/evcc-io/evcc/vehicle/vw/id"
 )
 
 // https://github.com/TA2k/ioBroker.vw-connect
@@ -68,7 +67,7 @@ func NewEtronFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
 
 	if err == nil {
-		api := id.NewAPI(log, vag.IDTokenSource(its))
+		api := id.NewAPI(log, its)
 		api.Client.Timeout = cc.Timeout
 
 		v.Provider = id.NewProvider(api, cc.VIN, cc.Cache)
diff --git a/vehicle/audi/etron/api.go b/vehicle/audi/etron/api.go
index 794edbdd..30fb1426 100644
--- a/vehicle/audi/etron/api.go
+++ b/vehicle/audi/etron/api.go
@@ -22,7 +22,7 @@ func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
 	ctx := context.WithValue(
 		context.Background(),
 		oauth2.HTTPClient,
-		request.NewHelper(log).Client,
+		request.NewClient(log),
 	)
 
 	v := &API{
diff --git a/vehicle/audi/etron/params.go b/vehicle/audi/etron/params.go
index 00e6d79c..e3cc6ae1 100644
--- a/vehicle/audi/etron/params.go
+++ b/vehicle/audi/etron/params.go
@@ -17,4 +17,4 @@ var IDKParams = url.Values{
 	"redirect_uri": {"myaudi:///"},
 }
 
-const AZSConfig = "audi"
+const AZSConfig = "myaudi"
diff --git a/vehicle/mercedes/identity.go b/vehicle/mercedes/identity.go
index 2609e78b..dfa42599 100644
--- a/vehicle/mercedes/identity.go
+++ b/vehicle/mercedes/identity.go
@@ -7,7 +7,7 @@ import (
 	"net/http"
 	"net/url"
 
-	"github.com/coreos/go-oidc"
+	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/provider"
 	"github.com/evcc-io/evcc/server/auth"
diff --git a/vehicle/seat-cupra.go b/vehicle/seat-cupra.go
index f3b25989..eccb0352 100644
--- a/vehicle/seat-cupra.go
+++ b/vehicle/seat-cupra.go
@@ -1,15 +1,18 @@
 package vehicle
 
 import (
+	"context"
+	"fmt"
 	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
-	"github.com/evcc-io/evcc/vehicle/cupra"
 	"github.com/evcc-io/evcc/vehicle/seat"
+	"github.com/evcc-io/evcc/vehicle/seat/cupra"
 	"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
 	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
+	"golang.org/x/oauth2"
 )
 
 // Cupra is an api.Vehicle implementation for Seat Cupra cars
@@ -56,12 +59,16 @@ func NewCupraFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		return nil, err
 	}
 
-	ui, err := vwidentity.UserInfo(log, token)
+	ts := trs.TokenSource(token)
+
+	// get OIDC user information
+	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
+	ui, err := vwidentity.Config.NewProvider(ctx).UserInfo(ctx, ts)
 	if err != nil {
-		return nil, err
+		return nil, fmt.Errorf("failed getting user information: %w", err)
 	}
 
-	api := cupra.NewAPI(log, trs.TokenSource(token))
+	api := cupra.NewAPI(log, ts)
 
 	cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) {
 		return api.Vehicles(ui.Subject)
diff --git a/vehicle/cupra/api.go b/vehicle/seat/cupra/api.go
similarity index 100%
rename from vehicle/cupra/api.go
rename to vehicle/seat/cupra/api.go
diff --git a/vehicle/cupra/params.go b/vehicle/seat/cupra/params.go
similarity index 100%
rename from vehicle/cupra/params.go
rename to vehicle/seat/cupra/params.go
diff --git a/vehicle/cupra/provider.go b/vehicle/seat/cupra/provider.go
similarity index 100%
rename from vehicle/cupra/provider.go
rename to vehicle/seat/cupra/provider.go
diff --git a/vehicle/cupra/types.go b/vehicle/seat/cupra/types.go
similarity index 100%
rename from vehicle/cupra/types.go
rename to vehicle/seat/cupra/types.go
diff --git a/vehicle/tesla.go b/vehicle/tesla.go
index 857ec92e..46c7cdc5 100644
--- a/vehicle/tesla.go
+++ b/vehicle/tesla.go
@@ -52,7 +52,7 @@ func NewTeslaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	// authenticated http client with logging injected to the Tesla client
 	log := util.NewLogger("tesla").Redact(cc.Tokens.Access, cc.Tokens.Refresh)
-	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewHelper(log).Client)
+	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
 
 	options := []tesla.ClientOption{tesla.WithToken(&oauth2.Token{
 		AccessToken:  cc.Tokens.Access,
diff --git a/vehicle/tronity.go b/vehicle/tronity.go
index 501dea34..4472c2f7 100644
--- a/vehicle/tronity.go
+++ b/vehicle/tronity.go
@@ -99,7 +99,7 @@ func NewTronityFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		ts = oauth.RefreshTokenSource(&oauth2.Token{}, v)
 	} else {
 		// use provided tokens generated by code flow
-		ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewHelper(log).Client)
+		ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
 		ts = oc.TokenSource(ctx, &oauth2.Token{
 			AccessToken:  cc.Tokens.Access,
 			RefreshToken: cc.Tokens.Refresh,
diff --git a/vehicle/vag/idkproxy/endpoint.go b/vehicle/vag/idkproxy/endpoint.go
index 04836fa8..e5429e42 100644
--- a/vehicle/vag/idkproxy/endpoint.go
+++ b/vehicle/vag/idkproxy/endpoint.go
@@ -10,22 +10,21 @@ import (
 	"strings"
 	"time"
 
+	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/util/urlvalues"
 	"github.com/evcc-io/evcc/vehicle/vag"
-	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
-	"golang.org/x/oauth2"
 )
 
 const (
 	BaseURL   = "https://idkproxy-service.apps.emea.vwapps.io"
-	WellKnown = BaseURL + "/v1/emea/openid-configuration"
+	WellKnown = "https://idkproxy-service.apps.emea.vwapps.io/v1/emea/openid-configuration"
 )
 
-var Endpoint = &oauth2.Endpoint{
-	AuthURL:  vwidentity.Endpoint.AuthURL,
-	TokenURL: BaseURL + "/v1/emea/token",
+var Config = &oidc.ProviderConfig{
+	AuthURL:  "https://identity.vwgroup.io/oidc/v1/authorize",
+	TokenURL: "https://idkproxy-service.apps.emea.vwapps.io/v1/emea/token",
 }
 
 type Service struct {
@@ -71,7 +70,7 @@ func (v *Service) Exchange(q url.Values) (*vag.Token, error) {
 
 	var res vag.Token
 
-	req, err := request.New(http.MethodPost, Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
+	req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{
 		"Content-Type": request.FormContent,
 		"Accept":       request.JSONContent,
 		"x-qmauth":     qmauthNow(),
@@ -95,7 +94,7 @@ func (v *Service) Refresh(token *vag.Token) (*vag.Token, error) {
 
 	var res vag.Token
 
-	req, err := request.New(http.MethodPost, Endpoint.TokenURL, strings.NewReader(data.Encode()), map[string]string{
+	req, err := request.New(http.MethodPost, Config.TokenURL, strings.NewReader(data.Encode()), map[string]string{
 		"Content-Type": request.FormContent,
 		"Accept":       request.JSONContent,
 		"x-qmauth":     qmauthNow(),
diff --git a/vehicle/vag/tokensource.go b/vehicle/vag/tokensource.go
index d6427e1b..d7180170 100644
--- a/vehicle/vag/tokensource.go
+++ b/vehicle/vag/tokensource.go
@@ -117,28 +117,3 @@ func (ts *metaTokenSource) TokenEx() (*Token, error) {
 
 	return token, err
 }
-
-type idTokenSource struct {
-	ts TokenSource
-}
-
-// IDTokenSource provides an oauth2 token source with access_token populated from vag id_token
-func IDTokenSource(ts TokenSource) oauth2.TokenSource {
-	return &idTokenSource{ts}
-}
-
-// Token returns an oauth2 token or an error
-func (ts *idTokenSource) Token() (*oauth2.Token, error) {
-	token, err := ts.ts.TokenEx()
-	if err != nil {
-		return nil, err
-	}
-
-	idToken := &oauth2.Token{
-		TokenType:   token.TokenType,
-		AccessToken: token.IDToken,
-		Expiry:      token.Expiry,
-	}
-
-	return idToken, err
-}
diff --git a/vehicle/vag/vwidentity/endpoint.go b/vehicle/vag/vwidentity/endpoint.go
index 1a8486ab..67c23fcd 100644
--- a/vehicle/vag/vwidentity/endpoint.go
+++ b/vehicle/vag/vwidentity/endpoint.go
@@ -8,31 +8,29 @@ import (
 	"net/url"
 	"strings"
 
-	"github.com/coreos/go-oidc"
+	"github.com/coreos/go-oidc/v3/oidc"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/util/urlvalues"
 	"github.com/evcc-io/evcc/vehicle/vag"
 	"github.com/google/uuid"
 	"golang.org/x/net/publicsuffix"
-	"golang.org/x/oauth2"
 )
 
 const (
-	BaseURL     = "https://identity.vwgroup.io"
-	WellKnown   = "https://identity.vwgroup.io/.well-known/openid-configuration"
-	UserInfoURL = "https://identity-userinfo.vwgroup.io/oidc/userinfo"
+	BaseURL   = "https://identity.vwgroup.io"
+	WellKnown = "https://identity.vwgroup.io/.well-known/openid-configuration"
 )
 
-// TODO use OIDC provider instead
-var Endpoint = &oauth2.Endpoint{
-	AuthURL:  BaseURL + "/oidc/v1/authorize",
-	TokenURL: BaseURL + "/oidc/v1/token",
+var Config = &oidc.ProviderConfig{
+	AuthURL:     "https://identity.vwgroup.io/oidc/v1/authorize",
+	TokenURL:    "https://identity.vwgroup.io/oidc/v1/token",
+	UserInfoURL: "https://identity-userinfo.vwgroup.io/oidc/userinfo",
 }
 
 // Login performs VW identity login with optional code challenge
 func Login(log *util.Logger, q url.Values, user, password string) (url.Values, error) {
-	return LoginWithAuthURL(log, Endpoint.AuthURL, q, user, password)
+	return LoginWithAuthURL(log, Config.AuthURL, q, user, password)
 }
 func LoginWithAuthURL(log *util.Logger, uri string, q url.Values, user, password string) (url.Values, error) {
 	var verify func(url.Values)
@@ -58,21 +56,6 @@ func LoginWithAuthURL(log *util.Logger, uri string, q url.Values, user, password
 	return q, nil
 }
 
-// UserInfo returns the OIDS user information
-func UserInfo(log *util.Logger, token *vag.Token) (oidc.UserInfo, error) {
-	var ui oidc.UserInfo
-
-	req, err := request.New(http.MethodGet, UserInfoURL, nil, map[string]string{
-		"Authorization": "Bearer " + token.AccessToken,
-		"Accept":        "application/json",
-	})
-	if err == nil {
-		err = request.NewHelper(log).DoJSON(req, &ui)
-	}
-
-	return ui, err
-}
-
 type Service struct {
 	*request.Helper
 }
diff --git a/vehicle/vw-id.go b/vehicle/vw-id.go
index deed55e6..b8cc375f 100644
--- a/vehicle/vw-id.go
+++ b/vehicle/vw-id.go
@@ -6,9 +6,9 @@ import (
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
-	"github.com/evcc-io/evcc/vehicle/id"
 	"github.com/evcc-io/evcc/vehicle/vag/loginapps"
 	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
+	"github.com/evcc-io/evcc/vehicle/vw/id"
 )
 
 // https://github.com/TA2k/ioBroker.vw-connect


Test patch:
diff --git a/core/site_test.go b/core/site_test.go
index bbbda955..51b7f896 100644
--- a/core/site_test.go
+++ b/core/site_test.go
@@ -2,27 +2,31 @@ package core
 
 import (
 	"testing"
+
+	"github.com/evcc-io/evcc/util"
 )
 
 func TestSitePower(t *testing.T) {
 	tc := []struct {
-		grid, battery, site float64
+		maxGrid, grid, battery, site float64
 	}{
-		{0, 0, 0},    // silent night
-		{0, 1, 1},    // battery discharging
-		{0, -1, -1},  // battery charging -> negative result cannot occur in reality
-		{1, 0, 1},    // grid import
-		{1, 1, 2},    // grid import + battery discharging
-		{-1, 0, -1},  // grid export
-		{-1, -1, -2}, // grid export + battery charging
+		// {0, 0, 0, 0},    // silent night
+		// {0, 0, 1, 1},    // battery discharging
+		// {0, 0, -1, -1},  // battery charging -> negative result cannot occur in reality
+		// {0, 1, 0, 1},    // grid import
+		// {0, 1, 1, 2},    // grid import + battery discharging
+		// {0, -1, 0, -1},  // grid export
+		// {0, -1, -1, -2}, // grid export + battery charging
+		{0, 1, -1, 0},   // grid import + battery charging -> should not happen
+		{0.5, 1, -1, 1}, // grid import + DC battery charging
 	}
 
+	log := util.NewLogger("foo")
+
 	for _, tc := range tc {
-		res := sitePower(tc.grid, tc.battery, 0)
+		res := sitePower(log, tc.maxGrid, tc.grid, tc.battery, 0)
 		if res != tc.site {
 			t.Errorf("sitePower wanted %.f, got %.f", tc.site, res)
 		}
 	}
 }
-
-// TODO add test case for battery priority charging