Repository: evcc-io/evcc
Pull request #11878 (2024-01-26 19:01:10)

Improve switch-off speed in pv mode
Improve switching off in pv mode when phase switching is active.

Currently if more than one phase is active  in pv mode and phases can be scaled down, evcc will first scale phases down and then decide if charging has to be switched off. 

This PR calculates the consumption after scaling down to 1p and directly starts the disable sequence if the consumption will still be higher than the limit.

andig: Will this PR still scale to 1p if available power above 1p threshold or remain on 3p? Could we add a test to verify that?
MarkusGH: > Will this PR still scale to 1p if available power above 1p threshold or remain on 3p? 

Now that I fixed it - yes!

It will only switch off directly when `sitePower-minCurrent*float64(activePhases-1) >= lp.Disable.Threshold`.
`sitePower-minCurrent*float64(activePhases-1)` is the resulting sitePower with lp @ 1p and minCurrent.

That condition is only met if there is no available power above 1p threshold.

> Could we add a test to verify that?

I would, but I don't understand the test code well enough.
GrimmiMeloni: > > Could we add a test to verify that?
> 
> I would, but I don't understand the test code well enough.

I will have a look at this in the coming days.
MarkusGH: @GrimmiMeloni: Ich verstehe nicht, warum die Tests nach dem Mergen mit "master" kaputt sind - es gibt nichts was das in diesem PR verursachen könnte. Gibt es da evtl. ein Problem mit Deinen Änderungen an loadpoint_test.go? 
GrimmiMeloni: Nein. Zumindest nicht direkt. Das sieht aber generell ungesund aus:

```
# github.com/evcc-io/evcc/core [github.com/evcc-io/evcc/core.test]
Error: core/loadpoint_test.go:817:3: unknown field MinCurrent in struct literal of type Loadpoint
Error: core/loadpoint_test.go:818:3: unknown field MaxCurrent in struct literal of type Loadpoint
Error: core/loadpoint_test.go:879:3: unknown field MinCurrent in struct literal of type Loadpoint
Error: core/loadpoint_test.go:880:3: unknown field MaxCurrent in struct literal of type Loadpoint
```

Da muß ein Merge daneben gegangen sein. Hier würde ich erstmal gar nichts ändern. Die Ursache wird wonanders sein.
MarkusGH: > Da muß ein Merge daneben gegangen sein. Hier würde ich erstmal gar nichts ändern. Die Ursache wird wonanders sein.

Neu synchronisiert und es ist wieder gut...
GrimmiMeloni: @MarkusGH danke für die Anpassungen, jetzt ist es verständlicher. 👍🏻

Das sieht insgesamt gut aus. Ich gebe hier noch kein approval, weil die Tests noch fehlen.
Ich werde mich dann jetzt die kommenden Tage um diese kümmern. Stay tuned.
MarkusGH: Vor allem ist es jetzt richtiger :-) Danke für das Feedback!
MarkusGH: @GrimmiMeloni: Habe das noch aufgehübscht und stelle es jetzt auf "Ready for review" damit klar ist, dass es von meiner Seite her fertig ist. Danke dass Du Dich um die Tests kümmern willst!
GrimmiMeloni: @andig scheint als hätte ich versehentlich direkt auf Markus branch gepushed, before ich dann meinen PR reingezogen hab.  Ist der gleiche Change - sollte also keinen Unterschied machen. 🤷‍♂️ 

Von meiner Seite her ist das jetzt rund.
andig: Dürfte das nicht alles nur gemacht werden, wenn es auch eine Phasenumschaltung gibt? Mir ist von PR und Tests nicht klar, was bei statischer 3p WB passieren würde?
MarkusGH: > Dürfte das nicht alles nur gemacht werden, wenn es auch eine Phasenumschaltung gibt? Mir ist von PR und Tests nicht klar, was bei statischer 3p WB passieren würde?

In dem Fall würde ich zum einen erwarten dass der phaseTimer nicht aktiv ist - also keine Änderung zum alten Verhalten. Selbst wenn er aktiv sein sollte wäre es egal, denn wenn die Leistung nicht mal für eine Phase reicht, reicht sie für drei Phasen erst recht nicht.
andig: Das stimmt. Aber mal mindestens die Meldung in https://github.com/evcc-io/evcc/pull/11878/files#diff-c456526d4bd9022a488ce32de208b40d86016e2c40b0c81a2dffe557c186377dR1189 ist dann falsch. Daran ist mir aufgefallen, dass der Anfall anscheinend nicht berücksichtigt wird. 
MarkusGH: > Das stimmt. Aber mal mindestens die Meldung in https://github.com/evcc-io/evcc/pull/11878/files#diff-c456526d4bd9022a488ce32de208b40d86016e2c40b0c81a2dffe557c186377dR1189 ist dann falsch. Daran ist mir aufgefallen, dass der Anfall anscheinend nicht berücksichtigt wird.

Bin mit jeder anderen Meldung einverstanden.

Patch:
diff --git a/.golangci.yml b/.golangci.yml
index 38095ae2..fc1095a9 100644
--- a/.golangci.yml
+++ b/.golangci.yml
@@ -19,7 +19,6 @@ linters:
     - exportloopref
     - gci
     - gofmt
-    - gofumpt
     - goimports
     - goprintffuncname
     - govet
diff --git a/api/api.go b/api/api.go
index 85ff24b5..37f907e0 100644
--- a/api/api.go
+++ b/api/api.go
@@ -107,15 +107,20 @@ type Authorizer interface {
 	Authorize(key string) error
 }
 
+// PhaseDescriber returns the number of availablephases
+type PhaseDescriber interface {
+	Phases() int
+}
+
 // Vehicle represents the EV and it's battery
 type Vehicle interface {
 	Battery
 	BatteryCapacity
 	IconDescriber
 	FeatureDescriber
+	PhaseDescriber
 	Title() string
 	SetTitle(string)
-	Phases() int
 	Identifiers() []string
 	OnIdentified() ActionConfig
 }
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 5925dddb..b601113e 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -170,7 +170,8 @@ export default {
 		phases: Number,
 		phasesConfigured: Number,
 		phasesActive: Number,
-		phases1p3p: Boolean,
+		chargerPhases1p3p: Boolean,
+		chargerPhysicalPhases: Number,
 		minCurrent: Number,
 		maxCurrent: Number,
 		chargeCurrent: Number,
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 68853330..f73eec78 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -26,7 +26,7 @@
 							<h4 class="d-flex align-items-center mb-3 mt-4 text-evcc">
 								{{ $t("main.loadpointSettings.currents") }}
 							</h4>
-							<div class="mb-3 row">
+							<div v-if="phasesOptions.length" class="mb-3 row">
 								<label
 									:for="formId('phases_0')"
 									class="col-sm-4 col-form-label pt-0"
@@ -34,7 +34,7 @@
 									{{ $t("main.loadpointSettings.phasesConfigured.label") }}
 								</label>
 								<div class="col-sm-8 pe-0">
-									<p v-if="!phases1p3p" class="mt-0 mb-2">
+									<p v-if="!chargerPhases1p3p" class="mt-0 mb-2">
 										<small>
 											{{
 												$t(
@@ -166,7 +166,8 @@ export default {
 		id: [String, Number],
 		phasesConfigured: Number,
 		phasesActive: Number,
-		phases1p3p: Boolean,
+		chargerPhases1p3p: Boolean,
+		chargerPhysicalPhases: Number,
 		minSoc: Number,
 		maxCurrent: Number,
 		minCurrent: Number,
@@ -182,13 +183,19 @@ export default {
 	},
 	computed: {
 		phasesOptions: function () {
-			if (this.phases1p3p) {
+			if (this.chargerPhysicalPhases == 1) {
+				// known fixed phase configuration, no settings required
+				return [];
+			}
+			if (this.chargerPhases1p3p) {
+				// automatic switching
 				return [PHASES_AUTO, PHASES_3, PHASES_1];
 			}
+			// 1p or 3p possible
 			return [PHASES_3, PHASES_1];
 		},
 		maxPower: function () {
-			if (this.phases1p3p) {
+			if (this.chargerPhases1p3p) {
 				if (this.phasesConfigured === PHASES_AUTO) {
 					return this.maxPowerPhases(3);
 				}
@@ -199,7 +206,7 @@ export default {
 			return this.fmtKw(this.maxCurrent * V * this.phasesActive);
 		},
 		minPower: function () {
-			if (this.phases1p3p) {
+			if (this.chargerPhases1p3p) {
 				if (this.phasesConfigured === PHASES_AUTO) {
 					return this.minPowerPhases(1);
 				}
diff --git a/assets/js/router.js b/assets/js/router.js
index 39f375b4..373c98a3 100644
--- a/assets/js/router.js
+++ b/assets/js/router.js
@@ -1,8 +1,17 @@
 import { createRouter, createWebHashHistory } from "vue-router";
-
+import Modal from "bootstrap/js/dist/modal";
 import Main from "./views/Main.vue";
 import { ensureCurrentLocaleMessages } from "./i18n";
 
+function hideAllModals() {
+  [...document.querySelectorAll(".modal.show")].forEach((modal) => {
+    const modalInstance = Modal.getInstance(modal);
+    if (modalInstance) {
+      modalInstance.hide();
+    }
+  });
+}
+
 export default function setupRouter(i18n) {
   const router = createRouter({
     history: createWebHashHistory(),
@@ -28,5 +37,8 @@ export default function setupRouter(i18n) {
     await ensureCurrentLocaleMessages(i18n.global);
     return true;
   });
+  router.afterEach(() => {
+    hideAllModals();
+  });
   return router;
 }
diff --git a/assets/js/views/ChargingSessions.vue b/assets/js/views/ChargingSessions.vue
index e043e18b..58107b4a 100644
--- a/assets/js/views/ChargingSessions.vue
+++ b/assets/js/views/ChargingSessions.vue
@@ -41,7 +41,6 @@
 									{{ $t("sessions.date") }}
 								</th>
 								<th
-									v-if="showLoadpoints"
 									scope="col"
 									class="align-top d-none d-md-table-cell"
 									data-testid="loadpoint"
@@ -61,7 +60,6 @@
 									</CustomSelect>
 								</th>
 								<th
-									v-if="showVehicles"
 									scope="col"
 									class="align-top d-none d-md-table-cell"
 									data-testid="vehicle"
@@ -81,10 +79,7 @@
 									</CustomSelect>
 								</th>
 								<th scope="col" class="align-top d-md-none text-truncate">
-									<div
-										v-if="showLoadpoints"
-										class="d-flex flex-wrap text-truncate"
-									>
+									<div class="d-flex flex-wrap text-truncate">
 										<div class="me-2 text-truncate">
 											{{ $t("sessions.loadpoint") }}
 										</div>
@@ -103,10 +98,7 @@
 											</span>
 										</CustomSelect>
 									</div>
-									<div
-										class="text-truncate"
-										:class="{ 'd-flex flex-wrap': showLoadpoints }"
-									>
+									<div class="text-truncate d-flex flex-wrap">
 										<div class="me-2 text-truncate">
 											{{ $t("sessions.vehicle") }}
 										</div>
@@ -154,16 +146,8 @@
 								<th scope="col" class="align-top ps-0">
 									{{ $t("sessions.total") }}
 								</th>
-								<th
-									v-if="showLoadpoints"
-									scope="col"
-									class="d-none d-md-table-cell"
-								></th>
-								<th
-									v-if="showVehicles"
-									scope="col"
-									class="d-none d-md-table-cell"
-								></th>
+								<th scope="col" class="d-none d-md-table-cell"></th>
+								<th scope="col" class="d-none d-md-table-cell"></th>
 								<th scope="col" class="d-md-none"></th>
 								<th
 									v-for="column in columnsPerBreakpoint"
@@ -187,14 +171,14 @@
 								<td class="ps-0">
 									{{ fmtFullDateTime(new Date(session.created), true) }}
 								</td>
-								<td v-if="showLoadpoints" class="d-none d-md-table-cell">
+								<td class="d-none d-md-table-cell">
 									{{ session.loadpoint }}
 								</td>
-								<td v-if="showVehicles" class="d-none d-md-table-cell">
+								<td class="d-none d-md-table-cell">
 									{{ session.vehicle }}
 								</td>
 								<td class="d-md-none text-truncate">
-									<div v-if="showLoadpoints">{{ session.loadpoint }}</div>
+									<div>{{ session.loadpoint }}</div>
 									<div>{{ session.vehicle }}</div>
 								</td>
 								<td
@@ -453,20 +437,6 @@ export default {
 			}
 			return null;
 		},
-		showVehicles() {
-			return this.hasMultipleVehicles || this.vehicleFilter;
-		},
-		showLoadpoints() {
-			return this.hasMultipleLoadpoints || this.loadpointFilter;
-		},
-		hasMultipleVehicles() {
-			const vehicles = this.currentSessions.map((s) => s.vehicle);
-			return new Set(vehicles).size > 1;
-		},
-		hasMultipleLoadpoints() {
-			const loadpoints = this.currentSessions.map((s) => s.loadpoint);
-			return new Set(loadpoints).size > 1;
-		},
 		pricePerKWh() {
 			const total = this.filteredSessions
 				.filter((s) => s.price !== null)
diff --git a/charger/amperfied.go b/charger/amperfied.go
index d4457bd3..9cae1836 100644
--- a/charger/amperfied.go
+++ b/charger/amperfied.go
@@ -61,8 +61,8 @@ func init() {
 // NewAmperfiedFromConfig creates a Amperfied charger from generic config
 func NewAmperfiedFromConfig(other map[string]interface{}) (api.Charger, error) {
 	cc := struct {
-		modbus.TcpSettings
-		Phases1p3p bool
+		modbus.TcpSettings `mapstructure:",squash"`
+		Phases1p3p         bool
 	}{
 		TcpSettings: modbus.TcpSettings{
 			ID: 255,
diff --git a/charger/switchsocket.go b/charger/switchsocket.go
index 99ef1333..45b5193a 100644
--- a/charger/switchsocket.go
+++ b/charger/switchsocket.go
@@ -98,3 +98,10 @@ var _ loadpoint.Controller = (*switchSocket)(nil)
 func (c *switchSocket) LoadpointControl(lp loadpoint.API) {
 	c.lp = lp
 }
+
+var _ api.PhaseDescriber = (*switchSocket)(nil)
+
+// Phases implements the api.PhasesDescriber interface
+func (v *switchSocket) Phases() int {
+	return 1
+}
diff --git a/charger/victron.go b/charger/victron.go
index 00f72ec0..2c4ca75d 100644
--- a/charger/victron.go
+++ b/charger/victron.go
@@ -25,7 +25,6 @@ import (
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/util"
 	"github.com/evcc-io/evcc/util/modbus"
-	"github.com/evcc-io/evcc/util/sponsor"
 	"github.com/spf13/cast"
 )
 
@@ -102,10 +101,6 @@ func NewVictron(uri string, slaveID uint8, regs victronRegs) (api.Charger, error
 		return nil, err
 	}
 
-	if !sponsor.IsAuthorized() {
-		return nil, api.ErrSponsorRequired
-	}
-
 	log := util.NewLogger("victron")
 	conn.Logger(log.TRACE)
 
diff --git a/cmd/configure/configure.tpl b/cmd/configure/configure.tpl
index 07c85359..b3a02086 100644
--- a/cmd/configure/configure.tpl
+++ b/cmd/configure/configure.tpl
@@ -54,9 +54,6 @@ loadpoints:
   vehicle: {{ .Vehicle }}
 {{- end }}
   mode: {{ .Mode }}
-  phases: {{ .Phases }}
-  mincurrent: {{ .MinCurrent }}
-  maxcurrent: {{ .MaxCurrent }}
 {{- end }}
 {{- end }}
 
diff --git a/cmd/flags.go b/cmd/flags.go
index 5e019e33..0436d4be 100644
--- a/cmd/flags.go
+++ b/cmd/flags.go
@@ -40,6 +40,7 @@ const (
 
 	flagDigits = "digits"
 	flagDelay  = "delay"
+	flagForce  = "force"
 )
 
 func bind(cmd *cobra.Command, key string, flagName ...string) {
diff --git a/cmd/settings-get.go b/cmd/settings-get.go
index 325db4a0..12f4ecc8 100644
--- a/cmd/settings-get.go
+++ b/cmd/settings-get.go
@@ -3,9 +3,7 @@ package cmd
 import (
 	"fmt"
 	"os"
-	"os/signal"
 	"regexp"
-	"syscall"
 	"text/tabwriter"
 
 	"github.com/evcc-io/evcc/server/db/settings"
@@ -49,14 +47,4 @@ func runSettingsGet(cmd *cobra.Command, args []string) {
 		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-set.go b/cmd/settings-set.go
new file mode 100644
index 00000000..cfdbabe2
--- /dev/null
+++ b/cmd/settings-set.go
@@ -0,0 +1,57 @@
+package cmd
+
+import (
+	"fmt"
+
+	"github.com/AlecAivazis/survey/v2"
+	"github.com/evcc-io/evcc/server/db/settings"
+	"github.com/spf13/cobra"
+)
+
+// settingsSetCmd represents the configure command
+var settingsSetCmd = &cobra.Command{
+	Use:   "set",
+	Short: "Set configuration setting",
+	Run:   runSettingsSet,
+	Args:  cobra.ExactArgs(2),
+}
+
+func init() {
+	settingsCmd.AddCommand(settingsSetCmd)
+	settingsSetCmd.Flags().BoolP(flagForce, "f", false, "Force (no confirmation)")
+}
+
+func runSettingsSet(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)
+	}
+
+	confirmation, _ := cmd.Flags().GetBool(flagForce)
+	if !confirmation {
+		msg := fmt.Sprintf("Set %s", args[0])
+		if val, _ := settings.String(args[0]); val != "" {
+			msg = fmt.Sprintf("Override %s (current value: %s)", args[0], val)
+		}
+
+		prompt := &survey.Confirm{
+			Message: msg,
+		}
+
+		if err := survey.AskOne(prompt, &confirmation); err != nil {
+			log.FATAL.Fatal(err)
+		}
+	}
+
+	if confirmation {
+		settings.SetString(args[0], args[1])
+	}
+
+	// wait for shutdown
+	<-shutdownDoneC()
+}
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 2fb543d1..8fd627a4 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -14,12 +14,13 @@ const (
 	DisableThreshold = "disableThreshold"
 
 	PhasesConfigured = "phasesConfigured" // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
-	Phases1p3p       = "phases1p3p"       // phase switcher (1p3p chargers)
 	PhasesEnabled    = "phasesEnabled"    // enabled phases (1/3)
 	PhasesActive     = "phasesActive"     // active phases as used by vehicle (1/2/3)
 
-	ChargerIcon    = "chargerIcon"    // charger icon for ui
-	ChargerFeature = "chargerFeature" // charger feature
+	ChargerIcon           = "chargerIcon"           // charger icon for ui
+	ChargerFeature        = "chargerFeature"        // charger feature
+	ChargerPhysicalPhases = "chargerPhysicalPhases" // charger phases
+	ChargerPhases1p3p     = "chargerPhases1p3p"     // phase switcher (1p3p chargers)
 
 	// loadpoint status
 	Enabled   = "enabled"   // loadpoint enabled
diff --git a/core/loadpoint.go b/core/loadpoint.go
index a46db590..d19ff444 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -595,13 +595,19 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
 
 	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
-	lp.publish(keys.Phases1p3p, lp.hasPhaseSwitching())
+	lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
 	lp.publish(keys.PhasesEnabled, lp.phases)
 	lp.publish(keys.PhasesActive, lp.ActivePhases())
 	lp.publishTimer(phaseTimer, 0, timerInactive)
 	lp.publishTimer(pvTimer, 0, timerInactive)
 	lp.publishTimer(guardTimer, 0, timerInactive)
 
+	if phases := lp.getChargerPhysicalPhases(); phases != 0 {
+		lp.publish(keys.ChargerPhysicalPhases, phases)
+	} else {
+		lp.publish(keys.ChargerPhysicalPhases, nil)
+	}
+
 	// charger features
 	for _, f := range []api.Feature{api.IntegratedDevice, api.Heating} {
 		lp.publishChargerFeature(f)
@@ -1178,9 +1184,15 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
 	}
 
 	if mode == api.ModePV && lp.enabled && targetCurrent < minCurrent {
+		projectedSitePower := sitePower
+		if !lp.phaseTimer.IsZero() {
+			// calculate site power after a phase switch from activePhases phases -> 1 phase
+			// notes: activePhases can be 1, 2 or 3 and phaseTimer can only be active if lp current is already at minCurrent
+			projectedSitePower -= Voltage * minCurrent * float64(activePhases-1)
+		}
 		// kick off disable sequence
-		if sitePower >= lp.Disable.Threshold && lp.phaseTimer.IsZero() {
-			lp.log.DEBUG.Printf("site power %.0fW >= %.0fW disable threshold", sitePower, lp.Disable.Threshold)
+		if projectedSitePower >= lp.Disable.Threshold {
+			lp.log.DEBUG.Printf("projected site power %.0fW >= %.0fW disable threshold", projectedSitePower, lp.Disable.Threshold)
 
 			if lp.pvTimer.IsZero() {
 				lp.log.DEBUG.Printf("pv disable timer start: %v", lp.Disable.Delay)
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index b2b96d69..e665e432 100644
--- a/core/loadpoint_phases.go
+++ b/core/loadpoint_phases.go
@@ -62,8 +62,9 @@ func (lp *Loadpoint) ActivePhases() int {
 	physical := lp.GetPhases()
 	vehicle := lp.getVehiclePhases()
 	measured := lp.getMeasuredPhases()
+	charger := lp.getChargerPhysicalPhases()
 
-	active := min(expect(vehicle), expect(physical), expect(measured))
+	active := min(expect(vehicle), expect(physical), expect(measured), expect(charger))
 
 	// sanity check - we should not assume less active phases than actually measured
 	if measured > 0 && active < measured {
@@ -78,6 +79,7 @@ func (lp *Loadpoint) maxActivePhases() int {
 	physical := lp.GetPhases()
 	measured := lp.getMeasuredPhases()
 	vehicle := lp.getVehiclePhases()
+	charger := lp.getChargerPhysicalPhases()
 
 	// during 1p or unknown config, 1p measured is not a restriction
 	if physical <= 1 || vehicle == 1 {
@@ -89,7 +91,7 @@ func (lp *Loadpoint) maxActivePhases() int {
 		physical = lp.configuredPhases
 	}
 
-	return min(expect(vehicle), expect(physical), expect(measured))
+	return min(expect(vehicle), expect(physical), expect(measured), expect(charger))
 }
 
 func (lp *Loadpoint) getVehiclePhases() int {
@@ -100,6 +102,14 @@ func (lp *Loadpoint) getVehiclePhases() int {
 	return 0
 }
 
+func (lp *Loadpoint) getChargerPhysicalPhases() int {
+	if cc, ok := lp.charger.(api.PhaseDescriber); ok {
+		return cc.Phases()
+	}
+
+	return 0
+}
+
 func (lp *Loadpoint) hasPhaseSwitching() bool {
 	_, ok := lp.charger.(api.PhaseSwitcher)
 	return ok
diff --git a/core/site.go b/core/site.go
index c09327b9..2d38e870 100644
--- a/core/site.go
+++ b/core/site.go
@@ -242,13 +242,13 @@ func (site *Site) restoreMeters() {
 		site.Meters.GridMeterRef = v
 	}
 	if v, err := settings.String(keys.PvMeters); err == nil && v != "" {
-		site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, strings.Split(v, ",")...)
+		site.Meters.PVMetersRef = append(site.Meters.PVMetersRef, filterConfigurable(strings.Split(v, ","))...)
 	}
 	if v, err := settings.String(keys.BatteryMeters); err == nil && v != "" {
-		site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, strings.Split(v, ",")...)
+		site.Meters.BatteryMetersRef = append(site.Meters.BatteryMetersRef, filterConfigurable(strings.Split(v, ","))...)
 	}
 	if v, err := settings.String(keys.AuxMeters); err == nil && v != "" {
-		site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, strings.Split(v, ",")...)
+		site.Meters.AuxMetersRef = append(site.Meters.AuxMetersRef, filterConfigurable(strings.Split(v, ","))...)
 	}
 }
 
diff --git a/go.mod b/go.mod
index 966e768f..91681c91 100644
--- a/go.mod
+++ b/go.mod
@@ -62,7 +62,7 @@ require (
 	github.com/libp2p/zeroconf/v2 v2.2.0
 	github.com/lorenzodonini/ocpp-go v0.17.1-0.20231031074144-d214d7c42cbc
 	github.com/lunixbochs/struc v0.0.0-20200707160740-784aaebc1d40
-	github.com/mabunixda/wattpilot v1.6.4
+	github.com/mabunixda/wattpilot v1.6.3
 	github.com/manifoldco/promptui v0.9.0
 	github.com/mitchellh/go-homedir v1.1.0
 	github.com/mitchellh/mapstructure v1.5.0
@@ -208,3 +208,5 @@ replace github.com/grid-x/modbus => github.com/evcc-io/modbus v0.0.0-20230705160
 replace github.com/imdario/mergo => github.com/imdario/mergo v0.3.16
 
 replace github.com/spf13/viper => github.com/spf13/viper v1.18.1
+
+replace github.com/mabunixda/wattpilot => github.com/mabunixda/wattpilot v1.6.2
diff --git a/go.sum b/go.sum
index 0dd242b6..dd19c1f4 100644
--- a/go.sum
+++ b/go.sum
@@ -419,8 +419,8 @@ github.com/lorenzodonini/ocpp-go v0.17.1-0.20231031074144-d214d7c42cbc/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.6.4 h1:548QvdloTnRqfZ3wBZJ3QMrI9UxIFwnCtFX26nYLLlI=
-github.com/mabunixda/wattpilot v1.6.4/go.mod h1:whn/S4zLvWajy6t/DE5pXABkD1I34rAE+ML607BL620=
+github.com/mabunixda/wattpilot v1.6.2 h1:xZE/elFntUJgaWTK2V7CJVQRcE+WvkT8g6dCk/oIG78=
+github.com/mabunixda/wattpilot v1.6.2/go.mod h1:2tcdLRoqycO6bWYAjZMP9g1giMgw4t2MnbN3VxaCN1Q=
 github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
 github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
 github.com/manifoldco/promptui v0.9.0 h1:3V4HzJk1TtXW1MTZMP7mdlwbBpIinw3HztaIlYthEiA=
diff --git a/i18n/da.toml b/i18n/da.toml
index 323fe633..d79c6191 100644
--- a/i18n/da.toml
+++ b/i18n/da.toml
@@ -261,6 +261,7 @@ solar = "solenergi"
 [main.loadpointSettings]
 currents = "Ladestrøm"
 default = "standard"
+disclaimerHint = "Bemærk:"
 onlyForSocBasedCharging = "Disse indstillinger er kun tilgængelig for køretøjer med kendt opladningsniveau"
 title = "Indstillinger {0}"
 vehicle = "Køretøj"
@@ -281,6 +282,7 @@ label = "Min. opladning %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Faser"
+no1p3pSupport = "Hvordan er din oplader forbundet"
 phases_0 = "automatisk skift"
 phases_1 = "1 fase"
 phases_1_hint = "({min} til {max})"
diff --git a/i18n/de.toml b/i18n/de.toml
index ccaef28d..67e6e743 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -23,15 +23,59 @@ never = "nur mit genug PV-Überschuss"
 
 [config]
 
+[config.battery]
+titleAdd = "Batterie Zähler hinzufügen"
+titleEdit = "Batterie Zähler bearbeiten"
+
+[config.deviceValue]
+capacity = "Kapazität"
+chargeStatus = "Status"
+chargedEnergy = "Geladen"
+current = "Aktuell"
+enabled = "Aktiviert"
+energy = "Energie"
+odometer = "Kilometerstand"
+phaseCurrents = "Strom L1..L3"
+phasePowers = "Leistung L1..L3"
+phaseVoltages = "Spannung L1..L3"
+power = "Leistung"
+range = "Reichweite"
+soc = "SoC"
+socLimit = "SoC Begrenzung"
+
 [config.form]
 example = "Beispiel"
 optional = "optional"
 
+[config.grid]
+titleAdd = "Stromzähler (Netz) hinzufügen"
+titleEdit = "Stromzähler (Netz) bearbeiten"
+
 [config.main]
+addLoadpoint = "Ladepunkt hinzufügen"
+addPvBattery = "PV oder Speicher hinzufügen"
 addVehicle = "Fahrzeug hinzufügen"
 edit = "bearbeiten"
 title = "Konfiguration"
+unconfigured = "nicht konfiguriert"
 vehicles = "Meine Fahrzeuge"
+yaml = "Konfiguration in evcc.yaml gefunden. Nicht per UI editierbar"
+
+[config.meter]
+cancel = "Abbrechen"
+delete = "Löschen"
+save = "Speichern"
+template = "Hersteller"
+titleChoice = "Was möchtest du hinzufügen?"
+validateSave = "Überprüfen & speichern"
+
+[config.pv]
+titleAdd = "Zähler (PV) hinzufügen"
+titleEdit = "Zähler (PV) bearbeiten"
+
+[config.site]
+cancel = "Abbrechen"
+save = "Speichern"
 
 [config.validation]
 failed = "fehlgeschlagen"
@@ -213,6 +257,7 @@ solar = "Sonne"
 [main.loadpointSettings]
 currents = "Ladestrom"
 default = "default"
+disclaimerHint = "Hinweis:"
 onlyForSocBasedCharging = "Diese Optionen sind nur für Fahrzeuge mit bekanntem Ladestand verfügbar."
 title = "Einstellungen {0}"
 vehicle = "Fahrzeug"
@@ -298,7 +343,7 @@ fallbackName = "Fahrzeug"
 moreActions = "Weitere Aktionen"
 none = "Kein Fahrzeug"
 targetSoc = "Ladelimit"
-temp = "Temp."
+temp = "Temperatur"
 tempLimit = "Zieltemp."
 unknown = "Gastfahrzeug"
 vehicleSoc = "Ladestand"
diff --git a/i18n/en.toml b/i18n/en.toml
index 8b5bc0e2..8abf3e5a 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -254,6 +254,7 @@ solar = "Solar"
 [main.loadpointSettings]
 currents = "Charging current"
 default = "default"
+disclaimerHint = "Note:"
 onlyForSocBasedCharging = "These options are only available for vehicles with known charging level."
 title = "Settings {0}"
 vehicle = "Vehicle"
diff --git a/i18n/es.toml b/i18n/es.toml
index 5993dfa6..2e24754f 100644
--- a/i18n/es.toml
+++ b/i18n/es.toml
@@ -266,6 +266,7 @@ solar = "Solar"
 [main.loadpointSettings]
 currents = "Corriente de carga"
 default = "defecto"
+disclaimerHint = "Nota:"
 onlyForSocBasedCharging = "Estas opciones sólo están disponibles para vehículos con nivel de carga conocido"
 title = "Configuraciones {0}"
 vehicle = "Vehículo"
@@ -286,6 +287,7 @@ label = "Carga de min. %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Fases"
+no1p3pSupport = "¿Cómo está conectado tu cargador?"
 phases_0 = "Cambio automático"
 phases_1 = "monofásica"
 phases_1_hint = "({min} a {max})"
diff --git a/i18n/fi.toml b/i18n/fi.toml
index 1747d414..6ce62616 100644
--- a/i18n/fi.toml
+++ b/i18n/fi.toml
@@ -199,6 +199,7 @@ solar = "Aurinkoenergia"
 [main.loadpointSettings]
 currents = "Latausvirta"
 default = "oletus"
+disclaimerHint = "Huomautus:"
 title = "Asetukset {0}"
 vehicle = "Ajoneuvo"
 
diff --git a/i18n/fr.toml b/i18n/fr.toml
index 7c7d6873..9b3c6103 100644
--- a/i18n/fr.toml
+++ b/i18n/fr.toml
@@ -216,6 +216,7 @@ solar = "Solaire"
 [main.loadpointSettings]
 currents = "Courant de charge"
 default = "défaut"
+disclaimerHint = "Remarque:"
 onlyForSocBasedCharging = "Ces options sont disponibles uniquement pour les véhicules avec niveau de charge connu."
 title = "Réglages {0}"
 vehicle = "Véhicule"
diff --git a/i18n/hr.toml b/i18n/hr.toml
index e1f04c72..54aef691 100644
--- a/i18n/hr.toml
+++ b/i18n/hr.toml
@@ -218,6 +218,7 @@ solar = "Solarno"
 [main.loadpointSettings]
 currents = "Struja napajanja"
 default = "zadano"
+disclaimerHint = "Bilješka:"
 onlyForSocBasedCharging = "Ove su opcije dostupne samo za vozila s poznatom razinom napunjenosti."
 title = "Postavke {0}"
 vehicle = "Vozilo"
diff --git a/i18n/it.toml b/i18n/it.toml
index 1857abb4..f4e179b5 100644
--- a/i18n/it.toml
+++ b/i18n/it.toml
@@ -101,6 +101,7 @@ remoteDisabledSoft = "{source}: Ricarica FV adattiva disabilitata"
 [main.loadpointSettings]
 currents = "Corrente di carica"
 default = "default"
+disclaimerHint = "Nota:"
 title = "Impostazioni {0}"
 vehicle = "Veicolo"
 
diff --git a/i18n/lb.toml b/i18n/lb.toml
index 0a1a7667..f379aa0e 100644
--- a/i18n/lb.toml
+++ b/i18n/lb.toml
@@ -94,6 +94,7 @@ power = "Leeschtung"
 [main.loadpointSettings]
 currents = "Ladestroum"
 default = "Standard"
+disclaimerHint = "Notiz:"
 title = "Astellungen {0}"
 vehicle = "Gefier"
 
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 9808cdd9..d6cd6c7e 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -266,6 +266,7 @@ solar = "Saulės"
 [main.loadpointSettings]
 currents = "Įkrovimo srovė"
 default = "standartiškai"
+disclaimerHint = "Pastaba:"
 onlyForSocBasedCharging = "Šie pasirinkimai galimi tik automobiliams su žinomu įkrovos lygiu."
 title = "Nustatymai {0}"
 vehicle = "Automobilis"
@@ -286,6 +287,7 @@ label = "Minimali įkrova %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Fazės"
+no1p3pSupport = "Kaip prijungtas jūsų įkroviklis?"
 phases_0 = "automatinis perjungimas"
 phases_1 = "1 fazė"
 phases_1_hint = "({min} iki {max})"
diff --git a/i18n/nl.toml b/i18n/nl.toml
index baecd794..b10a3891 100644
--- a/i18n/nl.toml
+++ b/i18n/nl.toml
@@ -1,6 +1,8 @@
 [batterySettings]
 batteryLevel = "Batterij-niveau"
 capacity = "{energy} van {total}"
+control = "Batterij aansturing"
+discharge = "Beperk het ontladen in de snelle modus of bij gepland laden"
 disclaimerHint = "Opgelet:"
 disclaimerText = "Deze instellingen beïnvloeden enkel de zonnemodus. Oplaadgedrag wordt overeenkomstig aangepast."
 legendBottomName = "huis heeft prioriteit"
@@ -14,34 +16,78 @@ legendTopSubline = "zonder onderbrekingen"
 modalTitle = "Batterij-instellingen"
 
 [batterySettings.bufferStart]
-full = "als bijna vol"
+full = "Wanneer bijna vol"
 low = "indien beetje vol"
 medium = "indien half vol"
 never = "enkel bij voldoende overschot"
 
 [config]
 
+[config.battery]
+titleAdd = "Voeg meter voor batterijsysteem toe"
+titleEdit = "Bewerk meter voor batterijsysteem"
+
+[config.deviceValue]
+capacity = "Capaciteit"
+chargeStatus = "Status"
+chargedEnergy = "Geladen"
+current = "Stroomsterke"
+enabled = "Ingeschakeld"
+energy = "Energie"
+odometer = "Kilometerstand"
+phaseCurrents = "Stroomsterkte L1..L3"
+phasePowers = "Vermogen L1..L3"
+phaseVoltages = "Spanning L1..L3"
+power = "Vermogen"
+range = "Rijbereik"
+soc = "Batterijpercentage"
+socLimit = "Limiet"
+
 [config.form]
 example = "Voorbeeld"
 optional = "Optioneel"
 
+[config.grid]
+titleAdd = "Voeg net meter toe"
+titleEdit = "Bewerk net meter"
+
 [config.main]
-addVehicle = "Voertuig toevoegen"
+addLoadpoint = "Voeg laadpunt toe"
+addPvBattery = "Voeg zonnepanelen of batterij toe"
+addVehicle = "Voeg voertuig toe"
 edit = "Bewerken"
 title = "Configuratie"
+unconfigured = "Niet geconfigureerd"
 vehicles = "Mijn voertuigen"
+yaml = "Geconfigureerd in evcc.yml. Niet aanpasbaar in de interface"
+
+[config.meter]
+cancel = "Annuleren"
+delete = "Verwijder"
+save = "Opslaan"
+template = "Fabrikant"
+titleChoice = "Wat wil je toevoegen?"
+validateSave = "Valideren en opslaan"
+
+[config.pv]
+titleAdd = "Voeg meter zonnepanelen toe"
+titleEdit = "Bewerk meter zonnepanelen"
+
+[config.site]
+cancel = "Annuleren"
+save = "Opslaan"
 
 [config.validation]
 failed = "mislukt"
 label = "Status"
-running = "valideren..."
+running = "Bezig met valideren..."
 success = "succesvol"
 unknown = "onbekend"
 validate = "valideer"
 
 [config.vehicle]
 cancel = "Annuleer"
-delete = "Verwijder voertuig"
+delete = "Verwijder"
 generic = "Andere integraties"
 offline = "Algemeen voertuig"
 online = "Voertuig met online API"
@@ -67,21 +113,33 @@ powerSub2 = "laden..."
 tabTitle = "Live community"
 
 [footer.savings]
+co2Saved = "{value} opgeslagen"
+co2Title = "CO₂ uitstoot"
+configurePriceCo2 = "Meer informatie rond het configureren van prijs en CO₂ gegevens"
 footerLong = "{percent}% zonne-energie"
 footerShort = "{percent}% zon"
 modalTitle = "Oplaadenergie-overzicht"
+moneySaved = "{value} opgeslagen"
 percentGrid = "{grid} kWh net"
 percentSelf = "{self} kWh zon"
 percentTitle = "Zonne-energie"
+periodLabel = "Periode:"
 priceFeedIn = "{feedInPrice} injectie"
 priceGrid = "{gridPrice} net"
 priceTitle = "Energieprijs"
+referenceGrid = "net"
+referenceLabel = "Referentiedata:"
 savingsComparedToGrid = "in vergelijking met netafname"
 savingsTitle = "Besparing"
 savingsTotalEnergy = "{total} kWh geladen"
 since = "sinds {since}"
 tabTitle = "Mijn data"
 
+[footer.savings.period]
+30d = "Laatste 30 dagen"
+365d = "Laatste 365 dagen"
+total = "totaal"
+
 [footer.sponsor]
 becomeSponsor = "Word sponsor"
 confetti = "Klaar voor confetti?"
@@ -126,7 +184,7 @@ about = "Over"
 blog = "Blog"
 docs = "Documentatie"
 github = "GitHub"
-login = "Voertuig logins"
+login = "Logingegevens van voertuigen"
 needHelp = "Hulp nodig?"
 sessions = "Laadsessies"
 settings = "Instellingen"
@@ -140,7 +198,7 @@ light = "Design: licht"
 discussionsButton = "GitHub discussies"
 documentationButton = "Documentatie"
 issueButton = "Een bug melden"
-issueDescription = "Vreemd of fout gedrag gevonden?"
+issueDescription = "Vreemd of foutief gedrag gevonden?"
 modalTitle = "Hulp nodig?"
 primaryActions = "Werkt iets niet naar behoren? Er zijn goede plaatsen om hulp te krijgen."
 restartButton = "Herstart"
@@ -158,18 +216,25 @@ modalTitle = "Ben je zeker dat je wil herstarten?"
 vehicles = "Parking"
 
 [main.chargingPlan]
+active = "Actief"
 arrivalTab = "Aankomst"
+day = "Dag"
 departureTab = "Vertrek"
+goal = "Oplaaddoel"
 modalTitle = "Laad Plan"
 none = "geen"
+remove = "Verwijderen"
+time = "Tijd"
 title = "Plan"
 titleMinSoc = "Min lading"
 titleTargetCharge = "Vertrek"
+update = "Toepassen"
 
 [main.energyflow]
 battery = "Batterij"
 batteryCharge = "Batterij opladen"
 batteryDischarge = "Batterij ontladen"
+batteryHold = "Batterij (aanhouden)"
 batteryTooltip = "{energy} van {total} ({soc})"
 gridImport = "Netafname"
 homePower = "Consumptie"
@@ -179,9 +244,15 @@ pvExport = "Netinjectie"
 pvProduction = "Productie"
 selfConsumption = "Zelfverbruik"
 
+[main.heatingStatus]
+charging = "Bezig met verwarmen..."
+cheapEnergyCharging = "Verwarmen met goedkope energie: {price} (limiet {limit})"
+cleanEnergyCharging = "Verwarmen met schone energie: {co2} (limiet {limit})"
+waitForVehicle = "Klaar. Wachten op voertuigverwarming"
+
 [main.loadpoint]
 avgPrice = "⌀ Prijs"
-charged = "Geladen"
+charged = "Lading"
 co2 = "⌀ CO₂"
 duration = "Duur"
 fallbackName = "Laadpunt"
@@ -195,22 +266,29 @@ solar = "Zon"
 [main.loadpointSettings]
 currents = "Laadstroom"
 default = "standaard"
+disclaimerHint = "Let op:"
+onlyForSocBasedCharging = "Deze instellingen zijn alleen beschikbaar bij wagens waar het batterijpercentage gekend is"
 title = "Instellingen {0}"
 vehicle = "Voertuig"
 
+[main.loadpointSettings.limitSoc]
+description = "Laadlimiet wanneer dit voertuig aangesloten wordt"
+label = "Standaard limiet"
+
 [main.loadpointSettings.maxCurrent]
-label = "Max. Stroom"
+label = "Max. Stroomsterkte"
 
 [main.loadpointSettings.minCurrent]
-label = "Min. Stroom"
+label = "Min. Stroomsterkte"
 
 [main.loadpointSettings.minSoc]
-description = "Het voertuig wordt „snel” opgeladen tot {0}% in deze modus, en gaat dan verder met alleen het zonneoverschot."
-label = "Min. lading %"
+description = "Het voertuig wordt „snel” opgeladen tot {0}% in deze modus. Het laden gaat daarna verder met zonneoverschot. Nuttig om een bepaald rijbereik te garanderen, ook gedurende periodes met weinig of geen zonnestroom."
+label = "Min. batterij %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Fasen"
-phases_0 = "automatisch omschakelen"
+no1p3pSupport = "Hoe is je lader aangesloten?"
+phases_0 = "automatisch wisselen van aantal fases"
 phases_1 = "1 fase"
 phases_1_hint = "({min} tot {max})"
 phases_3 = "3 fase"
@@ -230,24 +308,31 @@ logout = "afmelden"
 activate = "Activeren"
 co2Limit = "CO₂ limiet van {co2}"
 costLimitIgnore = "De geconfigureerde {limit} zal genegeerd worden tijdens deze periode."
+currentPlan = "Geactiveerd laadplan"
 descriptionEnergy = "Tegen wanneer moet {targetEnergy} in het voertuig geladen zijn?"
 descriptionSoc = "Wanneer moet het voertuig geladen zijn naar {targetSoc}%?"
 inactiveLabel = "Ingestelde tijd"
 modalTitle = "Stel tijd in"
+notReachableInTime = "Laaddoel niet haalbaar bij gevraagde eindtijd. Geschatte eindtijd: {endTime}."
 onlyInPvMode = "Laadplan werkt alleen in PV modus"
 planDuration = "Oplaadtijd"
 planPeriodLabel = "Periode"
 planPeriodValue = "{start} tot {end}"
 planUnknown = "nog niet bekend"
+preview = "Schatting laadplan"
 priceLimit = "prijslimiet van {price}"
 remove = "Verwijder"
 setTargetTime = "geen"
+targetIsAboveLimit = "De ingestelde laadlimiet {limit} wordt genegeerd gedurende deze periode."
+targetIsAboveVehicleLimit = "Verhoog de limiet ({limit}) om het laaddoel te halen"
 targetIsInThePast = "Kies een tijd in de toekomst, Marty."
 targetIsTooFarInTheFuture = "We passen het plan aan zodra we meer weten over de toekomst."
 title = "Doeltijd"
 today = "Vandaag"
 tomorrow = "Morgen"
 update = "Updaten"
+vehicleCapacityDocs = "Meer informatie over het configureren hiervan"
+vehicleCapacityRequired = "De batterijcapaciteit van het voertuig is een vereiste parameter om de oplaadtijd te kunnen bepalen"
 
 [main.targetChargePlan]
 chargeDuration = "Oplaadtijd"
@@ -282,8 +367,8 @@ vehicleTarget = "Voertuiglimiet: {soc}%"
 
 [main.vehicleStatus]
 charging = "Opladen..."
-cheapEnergyCharging = "Goedkope energie beschikbaar. Aan het laden…"
-cleanEnergyCharging = "Groene energie beschikbaar. Aan het laden…"
+cheapEnergyCharging = "Laden actief met goedkope energie"
+cleanEnergyCharging = "Laden actief met schone energie. {co2} (limit {limit})"
 climating = "Voor-conditionering gedetecteerd."
 connected = "Verbonden."
 disconnected = "Niet verbonden."
@@ -358,7 +443,7 @@ allVehicles = "alle voertuigen"
 filter = "filter"
 
 [settings]
-title = "Instellingen"
+title = "Algemene instellingen"
 
 [settings.gridDetails]
 co2 = "CO₂"
diff --git a/i18n/no.toml b/i18n/no.toml
index 43296d46..60c92d4e 100644
--- a/i18n/no.toml
+++ b/i18n/no.toml
@@ -192,6 +192,7 @@ solar = "Solenergi"
 [main.loadpointSettings]
 currents = "Ladestrøm"
 default = "forvalg"
+disclaimerHint = "Merknad:"
 title = "Innstillinger for {0}"
 vehicle = "Kjøretøy"
 
diff --git a/i18n/pl.toml b/i18n/pl.toml
index 196c666a..8123c8c2 100644
--- a/i18n/pl.toml
+++ b/i18n/pl.toml
@@ -217,6 +217,7 @@ solar = "Słoneczna"
 [main.loadpointSettings]
 currents = "Prąd ładowania"
 default = "domyślny"
+disclaimerHint = "Uwaga:"
 onlyForSocBasedCharging = "Opcje te są dostępne tylko dla pojazdów ze znanym poziomem naładowania."
 title = "Ustawienia {0}"
 vehicle = "Pojazd"
diff --git a/i18n/pt.toml b/i18n/pt.toml
index 5c081fec..15f3d569 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -216,6 +216,7 @@ solar = "Sol"
 [main.loadpointSettings]
 currents = "Corrente de carga"
 default = "predefinado"
+disclaimerHint = "Aviso:"
 onlyForSocBasedCharging = "Estas opções só estão disponíveis para veículos com nível de carregamento conhecido."
 title = "Configurações {0}"
 vehicle = "Veículo"
diff --git a/i18n/ro.toml b/i18n/ro.toml
index 0a478fd5..1881d1da 100644
--- a/i18n/ro.toml
+++ b/i18n/ro.toml
@@ -160,6 +160,7 @@ power = "Putere"
 [main.loadpointSettings]
 currents = "Curent încărcare"
 default = "default"
+disclaimerHint = "Nota:"
 title = "Setări {0}"
 vehicle = "Vehicul"
 
diff --git a/i18n/ru.toml b/i18n/ru.toml
index ce68153d..9c8aaacb 100644
--- a/i18n/ru.toml
+++ b/i18n/ru.toml
@@ -123,6 +123,7 @@ power = "Мощность"
 [main.loadpointSettings]
 currents = "Ток зарядки"
 default = "по умолчанию"
+disclaimerHint = "Заметка:"
 title = "Настройки {0}"
 vehicle = "Транспортное средство"
 
diff --git a/i18n/sl.toml b/i18n/sl.toml
index fc7b5038..79907a2d 100644
--- a/i18n/sl.toml
+++ b/i18n/sl.toml
@@ -217,6 +217,7 @@ solar = "Sončna energija"
 [main.loadpointSettings]
 currents = "Polnilni tok"
 default = "privzeto"
+disclaimerHint = "Opomba:"
 onlyForSocBasedCharging = "Te možnosti so na voljo samo za vozila z znano stopnjo napolnjenosti."
 title = "Nastavitve {0}"
 vehicle = "Vozilo"
diff --git a/i18n/sv.toml b/i18n/sv.toml
index 8b0181f9..f2019e81 100644
--- a/i18n/sv.toml
+++ b/i18n/sv.toml
@@ -23,15 +23,59 @@ never = "bara med tillräckligt sol-överskott"
 
 [config]
 
+[config.battery]
+titleAdd = "Lägg till batterimätare"
+titleEdit = "Ändra batterimätare"
+
+[config.deviceValue]
+capacity = "Kapacitet"
+chargeStatus = "Status"
+chargedEnergy = "Laddad"
+current = "Nuvarande"
+enabled = "Aktiverad"
+energy = "Energi"
+odometer = "Mätarställning"
+phaseCurrents = "Ström L1..L3"
+phasePowers = "Effekt L1..L3"
+phaseVoltages = "Spänning L1..L3"
+power = "Effekt"
+range = "Räckvidd"
+soc = "SoC"
+socLimit = "SoC gräns"
+
 [config.form]
 example = "Exempel"
 optional = "valfri"
 
+[config.grid]
+titleAdd = "Lägg till elnätsmätare"
+titleEdit = "Ändra elnätsmätare"
+
 [config.main]
+addLoadpoint = "Lägg till laddbox"
+addPvBattery = "Lägg till solceller eller batteri"
 addVehicle = "Lägg till fordon"
 edit = "ändra"
 title = "Inställningar"
+unconfigured = "ej konfigurerad"
 vehicles = "Mina fordon"
+yaml = "Konfigurera i evcc.yaml. Kan inte ändras i UI."
+
+[config.meter]
+cancel = "Avbryt"
+delete = "Radera"
+save = "Spara"
+template = "Tillverkare"
+titleChoice = "Vad vill du lägga till?"
+validateSave = "Validera & spara"
+
+[config.pv]
+titleAdd = "Lägg till solcellsmätare"
+titleEdit = "Ändra solcellsmätare"
+
+[config.site]
+cancel = "Avbryt"
+save = "Spara"
 
 [config.validation]
 failed = "misslyckades"
@@ -43,7 +87,7 @@ validate = "validerar"
 
 [config.vehicle]
 cancel = "Avbryt"
-delete = "Radera fordon"
+delete = "Radera"
 generic = "Andra integrationer"
 offline = "Generiskt fordon"
 online = "Fordon med online API"
@@ -142,7 +186,7 @@ docs = "Dokumentation"
 github = "GitHub"
 login = "Fordons inloggningar"
 needHelp = "Behöver du hjälp?"
-sessions = "Laddnings sessioner"
+sessions = "Laddningar"
 settings = "Inställningar"
 
 [help]
@@ -195,6 +239,12 @@ pvExport = "Export till elnät"
 pvProduction = "Produktion"
 selfConsumption = "Egenförbrukning"
 
+[main.heatingStatus]
+charging = "Värmer..."
+cheapEnergyCharging = "Värmer med billig energi: {price} (limit {limit})"
+cleanEnergyCharging = "Värmer med CO2-neutral energi: {co2} (limit {limit})"
+waitForVehicle = "Redo, väntar på värmare..."
+
 [main.loadpoint]
 avgPrice = "⌀ pris"
 charged = "Laddat"
@@ -209,8 +259,9 @@ remoteDisabledSoft = "{source}: Adaptiv solladdning avstängd"
 solar = "Solenergi"
 
 [main.loadpointSettings]
-currents = "Laddningsström"
+currents = "Laddström"
 default = "förvald"
+disclaimerHint = "Anmärkning:"
 onlyForSocBasedCharging = "Dessa inställningar är endast tillgängliga för fordon med känd laddnivå"
 title = "Inställningar {0}"
 vehicle = "Fordon"
@@ -231,6 +282,7 @@ label = "Min. laddning %"
 
 [main.loadpointSettings.phasesConfigured]
 label = "Faser"
+no1p3pSupport = "Hur är din laddbox ansluten?"
 phases_0 = "automatisk växling"
 phases_1 = "1-fas"
 phases_1_hint = "({min} till {max})"
@@ -281,7 +333,7 @@ vehicleCapacityDocs = "Lär dig hur du konfigurerar."
 vehicleCapacityRequired = "Fordonets batterikapacitet behövs för att beräkna laddtid."
 
 [main.targetChargePlan]
-chargeDuration = "Laddningstid"
+chargeDuration = "Laddtid"
 co2Label = "CO₂-utsläpp ⌀"
 priceLabel = "Energipris"
 timeRange = "{day} {range} t"
@@ -351,7 +403,7 @@ meterstop = "Mätare stopp"
 odometer = "Mätarställning"
 price = "Pris"
 started = "Start-tid"
-title = "Laddningssession"
+title = "Laddning"
 
 [sessions]
 avgPower = "⌀-effekt"
diff --git a/i18n/uk.toml b/i18n/uk.toml
index 2f575d06..493261c8 100644
--- a/i18n/uk.toml
+++ b/i18n/uk.toml
@@ -188,6 +188,7 @@ solar = "Сонячна"
 [main.loadpointSettings]
 currents = "Струм зарядки"
 default = "за замовчуванням"
+disclaimerHint = "Примітка:"
 title = "Налаштування {0}"
 vehicle = "Транспортний засіб"
 
diff --git a/i18n/zh-Hans.toml b/i18n/zh-Hans.toml
index 77ab9a14..f6167e62 100644
--- a/i18n/zh-Hans.toml
+++ b/i18n/zh-Hans.toml
@@ -198,6 +198,7 @@ solar = "太阳能"
 [main.loadpointSettings]
 currents = "充电货币"
 default = "默认"
+disclaimerHint = "笔记:"
 title = "设置 {0}"
 vehicle = "车辆"
 
diff --git a/meter/goodwe-wifi.go b/meter/goodwe-wifi.go
index 26652e21..c8ea6abf 100644
--- a/meter/goodwe-wifi.go
+++ b/meter/goodwe-wifi.go
@@ -1,15 +1,17 @@
 package meter
 
 import (
+	"time"
+
 	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/meter/goodwe"
 	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
 )
 
 type goodWeWiFi struct {
-	*goodwe.Server
-	usage string
-	uri   string
+	usage    string
+	inverter *util.Monitor[goodwe.Inverter]
 }
 
 func init() {
@@ -19,31 +21,36 @@ func init() {
 //go:generate go run ../cmd/tools/decorate.go -f decorateGoodWeWifi -b *goodWeWiFi -r api.Meter -t "api.Battery,Soc,func() (float64, error)"
 
 func NewGoodWeWifiFromConfig(other map[string]interface{}) (api.Meter, error) {
-	var cc struct {
+	cc := struct {
 		capacity   `mapstructure:",squash"`
 		URI, Usage string
+		Timeout    time.Duration
+	}{
+		Timeout: request.Timeout,
 	}
 
 	if err := util.DecodeOther(other, &cc); err != nil {
 		return nil, err
 	}
 
-	return NewGoodWeWiFi(cc.URI, cc.Usage)
+	return NewGoodWeWiFi(cc.URI, cc.Usage, cc.Timeout)
 }
 
-func NewGoodWeWiFi(uri string, usage string) (api.Meter, error) {
-	instance, err := goodwe.Instance()
+func NewGoodWeWiFi(uri, usage string, timeout time.Duration) (api.Meter, error) {
+	instance, err := goodwe.Instance(util.NewLogger("goodwe-wifi"))
 	if err != nil {
 		return nil, err
 	}
 
-	res := &goodWeWiFi{
-		Server: instance,
-		usage:  usage,
-		uri:    uri,
+	inverter := instance.GetInverter(uri)
+	if inverter == nil {
+		inverter = instance.AddInverter(uri, timeout)
 	}
 
-	instance.AddInverter(uri)
+	res := &goodWeWiFi{
+		usage:    usage,
+		inverter: inverter,
+	}
 
 	// decorate api.BatterySoc
 	var batterySoc func() (float64, error)
@@ -55,7 +62,10 @@ func NewGoodWeWiFi(uri string, usage string) (api.Meter, error) {
 }
 
 func (m *goodWeWiFi) CurrentPower() (float64, error) {
-	data := m.GetInverter(m.uri)
+	data, err := m.inverter.Get()
+	if err != nil {
+		return 0, err
+	}
 
 	switch m.usage {
 	case "grid":
@@ -69,5 +79,6 @@ func (m *goodWeWiFi) CurrentPower() (float64, error) {
 }
 
 func (m *goodWeWiFi) batterySoc() (float64, error) {
-	return m.GetInverter(m.uri).Soc, nil
+	data, err := m.inverter.Get()
+	return data.Soc, err
 }
diff --git a/meter/goodwe/server.go b/meter/goodwe/server.go
index 9a847c3a..ac40acfa 100644
--- a/meter/goodwe/server.go
+++ b/meter/goodwe/server.go
@@ -5,6 +5,8 @@ import (
 	"net"
 	"sync"
 	"time"
+
+	"github.com/evcc-io/evcc/util"
 )
 
 var (
@@ -12,7 +14,7 @@ var (
 	mu       sync.RWMutex
 )
 
-func Instance() (*Server, error) {
+func Instance(log *util.Logger) (*Server, error) {
 	mu.Lock()
 	defer mu.Unlock()
 
@@ -21,7 +23,8 @@ func Instance() (*Server, error) {
 	}
 
 	instance = &Server{
-		inverters: make(map[string]Inverter),
+		log:       log,
+		inverters: make(map[string]*util.Monitor[Inverter]),
 	}
 
 	addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:8899")
@@ -40,70 +43,81 @@ func Instance() (*Server, error) {
 	return instance, err
 }
 
-func (m *Server) AddInverter(ip string) {
+func (m *Server) AddInverter(ip string, timeout time.Duration) *util.Monitor[Inverter] {
 	mu.Lock()
 	defer mu.Unlock()
-	m.inverters[ip] = Inverter{IP: ip}
+	monitor := util.NewMonitor[Inverter](timeout)
+	m.inverters[ip] = monitor
+	return monitor
 }
 
-func (m *Server) GetInverter(uri string) Inverter {
+func (m *Server) GetInverter(ip string) *util.Monitor[Inverter] {
 	mu.RLock()
 	defer mu.RUnlock()
-	return m.inverters[uri]
+	return m.inverters[ip]
 }
 
 func (m *Server) readData() {
-	for _, inverter := range m.inverters {
-		addr, err := net.ResolveUDPAddr("udp", inverter.IP+":8899")
-		if err != nil {
-			return
-		}
-
-		if _, err := m.conn.WriteToUDP([]byte{0xF7, 0x03, 0x89, 0x1C, 0x00, 0x7D, 0x7A, 0xE7}, addr); err != nil {
-			return
+	for {
+		mu.RLock()
+		ips := make([]string, 0, len(m.inverters))
+		for ip := range m.inverters {
+			ips = append(ips, ip)
 		}
-		time.Sleep(5 * time.Second)
-		if _, err := m.conn.WriteToUDP([]byte{0xF7, 0x03, 0x90, 0x88, 0x00, 0x0D, 0x3D, 0xB3}, addr); err != nil {
-			return
+		mu.RUnlock()
+
+		for _, ip := range ips {
+			addr, err := net.ResolveUDPAddr("udp", net.JoinHostPort(ip, "8899"))
+			if err != nil {
+				m.log.ERROR.Println(err)
+				continue
+			}
+			if _, err := m.conn.WriteToUDP([]byte{0xF7, 0x03, 0x89, 0x1C, 0x00, 0x7D, 0x7A, 0xE7}, addr); err != nil {
+				m.log.ERROR.Println(err)
+				continue
+			}
+			time.Sleep(5 * time.Second)
+			if _, err := m.conn.WriteToUDP([]byte{0xF7, 0x03, 0x90, 0x88, 0x00, 0x0D, 0x3D, 0xB3}, addr); err != nil {
+				m.log.ERROR.Println(err)
+				continue
+			}
 		}
 	}
-	m.readData()
 }
 
 func (m *Server) listen() {
 	for {
 		buf := make([]byte, 1024)
-		_, addr, err := m.conn.ReadFromUDP(buf)
+		n, addr, err := m.conn.ReadFromUDP(buf)
 		if err != nil {
+			m.log.ERROR.Println(err)
 			continue
 		}
+		m.log.TRACE.Printf("recv from %s: % X", addr, buf[:n])
 
 		ip := addr.IP.String()
-
-		mu.Lock()
-		if buf[4] == 250 {
-			vPv1 := float64(int16(binary.BigEndian.Uint16(buf[11:]))) * 0.1
-			vPv2 := float64(int16(binary.BigEndian.Uint16(buf[19:]))) * 0.1
-			iPv1 := float64(int16(binary.BigEndian.Uint16(buf[13:]))) * 0.1
-			iPv2 := float64(int16(binary.BigEndian.Uint16(buf[21:]))) * 0.1
-			iBatt := float64(int16(binary.BigEndian.Uint16(buf[167:]))) * 0.1
-			vBatt := float64(int16(binary.BigEndian.Uint16(buf[165:]))) * 0.1
-
-			pvPower := vPv1*iPv1 + vPv2*iPv2
-
-			inverter := m.inverters[ip]
-			inverter.PvPower = pvPower
-			inverter.BatteryPower = vBatt * iBatt
-			inverter.NetPower = -float64(int32(binary.BigEndian.Uint32(buf[83:])))
-
-			m.inverters[ip] = inverter
+		monitor := m.GetInverter(ip)
+		if monitor == nil {
+			m.log.ERROR.Println("unknown inverter:", ip)
+			continue
 		}
 
-		if buf[4] == 26 {
-			inverter := m.inverters[ip]
-			inverter.Soc = float64(buf[20])
-			m.inverters[ip] = inverter
-		}
-		mu.Unlock()
+		monitor.SetFunc(func(inverter Inverter) Inverter {
+			if buf[4] == 250 {
+				ui := func(u, i int16) float64 {
+					return float64(int16(binary.BigEndian.Uint16(buf[u:]))) *
+						float64(int16(binary.BigEndian.Uint16(buf[i:]))) / 100
+				}
+				inverter.PvPower = ui(11, 13) + ui(19, 21)
+				inverter.BatteryPower = ui(165, 167)
+				inverter.NetPower = -float64(int32(binary.BigEndian.Uint32(buf[83:])))
+			}
+
+			if buf[4] == 26 {
+				inverter.Soc = float64(buf[20])
+			}
+
+			return inverter
+		})
 	}
 }
diff --git a/meter/goodwe/types.go b/meter/goodwe/types.go
index 06bb9986..02c372eb 100644
--- a/meter/goodwe/types.go
+++ b/meter/goodwe/types.go
@@ -2,15 +2,17 @@ package goodwe
 
 import (
 	"net"
+
+	"github.com/evcc-io/evcc/util"
 )
 
 type Server struct {
+	log       *util.Logger
 	conn      *net.UDPConn
-	inverters map[string]Inverter
+	inverters map[string]*util.Monitor[Inverter]
 }
 
 type Inverter struct {
-	IP           string
 	PvPower      float64
 	NetPower     float64
 	BatteryPower float64
diff --git a/provider/go.go b/provider/go.go
index ecf13871..d9e4610a 100644
--- a/provider/go.go
+++ b/provider/go.go
@@ -159,7 +159,7 @@ func (p *Go) handleSetter(param string, val any) error {
 func (p *Go) evaluate() (res any, err error) {
 	defer func() {
 		if r := recover(); r != nil {
-			err = fmt.Errorf("go script panic: %v", r)
+			err = fmt.Errorf("panic: %v", r)
 		}
 	}()
 
diff --git a/provider/modbus_sunspec.go b/provider/modbus_sunspec.go
index aa9a5d29..39d7aec1 100644
--- a/provider/modbus_sunspec.go
+++ b/provider/modbus_sunspec.go
@@ -31,7 +31,7 @@ func init() {
 func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error) {
 	cc := struct {
 		modbus.Settings `mapstructure:",squash"`
-		Value           string
+		Value           []string
 		Scale           float64
 		Delay           time.Duration
 		ConnectDelay    time.Duration
@@ -67,7 +67,7 @@ func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error)
 	log := util.NewLogger("sunspec")
 	conn.Logger(log.TRACE)
 
-	if cc.Value == "" {
+	if len(cc.Value) == 0 {
 		return nil, errors.New("value is required")
 	}
 
@@ -77,9 +77,13 @@ func NewModbusSunspecFromConfig(other map[string]interface{}) (Provider, error)
 		return nil, err
 	}
 
-	ops, err := modbus.ParsePoint(cc.Value)
-	if err != nil {
-		return nil, fmt.Errorf("invalid sunspec value: %s", cc.Value)
+	var ops []modbus.SunSpecOperation
+	for _, val := range cc.Value {
+		op, err := modbus.ParsePoint(val)
+		if err != nil {
+			return nil, fmt.Errorf("invalid sunspec value: %s", cc.Value)
+		}
+		ops = append(ops, op)
 	}
 
 	mb := &ModbusSunspec{
diff --git a/templates/definition/meter/fronius-ohmpilot.yaml b/templates/definition/meter/fronius-ohmpilot.yaml
new file mode 100644
index 00000000..19ceb0ab
--- /dev/null
+++ b/templates/definition/meter/fronius-ohmpilot.yaml
@@ -0,0 +1,19 @@
+template: fronius-ohmpilot
+products:
+  - brand: Fronius
+    description:
+      generic: Ohmpilot
+params:
+  - name: usage
+    choice: ["aux"]
+  - name: host
+render: |
+  type: custom
+  power:
+    source: http
+    uri: http://{{ .host }}/solar_api/v1/GetPowerFlowRealtimeData.fcgi
+    jq: if .Body.Data.Smartloads.Ohmpilots."720896".P_AC_Total == null then 0 else .Body.Data.Smartloads.Ohmpilots."720896".P_AC_Total * -1 end
+  soc:
+    source: http
+    uri: http://{{ .host }}/solar_api/v1/GetPowerFlowRealtimeData.fcgi
+    jq: if .Body.Data.Smartloads.Ohmpilots."720896".Temperature == null then 0 else .Body.Data.Smartloads.Ohmpilots."720896".Temperature end
diff --git a/templates/definition/meter/smartfox-em2.yaml b/templates/definition/meter/smartfox-em2.yaml
index 3901c38c..7062502e 100644
--- a/templates/definition/meter/smartfox-em2.yaml
+++ b/templates/definition/meter/smartfox-em2.yaml
@@ -6,14 +6,12 @@ products:
 requirements:
   description:
     de: |
-      Kann verwendet werden, um Daten für `grid`, `pv` und `aux` zu erhalten. 
-      Wenn `usage` nicht definiert ist, wird die Leistung für die Warmwasserbereitung zurückgegeben (als `aux` zu verwenden).
+      `aux` kann für die Leistung der Warmwasserbereitung verwendet werden.
     en: |
-      Can be used to get `grid`, `pv` and `aux` data. 
-      If `usage` is not defined, then return the power for the water heating (to be used as `aux`).
+      `aux` can be used for water heating power.
 params:
   - name: usage
-    choice: ["grid", "pv"]
+    choice: ["grid", "pv", "aux"]
   - name: host
 render: |
   {{- define "uri" -}}
@@ -21,13 +19,6 @@ render: |
   {{- end }}
   type: custom
   # jq: parse json generated from response values.xml (https://jqplay.org is your friend to test queries)
-  {{- if not .usage }}
-  power: # heating power in W
-    source: http
-    uri: {{ include "uri" . }}
-    jq: .values.value[] | select(.attrid=="htPowerMeasValue")."#content" | rtrimstr(" kW")
-    scale: 1000
-  {{- end }}
   {{- if eq .usage "grid" }}
   power: # grid power in W
     source: http
@@ -80,3 +71,10 @@ render: |
     uri: {{ include "uri" . }}
     jq: '[.values.value[] | select(.attrid|test("wr\\d+EnergyValue"))."#content" | rtrimstr(" kWh") | tonumber] | add'
   {{- end }}
+  {{- if eq .usage "aux" }}
+  power: # heating power in W
+    source: http
+    uri: {{ include "uri" . }}
+    jq: .values.value[] | select(.attrid=="htPowerMeasValue")."#content" | rtrimstr(" kW")
+    scale: 1000
+  {{- end }}
diff --git a/templates/definition/meter/smartfox.yaml b/templates/definition/meter/smartfox.yaml
index 6a4a4a1c..b79d8fa2 100644
--- a/templates/definition/meter/smartfox.yaml
+++ b/templates/definition/meter/smartfox.yaml
@@ -6,26 +6,18 @@ products:
 requirements:
   description:
     de: |
-      Kann verwendet werden, um Daten für `grid`, `pv` und `aux` zu erhalten. 
-      Wenn `usage` nicht definiert ist, wird die Leistung für die Warmwasserbereitung zurückgegeben (als `aux` zu verwenden).
+      `aux` kann für die Leistung der Warmwasserbereitung verwendet werden.
     en: |
-      Can be used to get `grid`, `pv` and `aux` data. 
-      If `usage` is not defined, then return the power for the water heating (to be used as `aux`).
+      `aux` can be used for water heating power.
 params:
   - name: usage
-    choice: ["grid", "pv"]
+    choice: ["grid", "pv", "aux"]
   - name: host
 render: |
   {{- define "uri" -}}
   http://{{ .host }}/all
   {{- end }}
   type: custom
-  {{- if not .usage }}
-  power:
-    source: http
-    uri: {{ include "uri" . }}
-    jq: .power_sf
-  {{- end }}
   {{- if eq .usage "grid" }}
   power:
     source: http
@@ -78,3 +70,9 @@ render: |
     jq: .PvEnergy[0]
     scale: 0.001
   {{- end }}
+  {{- if eq .usage "aux" }}
+  power:
+    source: http
+    uri: {{ include "uri" . }}
+    jq: .power_sf
+  {{- end }}
diff --git a/templates/definition/meter/sunspec-hybrid.yaml b/templates/definition/meter/sunspec-hybrid.yaml
index 72c0fa53..4c44d1e0 100644
--- a/templates/definition/meter/sunspec-hybrid.yaml
+++ b/templates/definition/meter/sunspec-hybrid.yaml
@@ -15,59 +15,104 @@ params:
     advanced: true
 render: |
   {{- if eq .usage "grid" }}
-  type: mbmd
-  {{- include "modbus" . }}
+  type: custom
   # sunspec model 203 (int+sf)/ 213 (float) meter
-  power: 203|213:W
-  energy: 203|213:TotWhImp
+  power:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value: 
+      - 203:W
+      - 213:W
+  energy:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value: 
+      - 203:TotWhImp
+      - 213:TotWhImp
   currents:
-    - 203|213:AphA
-    - 203|213:AphB
-    - 203|213:AphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphA
+        - 213:AphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphB
+        - 213:AphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphC
+        - 213:AphC
   voltages:
-    - 203|213:PhVphA
-    - 203|213:PhVphB
-    - 203|213:PhVphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphA
+        - 213:PhVphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphB
+        - 213:PhVphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphC
+        - 213:PhVphC
   powers:
-    - 203|213:WphA
-    - 203|213:WphB
-    - 203|213:WphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphA
+        - 213:WphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphB
+        - 213:WphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphC
+        - 213:WphC
   {{- end }}
   {{- if eq .usage "pv" }}
   type: custom
   power:
     source: calc
     add:
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:1:DCW # mppt 1
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:2:DCW # mppt 2
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:1:DCW # mppt 1
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:2:DCW # mppt 2
   energy:
     source: calc
     add:
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:1:DCWH # mppt 1
-      scale: 0.001
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:2:DCWH # mppt 2
-      scale: 0.001
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:1:DCWH # mppt 1
+        scale: 0.001
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:2:DCWH # mppt 2
+        scale: 0.001
   {{- end }}
   {{- if eq .usage "battery" }}
   type: custom
   power:
     source: calc
     add:
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:3:DCW # mppt 3 (charge)
-      scale: -1
-    - source: sunspec
-      {{- include "modbus" . | indent 4 }}
-      value: 160:4:DCW # mppt 4 (discharge)
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:3:DCW # mppt 3 (charge)
+        scale: -1
+      - source: sunspec
+        {{- include "modbus" . | indent 6 }}
+        value: 160:4:DCW # mppt 4 (discharge)
   energy:
     source: sunspec
     {{- include "modbus" . | indent 2 }}
@@ -76,8 +121,8 @@ render: |
   soc:
     source: sunspec
     {{- include "modbus" . | indent 2 }}
-    value: 124:ChaState
-  {{- if .capacity }}
+    value:
+      - 124:ChaState
+      - 802:SoC
   capacity: {{ .capacity }} # kWh
   {{- end }}
-  {{- end }}
diff --git a/templates/definition/meter/sunspec-inverter.yaml b/templates/definition/meter/sunspec-inverter.yaml
index 031227db..8a7ac6d1 100644
--- a/templates/definition/meter/sunspec-inverter.yaml
+++ b/templates/definition/meter/sunspec-inverter.yaml
@@ -9,63 +9,96 @@ params:
     choice: ["grid", "pv", "battery"]
   - name: modbus
     choice: ["tcpip", "rs485"]
-  - name: integer
-    description:
-      de: Integer Registerzugriff (nur für Netzzähler)
-      en: Integer register access (grid meter only)
-    help:
-      de: Einstellung Float/Integer im Wechselrichter überprüfen
-      en: Verify Float/Integer setting in inverter
-    advanced: true
-    type: bool
   - name: capacity
     advanced: true
 render: |
-  type: mbmd
-  {{- include "modbus" . }}
   {{- if eq .usage "grid" }}
-  model: sunspec
-  {{- if eq .integer "true" }}
-  # sunspec model 203 (int+sf) meter
-  power: 203:W
-  energy: 203:TotWhImp
-  currents:
-    - 203:AphA
-    - 203:AphB
-    - 203:AphC
-  voltages:
-    - 203:PhVphA
-    - 203:PhVphB
-    - 203:PhVphC
-  powers:
-    - 203:WphA
-    - 203:WphB
-    - 203:WphC
-  {{ else }}
-  # sunspec model 213 (float) meter
-  power: 213:W
-  energy: 213:TotWhImp
+  type: custom
+  # sunspec model 203 (int+sf)/ 213 (float) meter
+  power:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value: 
+      - 203:W
+      - 213:W
+  energy:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value: 
+      - 203:TotWhImp
+      - 213:TotWhImp
   currents:
-    - 213:AphA
-    - 213:AphB
-    - 213:AphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphA
+        - 213:AphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphB
+        - 213:AphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:AphC
+        - 213:AphC
   voltages:
-    - 213:PhVphA
-    - 213:PhVphB
-    - 213:PhVphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphA
+        - 213:PhVphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphB
+        - 213:PhVphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:PhVphC
+        - 213:PhVphC
   powers:
-    - 213:WphA
-    - 213:WphB
-    - 213:WphC
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphA
+        - 213:WphA
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphB
+        - 213:WphB
+    - source: sunspec
+      {{- include "modbus" . | indent 4 }}
+      value: 
+        - 203:WphC
+        - 213:WphC
+  {{- end }}
+  {{- if eq .usage "pv" }}
+  type: custom
+  power:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value:
+      - 101:W
+      - 103:W
+  energy:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value:
+      - 101:WH
+      - 103:WH
+    scale: 0.001
   {{- end }}
-  {{- else }}
-  model: sunspec
-  power: Power
-  energy: Export
   {{- if eq .usage "battery" }}
-  soc: 124:ChaState
-  {{- if .capacity }}
+  type: custom
+  soc:
+    source: sunspec
+    {{- include "modbus" . | indent 2 }}
+    value:
+      - 124:ChaState
+      - 802:SoC
   capacity: {{ .capacity }} # kWh
   {{- end }}
-  {{- end }}  
-  {{- end }}
diff --git a/util/modbus/mbmd.go b/util/modbus/mbmd.go
index 7ceeece8..ec9693eb 100644
--- a/util/modbus/mbmd.go
+++ b/util/modbus/mbmd.go
@@ -21,11 +21,9 @@ func ParseOperation(dev meters.Device, measurement string) (Operation, error) {
 	// if measurement cannot be parsed it could be SunSpec model/block/point
 	op.MBMD.IEC61850, err = meters.MeasurementString(measurement)
 	if err != nil {
-		suns, err := ParsePoint(measurement)
-		if err == nil {
-			op.SunSpec = suns[0]
+		if op.SunSpec, err = ParsePoint(measurement); err != nil {
+			return op, err
 		}
-		return op, err
 	}
 
 	// for RS485 check if producer supports the measurement
diff --git a/util/modbus/register.go b/util/modbus/register.go
index c672bfaf..3f7c8096 100644
--- a/util/modbus/register.go
+++ b/util/modbus/register.go
@@ -22,9 +22,6 @@ type Register struct {
 }
 
 func (r Register) Error() error {
-	if r.Address == 0 {
-		return errors.New("address is required")
-	}
 	if r.Type == "" {
 		return errors.New("type is required")
 	}
diff --git a/util/modbus/sunspec.go b/util/modbus/sunspec.go
index 363ef73c..8b862910 100644
--- a/util/modbus/sunspec.go
+++ b/util/modbus/sunspec.go
@@ -13,39 +13,30 @@ type SunSpecOperation struct {
 }
 
 // ParsePoint parses sunspec point from string
-func ParsePoint(selector string) ([]SunSpecOperation, error) {
+func ParsePoint(selector string) (SunSpecOperation, error) {
+	var (
+		res SunSpecOperation
+		err error
+	)
+
 	el := strings.Split(selector, ":")
 	if len(el) < 2 || len(el) > 3 {
-		return nil, fmt.Errorf("invalid sunspec format: %s", selector)
+		return res, fmt.Errorf("invalid sunspec format: %s", selector)
 	}
 
-	models := strings.Split(el[0], "|")
-	if len(models) == 0 {
-		return nil, fmt.Errorf("missing sunspec model: %s", selector)
+	if res.Model, err = strconv.Atoi(el[0]); err != nil {
+		return res, fmt.Errorf("invalid sunspec model: %s", selector)
 	}
 
-	var res []SunSpecOperation
-	for _, m := range models {
-		model, err := strconv.Atoi(m)
+	if len(el) == 3 {
+		// block is the middle element
+		res.Block, err = strconv.Atoi(el[1])
 		if err != nil {
-			return nil, fmt.Errorf("invalid sunspec model: %s", selector)
-		}
-
-		var block int
-		if len(el) == 3 {
-			// block is the middle element
-			block, err = strconv.Atoi(el[1])
-			if err != nil {
-				return nil, fmt.Errorf("invalid sunspec block: %s", selector)
-			}
+			return res, fmt.Errorf("invalid sunspec block: %s", selector)
 		}
-
-		res = append(res, SunSpecOperation{
-			Model: model,
-			Block: block,
-			Point: el[len(el)-1],
-		})
 	}
 
+	res.Point = el[len(el)-1]
+
 	return res, nil
 }
diff --git a/vehicle/tesla-vehicle-command/identity.go b/vehicle/tesla-vehicle-command/identity.go
index c2d0073c..8878e262 100644
--- a/vehicle/tesla-vehicle-command/identity.go
+++ b/vehicle/tesla-vehicle-command/identity.go
@@ -76,15 +76,22 @@ func NewIdentity(log *util.Logger, token *oauth2.Token) (*Identity, error) {
 
 	// 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)
+		var tok oauth2.Token
+		if err := settings.Json(v.settingsKey(), &tok); err == nil {
+			token = &tok
 		}
+	}
 
-		if !token.Valid() {
-			return nil, errors.New("token expired")
+	if !token.Valid() && token.RefreshToken != "" {
+		if tok, err := v.RefreshToken(token); err == nil {
+			token = tok
 		}
 	}
 
+	if !token.Valid() {
+		return nil, errors.New("token expired")
+	}
+
 	// acct, err := account.New(token.AccessToken, userAgent)
 	// if err != nil {
 	// 	return nil, err


Test patch:
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 548920ab..53c9b8a8 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -527,10 +527,7 @@ func cacheExpecter(t *testing.T, lp *Loadpoint) (*util.Cache, func(key string, v
 		time.Sleep(100 * time.Millisecond) // wait for cache to catch up
 
 		p := cache.Get(key)
-		t.Logf("%s: %v", key, p.Val) // REMOVE
-		if p.Val != val {
-			t.Errorf("%s wanted: %v, got %v", key, val, p.Val)
-		}
+		assert.Equal(t, val, p.Val, key)
 	}
 	return cache, expect
 }
@@ -614,98 +611,6 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 	ctrl.Finish()
 }
 
-// func TestTargetSoc(t *testing.T) {
-// 	ctrl := gomock.NewController(t)
-// 	vhc := api.NewMockVehicle(ctrl)
-
-// 	// TODO make vehicle settings mockable
-// 	// make vehicle settings discoverable
-// 	config.Vehicles().Add(config.NewStaticDevice[api.Vehicle](config.Named{}, vhc))
-
-// 	tc := []struct {
-// 		vehicle    api.Vehicle
-// 		limitSoc   int
-// 		vehicleSoc float64
-// 		res        bool
-// 	}{
-// 		{nil, 0, 0, false},     // never reached without vehicle
-// 		{nil, 0, 10, false},    // never reached without vehicle
-// 		{nil, 80, 0, false},    // never reached without vehicle
-// 		{nil, 80, 80, false},   // never reached without vehicle
-// 		{nil, 80, 100, false},  // never reached without vehicle
-// 		{vhc, 0, 0, false},     // target disabled
-// 		{vhc, 0, 10, false},    // target disabled
-// 		{vhc, 80, 0, false},    // target not reached
-// 		{vhc, 80, 80, true},    // target reached
-// 		{vhc, 80, 100, true},   // target reached
-// 		{vhc, 100, 100, false}, // target reached, let ev control deactivation
-// 	}
-
-// 	for _, tc := range tc {
-// 		t.Logf("%+v", tc)
-
-// 		lp := &Loadpoint{
-// 			vehicle:    tc.vehicle,
-// 			limitSoc:   tc.limitSoc,
-// 			vehicleSoc: tc.vehicleSoc,
-// 		}
-
-// 		if res := lp.limitSocReached(); tc.res != res {
-// 			t.Errorf("expected %v, got %v", tc.res, res)
-// 		}
-// 	}
-// }
-
-// func TestMinSoc(t *testing.T) {
-// 	ctrl := gomock.NewController(t)
-// 	vhc := api.NewMockVehicle(ctrl)
-
-// 	// TODO make vehicle settings mockable
-// 	// make vehicle settings discoverable
-// 	config.Vehicles().Add(config.NewStaticDevice[api.Vehicle](config.Named{}, vhc))
-
-// 	tc := []struct {
-// 		vehicle *api.MockVehicle
-// 		min     int
-// 		soc     float64
-// 		energy  float64
-// 		res     bool
-// 	}{
-// 		{nil, 0, 0, 0, false},    // never reached without vehicle
-// 		{nil, 0, 10, 0, false},   // never reached without vehicle
-// 		{nil, 80, 0, 0, false},   // never reached without vehicle
-// 		{nil, 80, 80, 0, false},  // never reached without vehicle
-// 		{nil, 80, 100, 0, false}, // never reached without vehicle
-// 		{vhc, 0, 0, 0, false},    // min disabled
-// 		{vhc, 0, 10, 0, false},   // min disabled
-// 		{vhc, 80, 0, 0, true},    // min not reached
-// 		{vhc, 80, 10, 0, true},   // min not reached
-// 		{vhc, 80, 0, 8.0, true},  // min energy not reached
-// 		{vhc, 80, 0, 9.0, false}, // min energy reached
-// 		{vhc, 80, 80, 0, false},  // min reached
-// 		{vhc, 80, 100, 0, false}, // min reached
-// 	}
-
-// 	for _, tc := range tc {
-// 		lp := &Loadpoint{
-// 			log: util.NewLogger("foo"),
-// 			Soc: SocConfig{
-// 				min: tc.min,
-// 			},
-// 			vehicleSoc:    tc.soc,
-// 			sessionEnergy: NewEnergyMetrics(),
-// 		}
-// 		lp.sessionEnergy.Update(tc.energy / 1e3)
-
-// 		if v := tc.vehicle; v != nil {
-// 			lp.vehicle = tc.vehicle // avoid assigning nil to interface
-// 			v.EXPECT().Capacity().Return(10.0).MaxTimes(1)
-// 		}
-
-// 		assert.Equal(t, tc.res, lp.minSocNotReached(), tc)
-// 	}
-// }
-
 func TestSocPoll(t *testing.T) {
 	clock := clock.NewMock()
 	tRefresh := pollInterval
@@ -787,9 +692,7 @@ func TestSocPoll(t *testing.T) {
 			lp.socUpdated = clock.Now()
 		}
 
-		if tc.res != res {
-			t.Errorf("expected %v, got %v", tc.res, res)
-		}
+		assert.Equal(t, tc.res, res)
 	}
 }
 
@@ -917,3 +820,71 @@ func TestGuardPublishOnDisableAbort(t *testing.T) {
 		clock.Add(time.Minute)
 	}
 }
+
+// test PV hysteresis after phase switch down, depending on remaining energy
+func TestPVHysteresisAfterPhaseSwitch(t *testing.T) {
+	const dt = time.Minute
+	type se struct {
+		site    float64
+		delay   time.Duration // test case delay since start
+		current float64
+	}
+	tc := []struct {
+		series []se
+	}{
+		// immediately disable when threshold met after phase switch
+		{[]se{
+			{1200, 0, minA},
+			{1200, 1, minA},
+			{1200, dt - 1, minA},
+			{1200, dt + 1, 0},
+		}},
+		// stay enabled when threshold not met after phase switch
+		{[]se{
+			{500, 0, minA},
+			{500, 1, minA},
+			{500, dt - 1, minA},
+			{500, dt + 1, minA},
+		}},
+	}
+
+	for _, tc := range tc {
+		t.Log(tc)
+
+		clck := clock.NewMock()
+		ctrl := gomock.NewController(t)
+
+		switcher := api.NewMockPhaseSwitcher(ctrl)
+		switcher.EXPECT().Phases1p3p(1)
+
+		charger := struct {
+			*api.MockCharger
+			*api.MockPhaseSwitcher
+		}{
+			api.NewMockCharger(ctrl), switcher,
+		}
+
+		Voltage = 100
+		lp := &Loadpoint{
+			log:        util.NewLogger("foo"),
+			clock:      clck,
+			charger:    charger,
+			minCurrent: minA,
+			maxCurrent: maxA,
+			Disable: ThresholdConfig{
+				Delay: dt,
+			},
+			status:  api.StatusC,
+			enabled: true,
+		}
+
+		start := clck.Now()
+
+		for step, se := range tc.series {
+			clck.Set(start.Add(se.delay))
+			assert.Equal(t, se.current, lp.pvMaxCurrent(api.ModePV, se.site, false, false), step)
+		}
+
+		ctrl.Finish()
+	}
+}
diff --git a/tests/sessions.spec.js b/tests/sessions.spec.js
index 28e7b9dc..49eeff7d 100644
--- a/tests/sessions.spec.js
+++ b/tests/sessions.spec.js
@@ -234,18 +234,10 @@ test.describe("columns desktop", async () => {
     await page.goto("/#/sessions?year=2023&month=5");
     await expect(page.getByTestId("vehicle")).toBeVisible();
   });
-  test("hide vehicle column only one exists", async ({ page }) => {
-    await page.goto("/#/sessions?year=2023&month=3");
-    await expect(page.getByTestId("vehicle")).toHaveCount(0);
-  });
   test("show loadpoint column if multiple different exist", async ({ page }) => {
     await page.goto("/#/sessions?year=2023&month=5");
     await expect(page.getByTestId("loadpoint")).toBeVisible();
   });
-  test("hide loadpoint column only one exists", async ({ page }) => {
-    await page.goto("/#/sessions?year=2023&month=3");
-    await expect(page.getByTestId("loadpoint")).toHaveCount(0);
-  });
   test("show co2 column it has values", async ({ page }) => {
     await page.goto("/#/sessions?year=2023&month=3");
     await expect(page.getByTestId("sessions-head-co2")).toBeVisible();
diff --git a/util/modbus/modbus_test.go b/util/modbus/modbus_test.go
index bf87c71e..70a18082 100644
--- a/util/modbus/modbus_test.go
+++ b/util/modbus/modbus_test.go
@@ -9,11 +9,10 @@ import (
 func TestParsePoint(t *testing.T) {
 	tc := []struct {
 		in  string
-		ops []SunSpecOperation
+		ops SunSpecOperation
 	}{
-		{"103:W", []SunSpecOperation{{103, 0, "W"}}},
-		{"802:1:V", []SunSpecOperation{{802, 1, "V"}}},
-		{"101|103:DCW", []SunSpecOperation{{101, 0, "DCW"}, {103, 0, "DCW"}}},
+		{"103:W", SunSpecOperation{103, 0, "W"}},
+		{"802:1:V", SunSpecOperation{802, 1, "V"}},
 	}
 
 	for _, tc := range tc {