Repository: evcc-io/evcc
Pull request #9625 (2023-08-30 18:14:01)

Go-E: use on/off instead of on/auto
Fix https://github.com/evcc-io/evcc/issues/9599. Submitten bitte erst wenn es wieder ruhiger ist und der Change etwas abhängen kann.

This reverts commit 6a58cd4a80cfbf6cfff0f05bd264e410336c7dae.


Patch:
diff --git a/api/actionconfig.go b/api/actionconfig.go
index 0080a7b6..ea13b324 100644
--- a/api/actionconfig.go
+++ b/api/actionconfig.go
@@ -15,7 +15,7 @@ type ActionConfig struct {
 	Mode       *ChargeMode `mapstructure:"mode,omitempty"`       // Charge Mode
 	MinCurrent *float64    `mapstructure:"minCurrent,omitempty"` // Minimum Current
 	MaxCurrent *float64    `mapstructure:"maxCurrent,omitempty"` // Maximum Current
-	MinSoc     *int        `mapstructure:"minSoc,omitempty"`     // Minimum Soc
+	MinSoc_    *int        `mapstructure:"minSoc,omitempty"`     // Minimum Soc (deprecated)
 	TargetSoc  *int        `mapstructure:"targetSoc,omitempty"`  // Target Soc
 	Priority   *int        `mapstructure:"priority,omitempty"`   // Priority
 }
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 8762fda9..09e1e2f9 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -238,9 +238,9 @@ export default {
 		// other information
 		phases: Number,
 		phasesConfigured: Number,
+		phasesActive: Number,
 		minCurrent: Number,
 		maxCurrent: Number,
-		phasesActive: Number,
 		chargeCurrent: Number,
 		connectedDuration: Number,
 		chargeCurrents: Array,
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 9a4cb553..2bc37e2b 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -126,7 +126,7 @@
 										@change="changeMaxCurrent"
 									>
 										<option
-											v-for="{ value, name } in currentOptions(true, 16)"
+											v-for="{ value, name } in maxCurrentOptions"
 											:key="value"
 											:value="value"
 										>
@@ -152,7 +152,7 @@
 										@change="changeMinCurrent"
 									>
 										<option
-											v-for="{ value, name } in currentOptions(false, 6)"
+											v-for="{ value, name } in minCurrentOptions"
 											:key="value"
 											:value="value"
 										>
@@ -183,12 +183,22 @@ import formatter from "../mixins/formatter";
 
 const V = 230;
 
+const range = (start, stop, step = -1) =>
+	Array.from({ length: (stop - start) / step + 1 }, (_, i) => start + i * step);
+
+const insertSorted = (arr, num) => {
+	const uniqueSet = new Set(arr);
+	uniqueSet.add(num);
+	return [...uniqueSet].sort((a, b) => b - a);
+};
+
 export default {
 	name: "LoadpointSettingsModal",
 	mixins: [formatter],
 	props: {
 		id: [String, Number],
 		phasesConfigured: Number,
+		phasesActive: Number,
 		minSoc: Number,
 		maxCurrent: Number,
 		minCurrent: Number,
@@ -216,7 +226,13 @@ export default {
 			return this.fmtKw(this.minCurrent * V * 3);
 		},
 		maxPower: function () {
-			return this.phasesConfigured === 1 ? this.maxPower1p : this.maxPower3p;
+			if (this.phasesConfigured === 3) {
+				return this.maxPower3p;
+			}
+			if (this.phasesConfigured === 1) {
+				return this.maxPower1p;
+			}
+			return this.fmtKw(this.maxCurrent * V * this.phasesActive);
 		},
 		minPower: function () {
 			return this.phasesConfigured === 3 ? this.minPower3p : this.minPower1p;
@@ -227,6 +243,18 @@ export default {
 		showCurrentSettings: function () {
 			return this.$hiddenFeatures();
 		},
+		minCurrentOptions: function () {
+			const opt1 = [...range(Math.floor(this.maxCurrent), 1), 0.5, 0.25, 0.125];
+			// ensure that current value is always included
+			const opt2 = insertSorted(opt1, this.minCurrent);
+			return opt2.map((value) => this.currentOption(value, value === 6));
+		},
+		maxCurrentOptions: function () {
+			const opt1 = range(32, Math.ceil(this.minCurrent));
+			// ensure that current value is always included
+			const opt2 = insertSorted(opt1, this.maxCurrent);
+			return opt2.map((value) => this.currentOption(value, value === 16));
+		},
 	},
 	watch: {
 		maxCurrent: function (value) {
@@ -255,18 +283,12 @@ export default {
 		changePhasesConfigured: function () {
 			this.$emit("phasesconfigured-updated", this.selectedPhases);
 		},
-		currentOptions: function (max, defaultCurrent = 16) {
-			const result = [];
-			const toValue = max ? 32 : this.maxCurrent;
-			const fromValue = max ? this.minCurrent : 1;
-			for (let value = toValue; value >= fromValue; value--) {
-				let name = `${value} A`;
-				if (value === defaultCurrent) {
-					name += ` (${this.$t("main.loadpointSettings.default")})`;
-				}
-				result.push({ value, name });
+		currentOption: function (value, isDefault) {
+			let name = `${this.fmtNumber(value)} A`;
+			if (isDefault) {
+				name += ` (${this.$t("main.loadpointSettings.default")})`;
 			}
-			return result;
+			return { value, name };
 		},
 	},
 };
diff --git a/charger/easee.go b/charger/easee.go
index 941925f8..a5966b5c 100644
--- a/charger/easee.go
+++ b/charger/easee.go
@@ -181,22 +181,9 @@ func NewEasee(user, password, charger string, timeout time.Duration) (*Easee, er
 		err = os.ErrDeadlineExceeded
 	}
 
-	if err == nil {
-		go c.refresh()
-	}
-
 	return c, err
 }
 
-// refresh ensures tokens are refreshed even when not charging for longer time
-func (c *Easee) refresh() {
-	for range time.Tick(5 * time.Minute) {
-		if _, err := c.Client.Transport.(*oauth2.Transport).Source.Token(); err != nil {
-			c.log.ERROR.Println("token refresh:", err)
-		}
-	}
-}
-
 func (c *Easee) chargerSite(charger string) (easee.Site, error) {
 	var res easee.Site
 	uri := fmt.Sprintf("%s/chargers/%s/site", easee.API, charger)
@@ -207,7 +194,7 @@ func (c *Easee) chargerSite(charger string) (easee.Site, error) {
 // connect creates an HTTP connection to the signalR hub
 func (c *Easee) connect(ts oauth2.TokenSource) func() (signalr.Connection, error) {
 	bo := backoff.NewExponentialBackOff()
-	bo.MaxElapsedTime = time.Minute
+	bo.MaxInterval = time.Minute
 
 	return func() (conn signalr.Connection, err error) {
 		defer func() {
@@ -447,7 +434,7 @@ func (c *Easee) inExpectedOpMode(enable bool) bool {
 	c.mux.Lock()
 	defer c.mux.Unlock()
 
-	//start/resume
+	// start/resume
 	if enable {
 		return c.opMode == easee.ModeCharging ||
 			c.opMode == easee.ModeCompleted ||
@@ -455,7 +442,7 @@ func (c *Easee) inExpectedOpMode(enable bool) bool {
 			c.opMode == easee.ModeReadyToCharge
 	}
 
-	//paused/stopped
+	// paused/stopped
 	return c.opMode == easee.ModeAwaitingStart || c.opMode == easee.ModeAwaitingAuthentication
 }
 
diff --git a/charger/easee/identity.go b/charger/easee/identity.go
index 0c24b5e3..35ab9371 100644
--- a/charger/easee/identity.go
+++ b/charger/easee/identity.go
@@ -35,20 +35,29 @@ func (t *Token) AsOAuth2Token() *oauth2.Token {
 // tokenSource is an oauth2.TokenSource
 type tokenSource struct {
 	*request.Helper
+	user, password string
 }
 
 // TokenSource creates an Easee token source
 func TokenSource(log *util.Logger, user, password string) (oauth2.TokenSource, error) {
 	c := &tokenSource{
-		Helper: request.NewHelper(log),
+		Helper:   request.NewHelper(log),
+		user:     user,
+		password: password,
 	}
 
+	token, err := c.authenticate()
+
+	return oauth.RefreshTokenSource(token.AsOAuth2Token(), c), err
+}
+
+func (c *tokenSource) authenticate() (*Token, error) {
 	data := struct {
 		Username string `json:"userName"`
 		Password string `json:"password"`
 	}{
-		Username: user,
-		Password: password,
+		Username: c.user,
+		Password: c.password,
 	}
 
 	uri := fmt.Sprintf("%s/%s", API, "accounts/login")
@@ -58,14 +67,9 @@ func TokenSource(log *util.Logger, user, password string) (oauth2.TokenSource, e
 	}
 
 	var token Token
-	if err := c.DoJSON(req, &token); err != nil {
-		return nil, err
-	}
-
-	oauthToken := token.AsOAuth2Token()
-	ts := oauth2.ReuseTokenSourceWithExpiry(oauthToken, oauth.RefreshTokenSource(oauthToken, c), 15*time.Minute)
+	err = c.DoJSON(req, &token)
 
-	return ts, nil
+	return &token, err
 }
 
 func (c *tokenSource) RefreshToken(oauthToken *oauth2.Token) (*oauth2.Token, error) {
@@ -80,12 +84,15 @@ func (c *tokenSource) RefreshToken(oauthToken *oauth2.Token) (*oauth2.Token, err
 	uri := fmt.Sprintf("%s/%s", API, "accounts/refresh_token")
 	req, err := request.New(http.MethodPost, uri, request.MarshalJSON(data), request.JSONEncoding)
 	if err != nil {
-		return oauthToken, err
+		return nil, err
 	}
 
-	var token Token
+	var token *Token
 	if err := c.DoJSON(req, &token); err != nil {
-		return oauthToken, err
+		// re-login
+		if token, err = c.authenticate(); err != nil {
+			return nil, err
+		}
 	}
 
 	return token.AsOAuth2Token(), nil
diff --git a/charger/go-e.go b/charger/go-e.go
index 8da270e9..cb951da7 100644
--- a/charger/go-e.go
+++ b/charger/go-e.go
@@ -128,7 +128,7 @@ func (c *GoE) Enable(enable bool) error {
 
 	param := map[bool]string{false: "alw", true: "frc"}[c.api.IsV2()]
 	if c.api.IsV2() {
-		b ^= 1
+		b += 1
 	}
 
 	return c.api.Update(fmt.Sprintf("%s=%d", param, b))
diff --git a/charger/hardybarth-salia.go b/charger/hardybarth-salia.go
index 533247d5..7fe10e48 100644
--- a/charger/hardybarth-salia.go
+++ b/charger/hardybarth-salia.go
@@ -119,7 +119,7 @@ func NewSalia(uri string, cache time.Duration) (api.Charger, error) {
 func (wb *Salia) heartbeat() {
 	bo := backoff.NewExponentialBackOff()
 	bo.InitialInterval = 5 * time.Second
-	bo.MaxElapsedTime = time.Minute
+	bo.MaxInterval = time.Minute
 
 	for range time.Tick(30 * time.Second) {
 		if err := backoff.Retry(func() error {
diff --git a/charger/helper.go b/charger/helper.go
index c176cb26..69a7cfad 100644
--- a/charger/helper.go
+++ b/charger/helper.go
@@ -4,6 +4,8 @@ import (
 	"bytes"
 	"fmt"
 	"strings"
+
+	"github.com/evcc-io/evcc/api"
 )
 
 // ensureCharger extracts VIN from list of VINs returned from `list` function
@@ -52,3 +54,15 @@ func ensureChargerWithFeature[Charger, Feature any](
 func bytesAsString(b []byte) string {
 	return strings.TrimSpace(string(bytes.TrimLeft(b, "\x00")))
 }
+
+// verifyEnabled validates the enabled state against the charger status
+func verifyEnabled(c api.Charger, enabled bool) (bool, error) {
+	if enabled {
+		return true, nil
+	}
+
+	status, err := c.Status()
+
+	// always treat charging as enabled
+	return status == api.StatusC, err
+}
diff --git a/charger/openwb.go b/charger/openwb.go
index 49256438..4b0d765d 100644
--- a/charger/openwb.go
+++ b/charger/openwb.go
@@ -177,7 +177,12 @@ func (m *OpenWB) Enable(enable bool) error {
 }
 
 func (m *OpenWB) Enabled() (bool, error) {
-	return m.enabled, nil
+	enabled, err := verifyEnabled(m, m.enabled)
+	if err == nil {
+		m.enabled = enabled
+	}
+
+	return enabled, err
 }
 
 func (m *OpenWB) Status() (api.ChargeStatus, error) {
diff --git a/charger/twc3.go b/charger/twc3.go
index faaacdff..c086ca9b 100644
--- a/charger/twc3.go
+++ b/charger/twc3.go
@@ -30,31 +30,31 @@ func init() {
 
 // Vitals is the /api/1/vitals response
 type Vitals struct {
-	ContactorClosed   bool    `json:"contactor_closed"`    //false
-	VehicleConnected  bool    `json:"vehicle_connected"`   //false
-	SessionS          int64   `json:"session_s"`           //0
-	GridV             float64 `json:"grid_v"`              //230.1
-	GridHz            float64 `json:"grid_hz"`             //49.928
-	VehicleCurrentA   float64 `json:"vehicle_current_a"`   //0.1
-	CurrentAA         float64 `json:"currentA_a"`          //0.0
-	CurrentBA         float64 `json:"currentB_a"`          //0.1
-	CurrentCA         float64 `json:"currentC_a"`          //0.0
-	CurrentNA         float64 `json:"currentN_a"`          //0.0
-	VoltageAV         float64 `json:"voltageA_v"`          //0.0
-	VoltageBV         float64 `json:"voltageB_v"`          //0.0
-	VoltageCV         float64 `json:"voltageC_v"`          //0.0
-	RelayCoilV        float64 `json:"relay_coil_v"`        //11.8
-	PcbaTempC         float64 `json:"pcba_temp_c"`         //19.2
-	HandleTempC       float64 `json:"handle_temp_c"`       //15.3
-	McuTempC          float64 `json:"mcu_temp_c"`          //25.1
-	UptimeS           int     `json:"uptime_s"`            //831580
+	ContactorClosed   bool    `json:"contactor_closed"`    // false
+	VehicleConnected  bool    `json:"vehicle_connected"`   // false
+	SessionS          int64   `json:"session_s"`           // 0
+	GridV             float64 `json:"grid_v"`              // 230.1
+	GridHz            float64 `json:"grid_hz"`             // 49.928
+	VehicleCurrentA   float64 `json:"vehicle_current_a"`   // 0.1
+	CurrentAA         float64 `json:"currentA_a"`          // 0.0
+	CurrentBA         float64 `json:"currentB_a"`          // 0.1
+	CurrentCA         float64 `json:"currentC_a"`          // 0.0
+	CurrentNA         float64 `json:"currentN_a"`          // 0.0
+	VoltageAV         float64 `json:"voltageA_v"`          // 0.0
+	VoltageBV         float64 `json:"voltageB_v"`          // 0.0
+	VoltageCV         float64 `json:"voltageC_v"`          // 0.0
+	RelayCoilV        float64 `json:"relay_coil_v"`        // 11.8
+	PcbaTempC         float64 `json:"pcba_temp_c"`         // 19.2
+	HandleTempC       float64 `json:"handle_temp_c"`       // 15.3
+	McuTempC          float64 `json:"mcu_temp_c"`          // 25.1
+	UptimeS           int     `json:"uptime_s"`            // 831580
 	InputThermopileUv float64 `json:"input_thermopile_uv"` //-233
-	ProxV             float64 `json:"prox_v"`              //0.0
-	PilotHighV        float64 `json:"pilot_high_v"`        //11.9
-	PilotLowV         float64 `json:"pilot_low_v"`         //11.9
-	SessionEnergyWh   float64 `json:"session_energy_wh"`   //22864.699
-	ConfigStatus      int     `json:"config_status"`       //5
-	EvseState         int     `json:"evse_state"`          //1
+	ProxV             float64 `json:"prox_v"`              // 0.0
+	PilotHighV        float64 `json:"pilot_high_v"`        // 11.9
+	PilotLowV         float64 `json:"pilot_low_v"`         // 11.9
+	SessionEnergyWh   float64 `json:"session_energy_wh"`   // 22864.699
+	ConfigStatus      int     `json:"config_status"`       // 5
+	EvseState         int     `json:"evse_state"`          // 1
 	CurrentAlerts     []any   `json:"current_alerts"`      //[]
 }
 
@@ -91,7 +91,12 @@ func NewTwc3FromConfig(other map[string]interface{}) (api.Charger, error) {
 
 // Enabled implements the api.Charger interface
 func (c *Twc3) Enabled() (bool, error) {
-	return c.enabled, nil
+	enabled, err := verifyEnabled(c, c.enabled)
+	if err == nil {
+		c.enabled = enabled
+	}
+
+	return enabled, err
 }
 
 // Enable implements the api.Charger interface
diff --git a/charger/wallbe.go b/charger/wallbe.go
index e927abad..8b5a1f4d 100644
--- a/charger/wallbe.go
+++ b/charger/wallbe.go
@@ -3,7 +3,6 @@ package charger
 import (
 	"encoding/binary"
 	"fmt"
-	"time"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
@@ -165,20 +164,6 @@ func (wb *Wallbe) maxCurrentMillis(current float64) error {
 	return err
 }
 
-var _ api.ChargeTimer = (*Wallbe)(nil)
-
-// ChargingTime implements the api.ChargeTimer interface
-func (wb *Wallbe) ChargingTime() (time.Duration, error) {
-	b, err := wb.conn.ReadInputRegisters(wbRegChargeTime, 2)
-	if err != nil {
-		return 0, err
-	}
-
-	// 2 words, least significant word first
-	secs := uint64(b[1]) | uint64(b[0])<<8 | uint64(b[3])<<16 | uint64(b[2])<<24
-	return time.Duration(secs) * time.Second, nil
-}
-
 // currentPower implements the api.Meter interface
 func (wb *Wallbe) currentPower() (float64, error) {
 	b, err := wb.conn.ReadInputRegisters(wbRegPower, 2)
diff --git a/cmd/setup.go b/cmd/setup.go
index 6e8bb3e1..c32772ab 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -277,8 +277,12 @@ func vehicleInstance(cc config.Named) (api.Vehicle, error) {
 }
 
 func configureVehicles(static []config.Named) error {
+	var mu sync.Mutex
 	g, _ := errgroup.WithContext(context.Background())
 
+	// stable-sort vehicles by name
+	devs1 := make([]config.Device[api.Vehicle], 0, len(static))
+
 	for i, cc := range static {
 		if cc.Name == "" {
 			return fmt.Errorf("cannot create vehicle %d: missing name", i+1)
@@ -291,7 +295,11 @@ func configureVehicles(static []config.Named) error {
 				return fmt.Errorf("cannot create vehicle '%s': %w", cc.Name, err)
 			}
 
-			return config.Vehicles().Add(config.NewStaticDevice(cc, instance))
+			mu.Lock()
+			defer mu.Unlock()
+			devs1 = append(devs1, config.NewStaticDevice(cc, instance))
+
+			return nil
 		})
 	}
 
@@ -302,8 +310,7 @@ func configureVehicles(static []config.Named) error {
 	}
 
 	// stable-sort vehicles by id
-	var mu sync.Mutex
-	devs := make([]config.ConfigurableDevice[api.Vehicle], 0, len(configurable))
+	devs2 := make([]config.ConfigurableDevice[api.Vehicle], 0, len(configurable))
 
 	for _, conf := range configurable {
 		conf := conf
@@ -316,7 +323,7 @@ func configureVehicles(static []config.Named) error {
 
 			mu.Lock()
 			defer mu.Unlock()
-			devs = append(devs, config.NewConfigurableDevice(conf, instance))
+			devs2 = append(devs2, config.NewConfigurableDevice(conf, instance))
 
 			return nil
 		})
@@ -326,11 +333,21 @@ func configureVehicles(static []config.Named) error {
 		return err
 	}
 
-	slices.SortFunc(devs, func(i, j config.ConfigurableDevice[api.Vehicle]) int {
+	slices.SortFunc(devs1, func(i, j config.Device[api.Vehicle]) int {
+		return cmp.Compare(strings.ToLower(i.Config().Name), strings.ToLower(j.Config().Name))
+	})
+
+	for _, dev := range devs1 {
+		if err := config.Vehicles().Add(dev); err != nil {
+			return err
+		}
+	}
+
+	slices.SortFunc(devs2, func(i, j config.ConfigurableDevice[api.Vehicle]) int {
 		return cmp.Compare(i.ID(), j.ID())
 	})
 
-	for _, dev := range devs {
+	for _, dev := range devs2 {
 		if err := config.Vehicles().Add(dev); err != nil {
 			return err
 		}
diff --git a/core/loadpoint.go b/core/loadpoint.go
index 9e0e438a..bec4bc3e 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -317,12 +317,13 @@ func (lp *Loadpoint) collectDefaults() {
 		*actionCfg.Mode = lp.GetMode()
 		*actionCfg.MinCurrent = lp.GetMinCurrent()
 		*actionCfg.MaxCurrent = lp.GetMaxCurrent()
-		*actionCfg.MinSoc = lp.GetMinSoc()
-		*actionCfg.TargetSoc = lp.GetTargetSoc()
 		*actionCfg.Priority = lp.GetPriority()
 	} else {
 		lp.log.ERROR.Printf("error allocating action config: %v", err)
 	}
+	// deprecated: do not reapply deprecated lp config values
+	actionCfg.TargetSoc = nil
+	actionCfg.MinSoc_ = nil
 }
 
 // requestUpdate requests site to update this loadpoint
@@ -552,6 +553,9 @@ func (lp *Loadpoint) applyAction(actionCfg api.ActionConfig) {
 	if min := actionCfg.MinCurrent; min != nil && *min >= *lp.onDisconnect.MinCurrent {
 		lp.SetMinCurrent(*min)
 	}
+	if actionCfg.TargetSoc != nil {
+		lp.SetTargetSoc(*actionCfg.TargetSoc)
+	}
 	if max := actionCfg.MaxCurrent; max != nil && *max <= *lp.onDisconnect.MaxCurrent {
 		lp.SetMaxCurrent(*max)
 	}
@@ -646,6 +650,7 @@ func (lp *Loadpoint) syncCharger() error {
 		}
 		enabled = true // treat as enabled when charging
 		lp.elapseGuard()
+		lp.elapsePVTimer()
 		return nil
 	}
 
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 9994460d..f395573e 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -206,6 +206,11 @@ tariffs:
     # token: <token>
     # zone: DE
 
+    # type: ngeso # National Grid Electricity System Operator data (United Kingdom only) https://carbonintensity.org.uk/
+    # provides national data if both region and postcode are omitted - do not supply both at the same time!
+    # region: 1 # optional, coarser than using a postcode - see https://api.carbonintensity.org.uk/ for full list
+    # postcode: SW1A1AA # optional
+
 # mqtt message broker
 mqtt:
   # broker: localhost:1883
diff --git a/go.mod b/go.mod
index 428accc6..89b2c2ae 100644
--- a/go.mod
+++ b/go.mod
@@ -61,7 +61,7 @@ require (
 	github.com/libp2p/zeroconf/v2 v2.2.0
 	github.com/lorenzodonini/ocpp-go v0.16.1-0.20230416102136-782920b66533
 	github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40
-	github.com/mabunixda/wattpilot v1.5.0
+	github.com/mabunixda/wattpilot v1.6.0
 	github.com/manifoldco/promptui v0.9.0
 	github.com/mergermarket/go-pkcs7 v0.0.0-20170926155232-153b18ea13c9
 	github.com/mitchellh/go-homedir v1.1.0
diff --git a/go.sum b/go.sum
index d57afef7..7619f475 100644
--- a/go.sum
+++ b/go.sum
@@ -762,8 +762,8 @@ github.com/lorenzodonini/ocpp-go v0.16.1-0.20230416102136-782920b66533/go.mod h1
 github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40 h1:EnfXoSqDfSNJv0VBNqY/88RNnhSGYkrHaO0mmFGbVsc=
 github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40/go.mod h1:vy1vK6wD6j7xX6O6hXe621WabdtNkou2h7uRtTfRMyg=
 github.com/lyft/protoc-gen-validate v0.0.13/go.mod h1:XbGvPuh87YZc5TdIa2/I4pLk0QoUACkjt2znoq26NVQ=
-github.com/mabunixda/wattpilot v1.5.0 h1:+0dINGlqdEhS/hUnkPaJGBMvUwg9LOjbON60vIpYwaE=
-github.com/mabunixda/wattpilot v1.5.0/go.mod h1:NK48nAFsHsP70SwcVfH9OaYBND0UON9i3YrScrWRloA=
+github.com/mabunixda/wattpilot v1.6.0 h1:GTx0Ld0srXX+yfHCyfL7FUJ4NMlpAcuIDik3se+nax4=
+github.com/mabunixda/wattpilot v1.6.0/go.mod h1:NK48nAFsHsP70SwcVfH9OaYBND0UON9i3YrScrWRloA=
 github.com/magiconair/properties v1.8.6/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
diff --git a/i18n/en.toml b/i18n/en.toml
index 0a357913..6430c9e6 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -34,7 +34,7 @@ vehicles = "My Vehicles"
 failed = "failed"
 label = "Status"
 running = "validating…"
-success = "successfull"
+success = "successful"
 unknown = "unknown"
 validate = "validate"
 
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 0686060c..b7bb81d4 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -401,7 +401,7 @@ mi = "mylios"
 
 [startupError]
 configFile = "Naudojamas konfiguracijos failas:"
-configuration = "Konfigūracija"
+configuration = "Konfiguracija"
 description = "Patikrinkite konfigūracijos failą. Jei klaidos žinutė jums nepadėjo, atsakymų ieškokite mūsų {0}."
 discussions = "GitHub Diskusijose"
 fixAndRestart = "Pabandykite Ištaisyti klaidą ir perkrauti serverį."
diff --git a/i18n/pl.toml b/i18n/pl.toml
index f2286596..ae72e0bc 100644
--- a/i18n/pl.toml
+++ b/i18n/pl.toml
@@ -18,6 +18,39 @@ low = "kiedy nieco pełny"
 medium = "kiedy w połowie pełny"
 never = "tylko z wystarczającą nadwyżką"
 
+[config]
+
+[config.form]
+example = "Przykład"
+optional = "opcjonalny"
+
+[config.main]
+addVehicle = "Dodaj pojazd"
+edit = "edytować"
+title = "Konfiguracja"
+vehicles = "Moje pojazdy"
+
+[config.validation]
+failed = "nie udało się"
+label = "Status"
+running = "weryfikacja…"
+success = "udany"
+unknown = "nieznany"
+validate = "zweryfikować"
+
+[config.vehicle]
+cancel = "Anulować"
+delete = "Usuń samochód"
+generic = "Inne integracje"
+offline = "Samochód ogólny"
+online = "Pojazdy z API online"
+save = "Zapisz"
+scooter = "Skuter"
+template = "Producent"
+titleAdd = "Dodaj samochód"
+titleEdit = "Edytuj pojazd"
+validateSave = "Sprawdź i zapisz"
+
 [footer]
 
 [footer.community]
@@ -366,7 +399,7 @@ mi = "mile"
 
 [startupError]
 configFile = "Używany plik konfiguracyjny:"
-configuration = "Konfiguracja"
+configuration = "Konfig"
 description = "Sprawdź swój plik konfiguracyjny. Jeśli komunikat o błędzie nie pomoże, zapoznaj się z naszym {0}."
 discussions = "Dyskusje GitHub"
 fixAndRestart = "Proszę rozwiązać problem i zrestartować serwer."
diff --git a/i18n/sv.toml b/i18n/sv.toml
index a1f75cef..c60dcf5a 100644
--- a/i18n/sv.toml
+++ b/i18n/sv.toml
@@ -1,3 +1,18 @@
+[batterySettings]
+batteryLevel = "Batterinivå"
+capacity = "{energy} av {total}"
+
+[config]
+
+[config.form]
+example = "Exempel"
+optional = "valfri"
+
+[config.vehicle]
+cancel = "Avbryt"
+delete = "Radera fordon"
+save = "Spara"
+
 [footer]
 
 [footer.community]
diff --git a/i18n/tr.toml b/i18n/tr.toml
new file mode 100644
index 00000000..7bff32e9
--- /dev/null
+++ b/i18n/tr.toml
@@ -0,0 +1,2 @@
+[batterySettings]
+batteryLevel = "Batarya seviyesi"
diff --git a/meter/dsmr.go b/meter/dsmr.go
index 1e5324cf..d7b4ee40 100644
--- a/meter/dsmr.go
+++ b/meter/dsmr.go
@@ -139,9 +139,8 @@ func NewDsmr(uri, energy string, timeout time.Duration) (api.Meter, error) {
 // based on https://github.com/basvdlei/gotsmart/blob/master/gotsmart.go
 func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 	log := util.NewLogger("dsmr")
-	backoff := backoff.NewExponentialBackOff()
-	backoff.InitialInterval = time.Second
-	backoff.MaxInterval = 5 * time.Minute
+	bo := backoff.NewExponentialBackOff()
+	bo.MaxInterval = 5 * time.Minute
 
 	handle := func(op string, err error) {
 		log.ERROR.Printf("%s: %v", op, err)
@@ -161,7 +160,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 			conn, err = m.connect()
 			if err != nil {
 				handle("connect", err)
-				time.Sleep(backoff.NextBackOff().Truncate(time.Second))
+				time.Sleep(bo.NextBackOff().Truncate(time.Second))
 				continue
 			}
 
@@ -169,7 +168,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 		}
 
 		if b, err := reader.Peek(1); err == nil {
-			backoff.Reset()
+			bo.Reset()
 
 			if string(b) != "/" {
 				log.DEBUG.Printf("ignoring garbage character: %c\n", b)
@@ -178,7 +177,7 @@ func (m *Dsmr) run(conn net.Conn, done chan struct{}) {
 			}
 		} else {
 			handle("peek", err)
-			time.Sleep(backoff.NextBackOff().Truncate(time.Second))
+			time.Sleep(bo.NextBackOff().Truncate(time.Second))
 			continue
 		}
 
diff --git a/meter/rct.go b/meter/rct.go
index 35bfaf63..ce317440 100644
--- a/meter/rct.go
+++ b/meter/rct.go
@@ -83,8 +83,8 @@ func NewRCT(uri, usage string, cache time.Duration, capacity func() float64) (ap
 	}
 
 	bo := backoff.NewExponentialBackOff()
-	bo.MaxElapsedTime = time.Second
 	bo.InitialInterval = 10 * time.Millisecond
+	bo.MaxInterval = time.Second
 
 	m := &RCT{
 		usage: strings.ToLower(usage),
diff --git a/tariff/helper.go b/tariff/helper.go
index d20bfb3a..2f9e0485 100644
--- a/tariff/helper.go
+++ b/tariff/helper.go
@@ -8,7 +8,7 @@ import (
 
 func newBackoff() backoff.BackOff {
 	bo := backoff.NewExponentialBackOff()
-	bo.InitialInterval = 5 * time.Second
-	bo.MaxElapsedTime = time.Minute
+	bo.InitialInterval = time.Second
+	bo.MaxInterval = time.Minute
 	return bo
 }
diff --git a/tariff/ngeso.go b/tariff/ngeso.go
new file mode 100644
index 00000000..5db8de87
--- /dev/null
+++ b/tariff/ngeso.go
@@ -0,0 +1,128 @@
+package tariff
+
+import (
+	"errors"
+	"net/http"
+	"sync"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/tariff/ngeso"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+	"slices"
+)
+
+type Ngeso struct {
+	mux            sync.Mutex
+	log            *util.Logger
+	regionId       string
+	regionPostcode string
+	data           api.Rates
+	updated        time.Time
+}
+
+var _ api.Tariff = (*Ngeso)(nil)
+
+func init() {
+	registry.Add("ngeso", NewNgesoFromConfig)
+}
+
+func NewNgesoFromConfig(other map[string]interface{}) (api.Tariff, error) {
+	var cc struct {
+		Region   string
+		Postcode string
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	if cc.Region != "" && cc.Postcode != "" {
+		return nil, errors.New("cannot define region and postcode simultaneously")
+	}
+
+	t := &Ngeso{
+		log:            util.NewLogger("ngeso"),
+		regionId:       cc.Region,
+		regionPostcode: cc.Postcode,
+	}
+
+	done := make(chan error)
+	go t.run(done)
+	err := <-done
+
+	return t, err
+}
+
+func (t *Ngeso) run(done chan error) {
+	var once sync.Once
+	client := request.NewHelper(t.log)
+	bo := newBackoff()
+
+	// Use national results by default.
+	var tReq ngeso.CarbonForecastRequest
+	tReq = ngeso.ConstructNationalForecastRequest()
+
+	// If a region is available, use that.
+	// These should never be set simultaneously (see NewNgesoFromConfig), but in the rare case that they are,
+	// use the postcode as the preferred method.
+	if t.regionId != "" {
+		tReq = ngeso.ConstructRegionalForecastByIDRequest(t.regionId)
+	}
+	if t.regionPostcode != "" {
+		tReq = ngeso.ConstructRegionalForecastByPostcodeRequest(t.regionPostcode)
+	}
+
+	// Data updated by ESO every half hour, but we only need data every hour to stay current.
+	for ; true; <-time.Tick(time.Hour) {
+		var carbonResponse ngeso.CarbonForecastResponse
+		if err := backoff.Retry(func() error {
+			var err error
+			carbonResponse, err = tReq.DoRequest(client)
+
+			// Consider whether errors.As would be more appropriate if this needs to start dealing with wrapped errors.
+			if se, ok := err.(request.StatusError); ok && se.HasStatus(http.StatusBadRequest) {
+				// Catch cases where we're sending completely incorrect data (usually the result of a bad region).
+				return backoff.Permanent(se)
+			}
+			return err
+		}, bo); err != nil {
+			once.Do(func() { done <- err })
+
+			t.log.ERROR.Println(err)
+			continue
+		}
+
+		once.Do(func() { close(done) })
+
+		t.mux.Lock()
+		t.updated = time.Now()
+
+		t.data = make(api.Rates, 0, len(carbonResponse.Results()))
+		for _, r := range carbonResponse.Results() {
+			ar := api.Rate{
+				Start: r.ValidityStart.Time,
+				End:   r.ValidityEnd.Time,
+				// Use the forecasted rate, as the actual rate is only available for historical data
+				Price: r.Intensity.Forecast,
+			}
+			t.data = append(t.data, ar)
+		}
+
+		t.mux.Unlock()
+	}
+}
+
+// Rates implements the api.Tariff interface
+func (t *Ngeso) Rates() (api.Rates, error) {
+	t.mux.Lock()
+	defer t.mux.Unlock()
+	return slices.Clone(t.data), outdatedError(t.updated, time.Hour)
+}
+
+// Type implements the api.Tariff interface
+func (t *Ngeso) Type() api.TariffType {
+	return api.TariffTypeCo2
+}
diff --git a/tariff/ngeso/api.go b/tariff/ngeso/api.go
new file mode 100644
index 00000000..f93ed14e
--- /dev/null
+++ b/tariff/ngeso/api.go
@@ -0,0 +1,139 @@
+// Package ngeso implements the carbonintensity.org.uk Grid CO2 tracking service, which provides CO2 forecasting for the UK at a national and regional level.
+// This service is provided by the National Grid Electricity System Operator (NGESO).
+package ngeso
+
+import (
+	"errors"
+	"fmt"
+	"github.com/evcc-io/evcc/util/request"
+	"time"
+)
+
+// BaseURI is the root path that the API is accessed from.
+const BaseURI = "https://api.carbonintensity.org.uk/"
+
+// ForecastNationalURI defines the location of the national forecast.
+// Replace the first %s with the RFC3339 timestamp to fetch from.
+const ForecastNationalURI = BaseURI + "intensity/%s/fw48h"
+
+// ForecastRegionalByIdURI defines the location of the regional forecast determined by Region ID.
+// Replace the first %s with the RFC3339 timestamp to fetch from, and the second with the appropriate Region ID.
+const ForecastRegionalByIdURI = BaseURI + "regional/intensity/%s/fw48h/regionid/%s"
+
+// ForecastRegionalByPostcodeURI defines the location of the regional forecast determined by a given postcode.
+// Replace the first %s with the RFC3339 timestamp to fetch from, and the second with the appropriate postcode.
+const ForecastRegionalByPostcodeURI = BaseURI + "regional/intensity/%s/fw48h/postcode/%s"
+
+// ConstructNationalForecastRequest returns a request object to be used when calling the national API.
+func ConstructNationalForecastRequest() *CarbonForecastNationalRequest {
+	return &CarbonForecastNationalRequest{}
+}
+
+// ConstructRegionalForecastByIDRequest returns a validly formatted, fully qualified URI to the forecast valid for the given region.
+func ConstructRegionalForecastByIDRequest(r string) *CarbonForecastRegionalRequest {
+	return &CarbonForecastRegionalRequest{regionid: r}
+}
+
+// ConstructRegionalForecastByPostcodeRequest returns a validly formatted, fully qualified URI to the forecast valid for the given postcode.
+func ConstructRegionalForecastByPostcodeRequest(p string) *CarbonForecastRegionalRequest {
+	return &CarbonForecastRegionalRequest{postcode: p}
+}
+
+type CarbonForecastRequest interface {
+	URI() (string, error)
+	DoRequest(helper *request.Helper) (CarbonForecastResponse, error)
+}
+
+type CarbonForecastNationalRequest struct{}
+
+func (r *CarbonForecastNationalRequest) URI() (string, error) {
+	return fmt.Sprintf(ForecastNationalURI, time.Now().UTC().Format(time.RFC3339)), nil
+}
+
+func (r *CarbonForecastNationalRequest) DoRequest(client *request.Helper) (CarbonForecastResponse, error) {
+	uri, err := r.URI()
+	if err != nil {
+		return nil, err
+	}
+	var res NationalIntensityResult
+	err = client.GetJSON(uri, res)
+	return res, err
+}
+
+type CarbonForecastRegionalRequest struct {
+	regionid string
+	postcode string
+}
+
+func (r *CarbonForecastRegionalRequest) URI() (string, error) {
+	currentTs := time.Now().UTC().Format(time.RFC3339)
+	// Prefer postcode to Region ID
+	if r.postcode != "" {
+		return fmt.Sprintf(ForecastRegionalByPostcodeURI, currentTs, r.postcode), nil
+	}
+	if r.regionid != "" {
+		return fmt.Sprintf(ForecastRegionalByIdURI, currentTs, r.regionid), nil
+	}
+
+	// One of the region identifiers must be supplied, if neither are then just return an error
+	return "", ErrRegionalRequestInvalidFormat
+}
+
+func (r *CarbonForecastRegionalRequest) DoRequest(client *request.Helper) (CarbonForecastResponse, error) {
+	uri, err := r.URI()
+	if err != nil {
+		return nil, err
+	}
+	res := &RegionalIntensityResult{}
+	err = client.GetJSON(uri, &res)
+	return res, err
+}
+
+type CarbonForecastResponse interface {
+	Results() []CarbonIntensityForecastEntry
+}
+
+// RegionalIntensityResult is returned by Regional requests. It wraps all data inside a data element.
+// Because that makes sense, and makes all of this SO much easier. /s
+type RegionalIntensityResult struct {
+	Data RegionalIntensityResultData `json:"data"`
+}
+
+func (r RegionalIntensityResult) Results() []CarbonIntensityForecastEntry {
+	return r.Data.Rates
+}
+
+// RegionalIntensityResultData is returned by Regional requests. It includes a bit of extra data.
+type RegionalIntensityResultData struct {
+	RegionId  int                            `json:"regionid"`
+	DNORegion string                         `json:"dnoregion"`
+	ShortName string                         `json:"shortname"`
+	Rates     []CarbonIntensityForecastEntry `json:"data"`
+}
+
+// NationalIntensityResult is returned either as a sub-element of a Regional request, or as the main result of a National request.
+type NationalIntensityResult struct {
+	Rates []CarbonIntensityForecastEntry `json:"data"`
+}
+
+// Results is a helper / interface function to return the current rate data.
+func (r NationalIntensityResult) Results() []CarbonIntensityForecastEntry {
+	return r.Rates
+}
+
+type CarbonIntensityForecastEntry struct {
+	ValidityStart shortRFC3339Timestamp `json:"from"`
+	ValidityEnd   shortRFC3339Timestamp `json:"to"`
+	Intensity     CarbonIntensity       `json:"intensity"`
+}
+
+type CarbonIntensity struct {
+	// The forecasted rate in gCO2/kWh
+	Forecast float64 `json:"forecast"`
+	// The rate recorded when this slot occurred - only available historically, otherwise nil
+	Actual float64 `json:"actual"`
+	// A human-readable representation of the level of emissions (e.g "low", "moderate")
+	Index string `json:"index"`
+}
+
+var ErrRegionalRequestInvalidFormat = errors.New("regional request object missing region")
diff --git a/tariff/ngeso/shortrfc3339.go b/tariff/ngeso/shortrfc3339.go
new file mode 100644
index 00000000..5f4051bc
--- /dev/null
+++ b/tariff/ngeso/shortrfc3339.go
@@ -0,0 +1,35 @@
+package ngeso
+
+import (
+	"strings"
+	"time"
+)
+
+// shortRFC3339Timestamp is a custom JSON encoder / decoder for shortened RFC3339-compliant timestamps (those without seconds).
+// Please don't ask why NGESO uses this format instead of standards-compliant RFC3339. 🇬🇧
+type shortRFC3339Timestamp struct {
+	time.Time
+}
+
+const s3339Layout = "2006-01-02T15:04Z"
+
+func (ct *shortRFC3339Timestamp) UnmarshalJSON(b []byte) (err error) {
+	s := strings.Trim(string(b), "\"")
+	if s == "null" {
+		ct.Time = time.Time{}
+		return
+	}
+	ct.Time, err = time.Parse(s3339Layout, s)
+	return
+}
+
+func (ct *shortRFC3339Timestamp) MarshalJSON() ([]byte, error) {
+	if ct.Time.IsZero() {
+		return []byte("null"), nil
+	}
+	return []byte(ct.Time.Format(s3339Layout)), nil
+}
+
+func (ct *shortRFC3339Timestamp) IsSet() bool {
+	return !ct.IsZero()
+}
diff --git a/vehicle/embed.go b/vehicle/embed.go
index deff59fb..d203519b 100644
--- a/vehicle/embed.go
+++ b/vehicle/embed.go
@@ -14,6 +14,16 @@ type embed struct {
 	OnIdentify   api.ActionConfig `mapstructure:"onIdentify"`
 }
 
+// Title implements the api.Vehicle interface
+func (v *embed) fromVehicle(title string, capacity float64) {
+	if v.Title_ == "" {
+		v.Title_ = title
+	}
+	if v.Capacity_ == 0 {
+		v.Capacity_ = capacity
+	}
+}
+
 // Title implements the api.Vehicle interface
 func (v *embed) Title() string {
 	return v.Title_
diff --git a/vehicle/seat-cupra.go b/vehicle/seat-cupra.go
index a967e9fe..b894e953 100644
--- a/vehicle/seat-cupra.go
+++ b/vehicle/seat-cupra.go
@@ -10,7 +10,7 @@ import (
 	"github.com/evcc-io/evcc/util/request"
 	"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/service"
 	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
 	"golang.org/x/oauth2"
 )
@@ -51,20 +51,11 @@ func NewCupraFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	log := util.NewLogger("cupra").Redact(cc.User, cc.Password, cc.VIN)
 
-	// get initial VW identity id_token
-	q, err := vwidentity.Login(log, seat.AuthParams, cc.User, cc.Password)
+	ts, err := service.TokenRefreshServiceTokenSource(log, seat.TRSParams, seat.AuthParams, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
 	}
 
-	trs := tokenrefreshservice.New(log, seat.TRSParams)
-	token, err := trs.Exchange(q)
-	if err != nil {
-		return nil, err
-	}
-
-	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)
@@ -74,12 +65,21 @@ func NewCupraFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	api := cupra.NewAPI(log, ts)
 
-	cc.VIN, err = ensureVehicle(cc.VIN, func() ([]string, error) {
-		return api.Vehicles(ui.Subject)
-	})
+	vehicle, err := ensureVehicleEx(
+		cc.VIN, func() ([]cupra.Vehicle, error) {
+			return api.Vehicles(ui.Subject)
+		},
+		func(v cupra.Vehicle) string {
+			return v.VIN
+		},
+	)
+
+	if err == nil {
+		v.fromVehicle(vehicle.VehicleNickname, 0)
+	}
 
 	if err == nil {
-		v.Provider = cupra.NewProvider(api, ui.Subject, cc.VIN, cc.Cache)
+		v.Provider = cupra.NewProvider(api, ui.Subject, vehicle.VIN, cc.Cache)
 	}
 
 	return v, err
diff --git a/vehicle/seat.go b/vehicle/seat.go
index a905e699..8709ff83 100644
--- a/vehicle/seat.go
+++ b/vehicle/seat.go
@@ -8,7 +8,6 @@ import (
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/vehicle/seat"
 	"github.com/evcc-io/evcc/vehicle/vag/service"
-	"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
 	"github.com/evcc-io/evcc/vehicle/vw"
 )
 
@@ -51,12 +50,12 @@ func NewSeatFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	log := util.NewLogger("seat").Redact(cc.User, cc.Password, cc.VIN)
 
-	trs := tokenrefreshservice.New(log, seat.TRSParams)
-	ts, err := service.MbbTokenSource(log, trs, seat.AuthClientID, seat.AuthParams, cc.User, cc.Password)
+	trs, err := service.TokenRefreshServiceTokenSource(log, seat.TRSParams, seat.AuthParams, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
 	}
 
+	ts := service.MbbTokenSource(log, trs, seat.AuthClientID)
 	api := vw.NewAPI(log, ts, seat.Brand, seat.Country)
 	api.Client.Timeout = cc.Timeout
 
diff --git a/vehicle/seat/cupra/api.go b/vehicle/seat/cupra/api.go
index 1697cc27..2b60c152 100644
--- a/vehicle/seat/cupra/api.go
+++ b/vehicle/seat/cupra/api.go
@@ -38,19 +38,15 @@ func NewAPI(log *util.Logger, ts oauth2.TokenSource) *API {
 }
 
 // Vehicles implements the /vehicles response
-func (v *API) Vehicles(userID string) (res []string, err error) {
-	var vehicles struct {
+func (v *API) Vehicles(userID string) ([]Vehicle, error) {
+	var res struct {
 		Vehicles []Vehicle
 	}
 
 	uri := fmt.Sprintf("%s/v1/users/%s/garage/vehicles", BaseURL, userID)
-	err = v.GetJSON(uri, &vehicles)
-
-	for _, v := range vehicles.Vehicles {
-		res = append(res, v.VIN)
-	}
+	err := v.GetJSON(uri, &res)
 
-	return res, err
+	return res.Vehicles, err
 }
 
 // Status implements the /status response
diff --git a/vehicle/skoda-enyaq.go b/vehicle/skoda-enyaq.go
index df427bd7..03d700d4 100644
--- a/vehicle/skoda-enyaq.go
+++ b/vehicle/skoda-enyaq.go
@@ -50,7 +50,7 @@ func NewEnyaqFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 	var err error
 	log := util.NewLogger("enyaq").Redact(cc.User, cc.Password, cc.VIN)
 
-	// use Skoda credentials to resolve list of vehicles
+	// use Skoda api to resolve list of vehicles
 	ts, err := service.TokenRefreshServiceTokenSource(log, skoda.TRSParams, skoda.AuthParams, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
@@ -66,11 +66,8 @@ func NewEnyaqFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 		},
 	)
 
-	if v.Title_ == "" {
-		v.Title_ = vehicle.Name
-	}
-	if v.Capacity_ == 0 {
-		v.Capacity_ = float64(vehicle.Specification.Battery.CapacityInKWh)
+	if err == nil {
+		v.fromVehicle(vehicle.Name, float64(vehicle.Specification.Battery.CapacityInKWh))
 	}
 
 	// use Connect credentials to build provider
diff --git a/vehicle/skoda.go b/vehicle/skoda.go
index c08410c9..83c5849f 100644
--- a/vehicle/skoda.go
+++ b/vehicle/skoda.go
@@ -8,7 +8,6 @@ import (
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/vehicle/skoda"
 	"github.com/evcc-io/evcc/vehicle/vag/service"
-	"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
 	"github.com/evcc-io/evcc/vehicle/vw"
 )
 
@@ -51,20 +50,33 @@ func NewSkodaFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	log := util.NewLogger("skoda").Redact(cc.User, cc.Password, cc.VIN)
 
-	trs := tokenrefreshservice.New(log, skoda.TRSParams)
-	ts, err := service.MbbTokenSource(log, trs, skoda.AuthClientID, skoda.AuthParams, cc.User, cc.Password)
+	// use Skoda api to resolve list of vehicles
+	trs, err := service.TokenRefreshServiceTokenSource(log, skoda.TRSParams, skoda.AuthParams, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
 	}
 
-	api := vw.NewAPI(log, ts, skoda.Brand, skoda.Country)
+	api := skoda.NewAPI(log, trs)
 	api.Client.Timeout = cc.Timeout
 
-	cc.VIN, err = ensureVehicle(cc.VIN, api.Vehicles)
+	vehicle, err := ensureVehicleEx(
+		cc.VIN, api.Vehicles,
+		func(v skoda.Vehicle) string {
+			return v.VIN
+		},
+	)
 
 	if err == nil {
-		if err = api.HomeRegion(cc.VIN); err == nil {
-			v.Provider = vw.NewProvider(api, cc.VIN, cc.Cache)
+		v.fromVehicle(vehicle.Name, float64(vehicle.Specification.Battery.CapacityInKWh))
+	}
+
+	if err == nil {
+		ts := service.MbbTokenSource(log, trs, skoda.AuthClientID)
+		api := vw.NewAPI(log, ts, skoda.Brand, skoda.Country)
+		api.Client.Timeout = cc.Timeout
+
+		if err = api.HomeRegion(vehicle.VIN); err == nil {
+			v.Provider = vw.NewProvider(api, vehicle.VIN, cc.Cache)
 		}
 	}
 
diff --git a/vehicle/vag/service/mbb.go b/vehicle/vag/service/mbb.go
index 6257bedb..c2767400 100644
--- a/vehicle/vag/service/mbb.go
+++ b/vehicle/vag/service/mbb.go
@@ -6,26 +6,14 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/vehicle/vag"
 	"github.com/evcc-io/evcc/vehicle/vag/mbb"
-	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
 )
 
 // MbbTokenSource creates a refreshing token source for use with the MBB api.
 // Once the MBB token expires, it is recreated from the token exchanger (either TokenRefreshService or IDK)
-func MbbTokenSource(log *util.Logger, tox vag.TokenExchanger, clientID string, q url.Values, user, password string) (vag.TokenSource, error) {
-	q, err := vwidentity.Login(log, q, user, password)
-	if err != nil {
-		return nil, err
-	}
-
-	token, err := tox.Exchange(q)
-	if err != nil {
-		return nil, err
-	}
-
-	trs := tox.TokenSource(token)
+func MbbTokenSource(log *util.Logger, trs vag.TokenSource, clientID string) vag.TokenSource {
 	mbb := mbb.New(log, clientID)
 
-	mts := vag.MetaTokenSource(func() (*vag.Token, error) {
+	return vag.MetaTokenSource(func() (*vag.Token, error) {
 		// get TRS token from refreshing TRS token source
 		itoken, err := trs.TokenEx()
 		if err != nil {
@@ -42,6 +30,4 @@ func MbbTokenSource(log *util.Logger, tox vag.TokenExchanger, clientID string, q
 
 		// produce tokens from refresh MBB token source
 	}, mbb.TokenSource)
-
-	return mts, nil
 }
diff --git a/vehicle/vag/service/tokenrefreshservice.go b/vehicle/vag/service/tokenrefreshservice.go
index 2ac19614..e818b936 100644
--- a/vehicle/vag/service/tokenrefreshservice.go
+++ b/vehicle/vag/service/tokenrefreshservice.go
@@ -4,12 +4,12 @@ import (
 	"net/url"
 
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/vehicle/vag"
 	"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
 	"github.com/evcc-io/evcc/vehicle/vag/vwidentity"
-	"golang.org/x/oauth2"
 )
 
-func TokenRefreshServiceTokenSource(log *util.Logger, data, q url.Values, user, password string) (oauth2.TokenSource, error) {
+func TokenRefreshServiceTokenSource(log *util.Logger, data, q url.Values, user, password string) (vag.TokenSource, error) {
 	q, err := vwidentity.Login(log, q, user, password)
 	if err != nil {
 		return nil, err
diff --git a/vehicle/vw.go b/vehicle/vw.go
index 5108ff6b..f2db0016 100644
--- a/vehicle/vw.go
+++ b/vehicle/vw.go
@@ -7,7 +7,6 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	"github.com/evcc-io/evcc/vehicle/vag/service"
-	"github.com/evcc-io/evcc/vehicle/vag/tokenrefreshservice"
 	"github.com/evcc-io/evcc/vehicle/vw"
 )
 
@@ -50,12 +49,12 @@ func NewVWFromConfig(other map[string]interface{}) (api.Vehicle, error) {
 
 	log := util.NewLogger("vw").Redact(cc.User, cc.Password, cc.VIN)
 
-	trs := tokenrefreshservice.New(log, vw.TRSParams)
-	ts, err := service.MbbTokenSource(log, trs, vw.AuthClientID, vw.AuthParams, cc.User, cc.Password)
+	trs, err := service.TokenRefreshServiceTokenSource(log, vw.TRSParams, vw.AuthParams, cc.User, cc.Password)
 	if err != nil {
 		return nil, err
 	}
 
+	ts := service.MbbTokenSource(log, trs, vw.AuthClientID)
 	api := vw.NewAPI(log, ts, vw.Brand, vw.Country)
 	api.Client.Timeout = cc.Timeout
 


Test patch:
diff --git a/api/actionconfig_test.go b/api/actionconfig_test.go
index 1286b8ef..e2e401d6 100644
--- a/api/actionconfig_test.go
+++ b/api/actionconfig_test.go
@@ -18,10 +18,8 @@ func TestMerge(t *testing.T) {
 
 	now := ModeNow
 	two := 2
-	three := 3
 	new := ActionConfig{
 		Mode:     &now,
-		MinSoc:   &three,
 		Priority: &two,
 	}
 
@@ -38,7 +36,6 @@ func TestMerge(t *testing.T) {
 	assert.Equal(t, dst, ActionConfig{
 		Mode:       &now,
 		MinCurrent: &six,
-		MinSoc:     &three,
 		Priority:   &two,
 	}, "new wrong")
 }
diff --git a/charger/wallbe_test.go b/charger/wallbe_test.go
deleted file mode 100644
index 496614e2..00000000
--- a/charger/wallbe_test.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package charger
-
-import (
-	"testing"
-
-	"github.com/evcc-io/evcc/api"
-)
-
-func TestWallbeLegacy(t *testing.T) {
-	wbc, err := NewWallbeFromConfig(map[string]interface{}{"legacy": true})
-	if err != nil {
-		t.Error(err)
-	}
-
-	wb, ok := wbc.(*Wallbe)
-	if !ok {
-		t.Error("unexpected type")
-	}
-
-	if wb.factor != 1 {
-		t.Errorf("invalid factor: %d", wb.factor)
-	}
-
-	if _, ok = wbc.(api.ChargeTimer); !ok {
-		t.Error("missing charge timer api")
-	}
-
-	if _, ok = wbc.(api.Diagnosis); !ok {
-		t.Error("missing diagnosis api")
-	}
-}
-
-func TestWallbeEx(t *testing.T) {
-	wbc, err := NewWallbeFromConfig(map[string]interface{}{"legacy": false})
-	if err != nil {
-		t.Error(err)
-	}
-
-	if _, ok := wbc.(api.ChargerEx); !ok {
-		t.Error("missing ChargerEx api")
-	}
-
-	if _, ok := wbc.(api.ChargeTimer); !ok {
-		t.Error("missing ChargeTimer api")
-	}
-
-	if _, ok := wbc.(api.Diagnosis); !ok {
-		t.Error("missing Diagnosis api")
-	}
-}
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index 33b240bf..9e7473d8 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -148,7 +148,6 @@ func TestDefaultVehicle(t *testing.T) {
 	ctrl := gomock.NewController(t)
 
 	mode := api.ModePV
-	minsoc := 20
 	targetsoc := 80
 
 	dflt := mock.NewMockVehicle(ctrl)
@@ -158,7 +157,6 @@ func TestDefaultVehicle(t *testing.T) {
 	dflt.EXPECT().Phases().AnyTimes()
 	dflt.EXPECT().OnIdentified().Return(api.ActionConfig{
 		Mode:      &mode,
-		MinSoc:    &minsoc,
 		TargetSoc: &targetsoc,
 	}).AnyTimes()
 
@@ -175,13 +173,10 @@ func TestDefaultVehicle(t *testing.T) {
 	// ondisconnect
 	off := api.ModeOff
 	zero := 0
-	hundred := 100
 	onDisconnect := api.ActionConfig{
 		Mode:       &off,
 		MinCurrent: &lp.MinCurrent,
 		MaxCurrent: &lp.MaxCurrent,
-		MinSoc:     &zero,
-		TargetSoc:  &hundred,
 		Priority:   &zero,
 	}
 
@@ -227,13 +222,12 @@ func TestDefaultVehicle(t *testing.T) {
 func TestApplyVehicleDefaults(t *testing.T) {
 	ctrl := gomock.NewController(t)
 
-	newConfig := func(mode api.ChargeMode, minCurrent, maxCurrent float64, minSoc, targetSoc int) api.ActionConfig {
+	newConfig := func(mode api.ChargeMode, minCurrent, maxCurrent float64, targetSoc *int) api.ActionConfig {
 		return api.ActionConfig{
 			Mode:       &mode,
 			MinCurrent: &minCurrent,
 			MaxCurrent: &maxCurrent,
-			MinSoc:     &minSoc,
-			TargetSoc:  &targetSoc,
+			TargetSoc:  targetSoc,
 		}
 	}
 
@@ -244,10 +238,11 @@ func TestApplyVehicleDefaults(t *testing.T) {
 	}
 
 	// onIdentified config
-	oi := newConfig(api.ModePV, 7, 15, 1, 99)
+	targetSoc := 99
+	oi := newConfig(api.ModePV, 7, 15, &targetSoc)
 
 	// onDefault config
-	od := newConfig(api.ModeOff, 6, 16, 2, 98)
+	od := newConfig(api.ModeOff, 6, 16, nil)
 
 	vehicle := mock.NewMockVehicle(ctrl)
 	vehicle.EXPECT().Title().Return("it's me").AnyTimes()
@@ -266,7 +261,7 @@ func TestApplyVehicleDefaults(t *testing.T) {
 	lp.ResetOnDisconnect = true
 
 	// check loadpoint default currents can't be violated
-	lp.applyAction(newConfig(*od.Mode, 5, 17, *od.MinSoc, *od.TargetSoc))
+	lp.applyAction(newConfig(*od.Mode, 5, 17, od.TargetSoc))
 	assertConfig(lp, od)
 
 	// vehicle identified
diff --git a/tariff/ngeso/shortrfc3339_test.go b/tariff/ngeso/shortrfc3339_test.go
new file mode 100644
index 00000000..b27c53cb
--- /dev/null
+++ b/tariff/ngeso/shortrfc3339_test.go
@@ -0,0 +1,30 @@
+package ngeso
+
+import (
+	"testing"
+)
+
+var (
+	tTsBytes      = []byte("2023-04-20T14:30Z")
+	tTsStringRepr = "2023-04-20 14:30:00 +0000 UTC"
+)
+
+func TestMarshalling(t *testing.T) {
+	// Firstly, test that we can unmarshal into a struct.
+	ct := shortRFC3339Timestamp{}
+	if err := ct.UnmarshalJSON(tTsBytes); err != nil {
+		t.Fatal(err)
+	}
+	if ct.Time.String() != tTsStringRepr {
+		t.Errorf("time did not unmarshal successfully: got %s, expected %s", ct.Time.String(), tTsStringRepr)
+	}
+
+	// Now test remarshalling.
+	res, err := ct.MarshalJSON()
+	if err != nil {
+		t.Fatal(err)
+	}
+	if string(res) != string(tTsBytes) {
+		t.Errorf("time did not marshal successfully: got %s, expected %s", res, tTsBytes)
+	}
+}