Repository: evcc-io/evcc
Pull request #11941 (2024-01-29 16:10:24)

Battery control: hold battery while min soc not reached
This PR extents the battery control mechanism to hold the battery also while `minSoc` is not reached, corresponding to #11511.

From my point of view this case should be treated in the same way as when `smartCostActive` or `api.ModeNow` is true.

premultiply: Hatten wir schon mal diskutiert.
Fazit war: Nicht sinnvoll da unnötig teurer Netzbezug generiert wird.
andig: Seems you could do the same by physically configuring `minsoc` on your battery. Hence nothing we should do in software.
rivengh: > Seems you could do the same by physically configuring `minsoc` on your battery. Hence nothing we should do in software.

No, this is a different story. I am referring to the vehicles minSoc not the home battery soc.
But anyway, it seems that you already discussed this topic and decided to don't include this case.
andig: I don't think this was discussed before, more that me (and probably @premultiply) misunderstood the intention of this PR. 

@premultiply es geht um Sofortladen bis `minSoc`- den Aspekt hatten mir m.E. noch nicht diskutiert?

/cc @naltatis 
naltatis: Mir ist das in der eigenen Nutzung auch schon aufgefallen. Glaube auch, dass wir? (aber auf jeden Fall ich) über Min-Soc einfach nicht gesprochen/nachgedacht haben. Eigentlich finde ich die Änderung hier konsistent mit unserer anderen "Schnell-Laden im PV-Modus" Funktionen. Also "weil Preis günstig" oder "weil Abfahrtszeit gesetzt". Da setzen wir ja auch Hold. Der Fall Schnelladen "weil Fahrzeug Akkustand niedrig" passt für mich genau in diese Reihe und wäre erwartungskonformes Verhalten.

@premultiply Aussage, dass das ggf. teurer ist, mag je nach Umständen stimmen. Aber daher machen wir "Batterycontrol" ja zu nem Opt-In Feature, für, dass sich der Nutzer aktiv entscheiden muss. 

Kurz: ich wäre dafür, das wieder aufzumachen.
andig: LGTM. @premultiply? Hier geht es um Schnelladen während das Fahrzeug seinen minSoc nicht erreicht hat- also analog Sofortladen. Ok?
premultiply: Ich enthalte mich dazu. 🤷🏻‍♂️
Macht wenn ihr denkt dass das besser ist.
andig: Also doch kein Mißverständnis?

> Fazit war: Nicht sinnvoll da unnötig teurer Netzbezug generiert wird.

Könntest Du nochmal sagen wo der Unterschied zu "Schnelladen" ist?
premultiply: Schnellladen ist etwas was man ggf. aktiv auswählen muss bzw. kann.
Das minSoc-Laden wird hingegen nachdrücklich automatisch zu jedem Zeitpunkt gestartet wenn das Fahrzeug nur angeschlossen wird und sonst im PV-Modus nicht geladen würde. Das kann auch bei sehr, sehr teurem Netzstrom sein.

Letztendlich ist es aber wahrscheinlich reine Geschmackssache.
rivengh: Danke, dass ihr den PR doch nochmal in Betracht zieht.

Ich sehe es genau so wie @naltatis geschrieben hat, dass es einfach konsistent zu den anderen Funktionen ist:

> Eigentlich finde ich die Änderung hier konsistent mit unserer anderen "Schnell-Laden im PV-Modus" Funktionen. Also "weil Preis günstig" oder "weil Abfahrtszeit gesetzt". Da setzen wir ja auch Hold. Der Fall Schnelladen "weil Fahrzeug Akkustand niedrig" passt für mich genau in diese Reihe und wäre erwartungskonformes Verhalten.

Ich kann den Einwand von @premultiply natürlich auch verstehen, jedoch trifft, dass imho auf alles Schnelllade-Modi zu. Außerdem kann ja auch das minSoc-Laden vom Nutzer explizit aktiviert oder deaktiviert werden. Ist es aktiviert, lebe ich immer mit dem Risiko, dass nach dem Anstecken aus dem Netz geladen wird.
GrimmiMeloni: Sieht für mich auch schlüssig aus.
Ich nutze das Vehicle min-Soc Feature bisher nicht, daher hatte ich das nicht auf dem Radar. Für mich ist das aber genau das gleiche wie Modus "schnell". Sollten wir wie gezeigt mit reinnehmen.

Patch:
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 5d633f7c..68853330 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -34,6 +34,15 @@
 									{{ $t("main.loadpointSettings.phasesConfigured.label") }}
 								</label>
 								<div class="col-sm-8 pe-0">
+									<p v-if="!phases1p3p" class="mt-0 mb-2">
+										<small>
+											{{
+												$t(
+													"main.loadpointSettings.phasesConfigured.no1p3pSupport"
+												)
+											}}</small
+										>
+									</p>
 									<div
 										v-for="phases in phasesOptions"
 										:key="phases"
@@ -179,20 +188,24 @@ export default {
 			return [PHASES_3, PHASES_1];
 		},
 		maxPower: function () {
-			if (this.phasesConfigured === PHASES_AUTO) {
-				return this.maxPowerPhases(3);
-			}
-			if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
-				return this.maxPowerPhases(this.phasesConfigured);
+			if (this.phases1p3p) {
+				if (this.phasesConfigured === PHASES_AUTO) {
+					return this.maxPowerPhases(3);
+				}
+				if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
+					return this.maxPowerPhases(this.phasesConfigured);
+				}
 			}
 			return this.fmtKw(this.maxCurrent * V * this.phasesActive);
 		},
 		minPower: function () {
-			if (this.phasesConfigured === PHASES_AUTO) {
-				return this.minPowerPhases(1);
-			}
-			if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
-				return this.minPowerPhases(this.phasesConfigured);
+			if (this.phases1p3p) {
+				if (this.phasesConfigured === PHASES_AUTO) {
+					return this.minPowerPhases(1);
+				}
+				if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
+					return this.minPowerPhases(this.phasesConfigured);
+				}
 			}
 			return this.fmtKw(this.minCurrent * V * this.phasesActive);
 		},
diff --git a/charger/cfos.go b/charger/cfos.go
index fd413ea1..dd7a9dc3 100644
--- a/charger/cfos.go
+++ b/charger/cfos.go
@@ -11,12 +11,13 @@ import (
 )
 
 const (
-	cfosRegRelaySelect  = 8087 // Holding
-	cfosRegStatus       = 8092 // Holding
-	cfosRegMaxCurrent   = 8093 // Holding
-	cfosRegEnable       = 8094 // Holding
-	cfosRegLastRfid     = 8096 // Holding
-	cfosRegSolarEnabled = 8113 // Holding
+	cfosRegDisconnectCp = 8086
+	cfosRegRelaySelect  = 8087
+	cfosRegStatus       = 8092
+	cfosRegMaxCurrent   = 8093
+	cfosRegEnable       = 8094
+	cfosRegLastRfid     = 8096
+	cfosRegSolarEnabled = 8113
 )
 
 // CfosPowerBrain is an charger implementation for cFos PowerBrain wallboxes.
@@ -149,6 +150,14 @@ func (wb *CfosPowerBrain) phases1p3p(phases int) error {
 	return err
 }
 
+var _ api.Resurrector = (*CfosPowerBrain)(nil)
+
+// WakeUp implements the api.Resurrector interface
+func (wb *CfosPowerBrain) WakeUp() error {
+	_, err := wb.conn.WriteSingleRegister(cfosRegDisconnectCp, uint16(5))
+	return err
+}
+
 var _ api.Identifier = (*CfosPowerBrain)(nil)
 
 // Identify implements the api.Identifier interface
diff --git a/cmd/settings-get.go b/cmd/settings-get.go
new file mode 100644
index 00000000..325db4a0
--- /dev/null
+++ b/cmd/settings-get.go
@@ -0,0 +1,62 @@
+package cmd
+
+import (
+	"fmt"
+	"os"
+	"os/signal"
+	"regexp"
+	"syscall"
+	"text/tabwriter"
+
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/spf13/cobra"
+)
+
+// settingsGetCmd represents the configure command
+var settingsGetCmd = &cobra.Command{
+	Use:   "get",
+	Short: "Get configuration settings",
+	Run:   runSettingsGet,
+	Args:  cobra.MaximumNArgs(1),
+}
+
+func init() {
+	settingsCmd.AddCommand(settingsGetCmd)
+}
+
+func runSettingsGet(cmd *cobra.Command, args []string) {
+	// load config
+	if err := loadConfigFile(&conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	// setup environment
+	if err := configureEnvironment(cmd, conf); err != nil {
+		log.FATAL.Fatal(err)
+	}
+
+	var re *regexp.Regexp
+	if len(args) > 0 {
+		re = regexp.MustCompile(args[0])
+	}
+
+	w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', 0)
+	for _, s := range settings.All() {
+		if re != nil && !re.MatchString(s.Key) {
+			continue
+		}
+
+		fmt.Fprintf(w, "%s:\t%s\n", s.Key, s.Value)
+	}
+	w.Flush()
+
+	// catch signals
+	go func() {
+		signalC := make(chan os.Signal, 1)
+		signal.Notify(signalC, os.Interrupt, syscall.SIGTERM)
+
+		<-signalC // wait for signal
+
+		os.Exit(1)
+	}()
+}
diff --git a/cmd/settings.go b/cmd/settings.go
new file mode 100644
index 00000000..7efd6ba4
--- /dev/null
+++ b/cmd/settings.go
@@ -0,0 +1,15 @@
+package cmd
+
+import (
+	"github.com/spf13/cobra"
+)
+
+// settingsCmd represents the configure command
+var settingsCmd = &cobra.Command{
+	Use:   "settings",
+	Short: "Manage configuration settings",
+}
+
+func init() {
+	rootCmd.AddCommand(settingsCmd)
+}
diff --git a/core/keys/site.go b/core/keys/site.go
index 63bc5483..a30ef717 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -32,9 +32,10 @@ const (
 	Vehicles              = "vehicles"
 
 	// meters
-	BatteryMeters = "batteryMeters"
-	GridMeter     = "GridMeter"
+	GridMeter     = "gridMeter"
 	PvMeters      = "pvMeters"
+	BatteryMeters = "batteryMeters"
+	AuxMeters     = "auxMeters"
 
 	// battery settings
 	BatteryCapacity         = "batteryCapacity"
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 19c967df..829b0b27 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -120,8 +120,8 @@ type API interface {
 	// charge progress
 	//
 
-	// GetPlanActive returns the active state of the planner
-	GetPlanActive() bool
+	// IsFastChargingActive indicates if fast charging with maximum power is active
+	IsFastChargingActive() bool
 	// GetRemainingDuration is the estimated remaining charging duration
 	GetRemainingDuration() time.Duration
 	// GetRemainingEnergy is the remaining charge energy in Wh
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 58ef9b27..4ba9a862 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -265,20 +265,6 @@ func (mr *MockAPIMockRecorder) GetPlan(arg0, arg1 any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlan", reflect.TypeOf((*MockAPI)(nil).GetPlan), arg0, arg1)
 }
 
-// GetPlanActive mocks base method.
-func (m *MockAPI) GetPlanActive() bool {
-	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "GetPlanActive")
-	ret0, _ := ret[0].(bool)
-	return ret0
-}
-
-// GetPlanActive indicates an expected call of GetPlanActive.
-func (mr *MockAPIMockRecorder) GetPlanActive() *gomock.Call {
-	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPlanActive", reflect.TypeOf((*MockAPI)(nil).GetPlanActive))
-}
-
 // GetPlanEnergy mocks base method.
 func (m *MockAPI) GetPlanEnergy() (time.Time, float64) {
 	m.ctrl.T.Helper()
@@ -407,6 +393,20 @@ func (mr *MockAPIMockRecorder) HasChargeMeter() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "HasChargeMeter", reflect.TypeOf((*MockAPI)(nil).HasChargeMeter))
 }
 
+// IsFastChargingActive mocks base method.
+func (m *MockAPI) IsFastChargingActive() bool {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "IsFastChargingActive")
+	ret0, _ := ret[0].(bool)
+	return ret0
+}
+
+// IsFastChargingActive indicates an expected call of IsFastChargingActive.
+func (mr *MockAPIMockRecorder) IsFastChargingActive() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "IsFastChargingActive", reflect.TypeOf((*MockAPI)(nil).IsFastChargingActive))
+}
+
 // PublishEffectiveValues mocks base method.
 func (m *MockAPI) PublishEffectiveValues() {
 	m.ctrl.T.Helper()
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 4606eac5..26fad311 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -395,11 +395,12 @@ func (lp *Loadpoint) GetMaxPower() float64 {
 	return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
 }
 
-// GetPlanActive returns the active state of the planner
-func (lp *Loadpoint) GetPlanActive() bool {
-	lp.Lock()
-	defer lp.Unlock()
-	return lp.planActive
+// IsFastChargingActive indicates if fast charging with maximum power is active
+func (lp *Loadpoint) IsFastChargingActive() bool {
+	lp.RLock()
+	defer lp.RUnlock()
+
+	return lp.mode == api.ModeNow || lp.planActive || lp.minSocNotReached()
 }
 
 // GetRemainingDuration is the estimated remaining charging duration
diff --git a/core/site.go b/core/site.go
index 2e248060..c09327b9 100644
--- a/core/site.go
+++ b/core/site.go
@@ -247,6 +247,9 @@ func (site *Site) restoreMeters() {
 	if v, err := settings.String(keys.BatteryMeters); err == nil && v != "" {
 		site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, strings.Split(v, ",")...)
 	}
+	if v, err := settings.String(keys.AuxMeters); err == nil && v != "" {
+		site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, strings.Split(v, ",")...)
+	}
 }
 
 // restoreSettings restores site settings
diff --git a/core/site_api.go b/core/site_api.go
index 74f0c5cb..8a35ee05 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -9,6 +9,7 @@ import (
 	"github.com/evcc-io/evcc/core/loadpoint"
 	"github.com/evcc-io/evcc/core/site"
 	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/evcc-io/evcc/util/config"
 )
 
 var _ site.API = (*Site)(nil)
@@ -21,6 +22,24 @@ const (
 	PlannerTariff = "planner"
 )
 
+// isConfigurable checks if the meter is configurable
+func isConfigurable(ref string) bool {
+	dev, _ := config.Meters().ByName(ref)
+	_, ok := dev.(config.ConfigurableDevice[api.Meter])
+	return ok
+}
+
+// filterConfigurable filters configurable meters
+func filterConfigurable(ref []string) []string {
+	var res []string
+	for _, r := range ref {
+		if isConfigurable(r) {
+			res = append(res, r)
+		}
+	}
+	return res
+}
+
 // GetTitle returns the title
 func (site *Site) GetTitle() string {
 	site.RLock()
@@ -68,8 +87,7 @@ func (site *Site) SetPVMeterRefs(ref []string) {
 	defer site.Unlock()
 
 	site.Meters.PVMetersRef = ref
-	// site.publish("siteGridMeterRef", meter)
-	settings.SetString(keys.PvMeters, strings.Join(ref, ","))
+	settings.SetString(keys.PvMeters, strings.Join(filterConfigurable(ref), ","))
 }
 
 // GetBatteryMeterRefs returns the BatteryMeterRef
@@ -85,8 +103,23 @@ func (site *Site) SetBatteryMeterRefs(ref []string) {
 	defer site.Unlock()
 
 	site.Meters.BatteryMetersRef = ref
-	// site.publish("siteGridMeterRef", meter)
-	settings.SetString(keys.BatteryMeters, strings.Join(ref, ","))
+	settings.SetString(keys.BatteryMeters, strings.Join(filterConfigurable(ref), ","))
+}
+
+// GetAuxMeterRefs returns the AuxMeterRef
+func (site *Site) GetAuxMeterRefs() []string {
+	site.RLock()
+	defer site.RUnlock()
+	return site.Meters.AuxMetersRef
+}
+
+// SetAuxMeterRefs sets the AuxMeterRef
+func (site *Site) SetAuxMeterRefs(ref []string) {
+	site.Lock()
+	defer site.Unlock()
+
+	site.Meters.AuxMetersRef = ref
+	settings.SetString(keys.AuxMeters, strings.Join(filterConfigurable(ref), ","))
 }
 
 // Loadpoints returns the list loadpoints
diff --git a/core/site_battery.go b/core/site_battery.go
index c0eb71f8..c688f547 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -32,7 +32,7 @@ func (site *Site) SetBatteryMode(batMode api.BatteryMode) {
 
 func (site *Site) determineBatteryMode(loadpoints []loadpoint.API, smartCostActive bool) api.BatteryMode {
 	for _, lp := range loadpoints {
-		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.GetMode() == api.ModeNow || lp.GetPlanActive()) {
+		if lp.GetStatus() == api.StatusC && (smartCostActive || lp.IsFastChargingActive()) {
 			return api.BatteryHold
 		}
 	}
diff --git a/i18n/de.toml b/i18n/de.toml
index 1f4d26eb..ccaef28d 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -233,6 +233,7 @@ label = "Min. Ladung %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Phasen"
+no1p3pSupport = "Wie ist deine Wallbox angeschlossen?"
 phases_0 = "automatischer Wechsel"
 phases_1 = "1-phasig"
 phases_1_hint = "({min} bis {max})"
diff --git a/i18n/en.toml b/i18n/en.toml
index 87eac223..8b5bc0e2 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -274,6 +274,7 @@ label = "Min. charge %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Phases"
+no1p3pSupport = "How is your charger connected?"
 phases_0 = "auto-switching"
 phases_1 = "1 phase"
 phases_1_hint = "({min} to {max})"
diff --git a/server/db/settings/setting.go b/server/db/settings/setting.go
index d45b71db..2d3451ad 100644
--- a/server/db/settings/setting.go
+++ b/server/db/settings/setting.go
@@ -1,6 +1,7 @@
 package settings
 
 import (
+	"cmp"
 	"encoding/json"
 	"errors"
 	"slices"
@@ -43,6 +44,18 @@ func Persist() error {
 	return db.Instance.Save(settings).Error
 }
 
+func All() []setting {
+	mu.RLock()
+	defer mu.RUnlock()
+
+	res := slices.Clone(settings)
+	slices.SortFunc(res, func(i, j setting) int {
+		return cmp.Compare(i.Key, j.Key)
+	})
+
+	return res
+}
+
 func SetString(key string, val string) {
 	mu.Lock()
 	defer mu.Unlock()
diff --git a/templates/definition/meter/fronius-solarapi-v1.yaml b/templates/definition/meter/fronius-solarapi-v1.yaml
index 463543ab..00d0526c 100644
--- a/templates/definition/meter/fronius-solarapi-v1.yaml
+++ b/templates/definition/meter/fronius-solarapi-v1.yaml
@@ -12,7 +12,7 @@ params:
   - name: capacity
     advanced: true
   - name: user
-    required: false
+    default: customer
   - name: password
     required: false
 render: |
@@ -32,6 +32,7 @@ render: |
     source: http
     uri: http://{{ .host }}/solar_api/v1/GetPowerFlowRealtimeData.fcgi
     jq: .Body.Data.Inverters."1".SOC
+  {{- if .password }}
   batterymode:
     source: switch
     switch:
@@ -71,6 +72,7 @@ render: |
           user: {{ .user }}
           password: {{ .password }}
         body: '{"timeofuse":[]}'
+  {{- end }}
   {{- if .capacity }}
   capacity: {{ .capacity }} # kWh
   {{- end }}
diff --git a/vehicle/tesla-command.go b/vehicle/tesla-command.go
index bb5605a6..b550bdb9 100644
--- a/vehicle/tesla-command.go
+++ b/vehicle/tesla-command.go
@@ -1,7 +1,6 @@
 package vehicle
 
 import (
-	"context"
 	"os"
 	"time"
 
@@ -9,8 +8,6 @@ import (
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/request"
 	vc "github.com/evcc-io/evcc/vehicle/tesla-vehicle-command"
-	"github.com/golang-jwt/jwt/v5"
-	"golang.org/x/oauth2"
 )
 
 // TeslaCommand is an api.Vehicle implementation for Tesla cars using the official Tesla vehicle-command api.
@@ -55,23 +52,12 @@ func NewTeslaCommandFromConfig(other map[string]interface{}) (api.Vehicle, error
 		return nil, err
 	}
 
-	if t := cc.Tokens.Access; t != "" {
-		var claims jwt.RegisteredClaims
-		if _, _, err := jwt.NewParser().ParseUnverified(t, &claims); err != nil {
-			return nil, err
-		}
-		token.Expiry = claims.ExpiresAt.Time
-	}
-
 	log := util.NewLogger("tesla-command").Redact(
 		cc.Tokens.Access, cc.Tokens.Refresh,
 		vc.OAuth2Config.ClientID, vc.OAuth2Config.ClientSecret,
 	)
 
-	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(log))
-	ts := vc.OAuth2Config.TokenSource(ctx, token)
-
-	identity, err := vc.NewIdentity(log, ts)
+	identity, err := vc.NewIdentity(log, token)
 	if err != nil {
 		return nil, err
 	}
diff --git a/vehicle/tesla-vehicle-command/helper.go b/vehicle/tesla-vehicle-command/helper.go
index 8a042072..3178c3b5 100644
--- a/vehicle/tesla-vehicle-command/helper.go
+++ b/vehicle/tesla-vehicle-command/helper.go
@@ -3,11 +3,30 @@ package vc
 import (
 	"errors"
 	"strings"
+	"sync"
 
 	"github.com/evcc-io/evcc/api"
 	"github.com/teslamotors/vehicle-command/pkg/connector/inet"
 )
 
+var (
+	mu         sync.Mutex
+	identities = make(map[string]*Identity)
+)
+
+func getInstance(subject string) *Identity {
+	mu.Lock()
+	defer mu.Unlock()
+	v, _ := identities[subject]
+	return v
+}
+
+func addInstance(subject string, identity *Identity) {
+	mu.Lock()
+	defer mu.Unlock()
+	identities[subject] = identity
+}
+
 // apiError converts HTTP 408 error to ErrTimeout
 func apiError(err error) error {
 	if err != nil && (errors.Is(err, inet.ErrVehicleNotAwake) ||
diff --git a/vehicle/tesla-vehicle-command/identity.go b/vehicle/tesla-vehicle-command/identity.go
index f4bc9e4c..c2d0073c 100644
--- a/vehicle/tesla-vehicle-command/identity.go
+++ b/vehicle/tesla-vehicle-command/identity.go
@@ -1,8 +1,16 @@
 package vc
 
 import (
+	"context"
+	"errors"
+	"fmt"
+	"sync"
+
+	"github.com/evcc-io/evcc/server/db/settings"
 	"github.com/evcc-io/evcc/util"
-	"github.com/teslamotors/vehicle-command/pkg/account"
+	"github.com/evcc-io/evcc/util/oauth"
+	"github.com/evcc-io/evcc/util/request"
+	"github.com/golang-jwt/jwt/v5"
 	"golang.org/x/oauth2"
 )
 
@@ -19,7 +27,7 @@ var OAuth2Config = &oauth2.Config{
 	Scopes: []string{"openid", "email", "offline_access"},
 }
 
-const userAgent = "evcc/evcc-io"
+// const userAgent = "evcc/evcc-io"
 
 var TESLA_CLIENT_ID, TESLA_CLIENT_SECRET string
 
@@ -34,45 +42,99 @@ func init() {
 
 type Identity struct {
 	oauth2.TokenSource
-	log   *util.Logger
-	token *oauth2.Token
-	acct  *account.Account
+	mu      sync.Mutex
+	log     *util.Logger
+	subject string
+	// acct  *account.Account
 }
 
-func NewIdentity(log *util.Logger, ts oauth2.TokenSource) (*Identity, error) {
-	token, err := ts.Token()
-	if err != nil {
+func NewIdentity(log *util.Logger, token *oauth2.Token) (*Identity, error) {
+	// serialise instance handling
+	mu.Lock()
+	defer mu.Unlock()
+
+	// determine tesla identity
+	var claims jwt.RegisteredClaims
+	if _, _, err := jwt.NewParser().ParseUnverified(token.AccessToken, &claims); err != nil {
 		return nil, err
 	}
 
-	acct, err := account.New(token.AccessToken, userAgent)
-	if err != nil {
-		return nil, err
+	// reuse identity instance
+	if instance := getInstance(claims.Subject); instance != nil {
+		return instance, nil
 	}
 
-	return &Identity{
-		TokenSource: ts,
-		token:       token,
-		acct:        acct,
-	}, nil
-}
+	if !token.Valid() {
+		token.Expiry = claims.ExpiresAt.Time
+	}
 
-func (v *Identity) Account() *account.Account {
-	token, err := v.Token()
-	if err != nil {
-		v.log.ERROR.Println(err)
-		return v.acct
+	v := &Identity{
+		log:     log,
+		subject: claims.Subject,
+		// acct:        acct,
 	}
 
-	if token.AccessToken != v.token.AccessToken {
-		acct, err := account.New(token.AccessToken, userAgent)
-		if err != nil {
-			v.log.ERROR.Println(err)
-			return v.acct
+	// database token
+	if !token.Valid() {
+		if err := settings.Json(v.settingsKey(), &token); err != nil {
+			return nil, fmt.Errorf("missing token setting for %s: %w", claims.Subject, err)
 		}
 
-		v.acct = acct
+		if !token.Valid() {
+			return nil, errors.New("token expired")
+		}
 	}
 
-	return v.acct
+	// acct, err := account.New(token.AccessToken, userAgent)
+	// if err != nil {
+	// 	return nil, err
+	// }
+
+	v.TokenSource = oauth.RefreshTokenSource(token, v)
+
+	// add instance
+	addInstance(claims.Subject, v)
+
+	return v, nil
+}
+
+func (v *Identity) settingsKey() string {
+	return fmt.Sprintf("tesla-command.%s", v.subject)
 }
+
+func (v *Identity) RefreshToken(token *oauth2.Token) (*oauth2.Token, error) {
+	v.mu.Lock()
+	defer v.mu.Unlock()
+
+	ctx := context.WithValue(context.Background(), oauth2.HTTPClient, request.NewClient(v.log))
+	ts := OAuth2Config.TokenSource(ctx, token)
+
+	token, err := ts.Token()
+	if err != nil {
+		return nil, err
+	}
+
+	err = settings.SetJson(v.settingsKey(), token)
+
+	return token, err
+}
+
+// func (v *Identity) Account() *account.Account {
+// 	token, err := v.Token()
+// 	if err != nil {
+// 		v.log.ERROR.Println(err)
+// 		return v.acct
+// 	}
+
+// 	if token.AccessToken != v.token.AccessToken {
+// 		acct, err := account.New(token.AccessToken, userAgent)
+// 		if err != nil {
+// 			v.log.ERROR.Println(err)
+// 			return v.acct
+// 		}
+
+// 		v.acct = acct
+// 	}
+
+// 	return v.acct
+// }


Test patch:
diff --git a/core/site_battery_test.go b/core/site_battery_test.go
index e2ecda52..630f46f8 100644
--- a/core/site_battery_test.go
+++ b/core/site_battery_test.go
@@ -15,19 +15,19 @@ func TestDetermineBatteryMode(t *testing.T) {
 	ctrl := gomock.NewController(t)
 
 	tcs := []struct {
-		chargeStatus api.ChargeStatus
-		planActive   bool
-		expBatMode   api.BatteryMode
-		mode         api.ChargeMode
+		chargeStatus       api.ChargeStatus
+		fastChargingActive bool
+		smartCostActive    bool
+		expBatMode         api.BatteryMode
 	}{
-		{api.StatusB, false, api.BatteryNormal, api.ModeOff},   // mode off -> bat normal
-		{api.StatusB, false, api.BatteryNormal, api.ModeNow},   // mode now, not charging -> bat normal
-		{api.StatusC, false, api.BatteryHold, api.ModeNow},     // mode now, charging -> bat hold
-		{api.StatusB, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, not charging -> bat normal
-		{api.StatusC, false, api.BatteryNormal, api.ModeMinPV}, // mode minPV, charging -> bat normal
-		{api.StatusB, false, api.BatteryNormal, api.ModePV},    // mode PV, not charging -> bat normal
-		{api.StatusC, false, api.BatteryNormal, api.ModePV},    // mode PV, charging, no planner -> bat normal
-		{api.StatusC, true, api.BatteryHold, api.ModePV},       // mode PV, charging, planner active -> bat hold
+		{api.StatusB, false, false, api.BatteryNormal}, // not charging | fast charge not active | smart cost not active -> bat normal
+		{api.StatusB, true, false, api.BatteryNormal},  // not charging | fast charge active 	 | smart cost not active -> bat normal
+		{api.StatusC, false, false, api.BatteryNormal}, // charging 	| fast charge not active | smart cost not active -> bat normal
+		{api.StatusC, true, false, api.BatteryHold},    // charging 	| fast charge active 	 | smart cost not active -> bat hold
+		{api.StatusB, false, true, api.BatteryNormal},  // not charging | fast charge not active | smart cost active	 -> bat normal
+		{api.StatusB, true, true, api.BatteryNormal},   // not charging | fast charge active	 | smart cost active	 -> bat normal
+		{api.StatusC, false, true, api.BatteryHold},    // charging 	| fast charge not active | smart cost active	 -> bat hold
+		{api.StatusC, true, true, api.BatteryHold},     // charging 	| fast charge active	 | smart cost active	 -> bat hold
 	}
 
 	log := util.NewLogger("foo")
@@ -39,12 +39,11 @@ func TestDetermineBatteryMode(t *testing.T) {
 
 		lp := loadpoint.NewMockAPI(ctrl)
 		lp.EXPECT().GetStatus().Return(tc.chargeStatus).AnyTimes()
-		lp.EXPECT().GetMode().Return(tc.mode).AnyTimes()
-		lp.EXPECT().GetPlanActive().Return(tc.planActive).AnyTimes()
+		lp.EXPECT().IsFastChargingActive().Return(tc.fastChargingActive).AnyTimes()
 
 		loadpoints := []loadpoint.API{lp}
 
-		mode := s.determineBatteryMode(loadpoints, false)
+		mode := s.determineBatteryMode(loadpoints, tc.smartCostActive)
 		assert.Equal(t, tc.expBatMode, mode, tc)
 	}
 }