Repository: evcc-io/evcc
Pull request #11571 (2024-01-08 20:58:06)

Persist phases/min/max current (BC)
TODO

- [x] add error handling to setters (@naltatis how do we want to handle implausible values?)
- [x] decide if always to call setter in `restoreSettings`
- [x] UI: add phases setting for all loadpoints
- [x] UI: min-/maxcurrent without experimental flag 🧪
- [x] phasesConfigured default value? What to show if nothing is set? **Result: default to 3**
- [x] more testing @naltatis 
- [x] fix deadlock on API `POST /.../phases` endpoint @andig 

naltatis: Macht hier ne einmalige automatische Migration Sinn? YAML Wert in DB schreiben, sofern da noch kein Wert ist.

Das ist potentiell schon ne kritischere Änderung da bspw. ein konfiguriertes MaxCurrent außer Kraft gesetzt wird was negative Effekte haben kann.
andig: Schadet nix- ist drin. Analog wäre das natürlich auch für Fahrzeugeinstellungen schön- das geht aber noch nicht ohne weitere Schritte, oder?
andig: Ping @naltatis priorisiert fertig machen?
naltatis: Hab jetzt einige Anpassungen gemacht. `auto` aka `0` taucht nun nur auf, wenn `phaseSwitcher: true` am Loadpoint existiert.

Mir ist noch unklar ob wir einen default-Wert für Phases haben sollten? Wie verhält sich die Regellogik, wenn nichts gesetzt ist? Brauchen wir eine "keine Angabe" Wahloption in der UI??
andig: > Mir ist noch unklar ob wir einen default-Wert für Phases haben sollten? Wie verhält sich die Regellogik, wenn nichts gesetzt ist? Brauchen wir eine "keine Angabe" Wahloption in der UI??

Dann müssten wir von 3 ausgehen, also maximaler Leistungsaufnahme. Bin grad nicht sicher, ob wir das tun?

Brauchen wir noch eine Fehlerbehandlung für die Ströme, also min/max untereinander verproben?
naltatis: Ich habe noch ein bisschen was gefunden. Außerdem scheint im aktuellen Stand ein Deadlock aufzutreten, sobald man den Phases API Endpunkt aufruft. Das war beim letzten Stand, den ich getestet habe, noch nicht der Fall. @andig magst du mal draufschauen?

Sonst hab ich keine offenen Punkte mehr.

Patch:
diff --git a/Dockerfile b/Dockerfile
index da7ca903..6715c006 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -90,6 +90,8 @@ EXPOSE 7070/tcp
 EXPOSE 7090/udp
 # OCPP charger
 EXPOSE 8887/tcp
+# GoodWe Wifi Inverter
+EXPOSE 8899/udp
 # SMA Energy Manager
 EXPOSE 9522/udp
 
diff --git a/assets/index.html b/assets/index.html
index e65606ca..29fc2369 100644
--- a/assets/index.html
+++ b/assets/index.html
@@ -1,5 +1,5 @@
 <!doctype html>
-<html lang="en">
+<html lang="[[.DefaultLang]]">
   <head>
     <meta charset="utf-8" />
     <meta name="viewport" content="width=device-width, initial-scale=1" />
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index d6ad7d1f..5925dddb 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -12,19 +12,11 @@
 						{{ loadpointTitle }}
 					</div>
 				</h3>
-				<LoadpointSettingsButton
-					v-if="settingsButtonVisible"
-					:id="id"
-					class="d-block d-sm-none"
-				/>
+				<LoadpointSettingsButton :id="id" class="d-block d-sm-none" />
 			</div>
 			<div class="mb-3 d-flex align-items-center">
 				<Mode class="flex-grow-1" :mode="mode" @updated="setTargetMode" />
-				<LoadpointSettingsButton
-					v-if="settingsButtonVisible"
-					:id="id"
-					class="d-none d-sm-block ms-2"
-				/>
+				<LoadpointSettingsButton :id="id" class="d-none d-sm-block ms-2" />
 			</div>
 		</div>
 		<LoadpointSettingsModal
@@ -178,6 +170,7 @@ export default {
 		phases: Number,
 		phasesConfigured: Number,
 		phasesActive: Number,
+		phases1p3p: Boolean,
 		minCurrent: Number,
 		maxCurrent: Number,
 		chargeCurrent: Number,
@@ -232,9 +225,6 @@ export default {
 		settingsModal: function () {
 			return this.collectProps(LoadpointSettingsModal);
 		},
-		settingsButtonVisible: function () {
-			return this.$hiddenFeatures() || [0, 1, 3].includes(this.phasesConfigured);
-		},
 		vehicleProps: function () {
 			return this.collectProps(Vehicle);
 		},
diff --git a/assets/js/components/LoadpointSettingsButton.vue b/assets/js/components/LoadpointSettingsButton.vue
index 664429b2..0f762f64 100644
--- a/assets/js/components/LoadpointSettingsButton.vue
+++ b/assets/js/components/LoadpointSettingsButton.vue
@@ -2,6 +2,7 @@
 	<button
 		type="button"
 		class="btn btn-sm btn-outline-secondary position-relative border-0 p-2 evcc-gray"
+		data-testid="loadpoint-settings-button"
 		@click="openModal"
 	>
 		<shopicon-regular-adjust size="s"></shopicon-regular-adjust>
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 512cf407..5d633f7c 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -23,14 +23,10 @@
 					</div>
 					<div class="modal-body">
 						<div class="container">
-							<h4
-								v-if="showConfigurablePhases || showCurrentSettings"
-								class="d-flex align-items-center mb-3 mt-4 text-evcc"
-							>
+							<h4 class="d-flex align-items-center mb-3 mt-4 text-evcc">
 								{{ $t("main.loadpointSettings.currents") }}
-								<shopicon-bold-lightning class="ms-1"></shopicon-bold-lightning>
 							</h4>
-							<div v-if="showConfigurablePhases" class="mb-3 row">
+							<div class="mb-3 row">
 								<label
 									:for="formId('phases_0')"
 									class="col-sm-4 col-form-label pt-0"
@@ -38,71 +34,37 @@
 									{{ $t("main.loadpointSettings.phasesConfigured.label") }}
 								</label>
 								<div class="col-sm-8 pe-0">
-									<div class="form-check">
-										<input
-											:id="formId('phases_0')"
-											v-model.number="selectedPhases"
-											class="form-check-input"
-											type="radio"
-											:name="formId('phases')"
-											:value="0"
-											@change="changePhasesConfigured"
-										/>
-										<label class="form-check-label" :for="formId('phases_0')">
-											{{
-												$t(
-													"main.loadpointSettings.phasesConfigured.phases_0"
-												)
-											}}
-										</label>
-									</div>
-									<div class="form-check">
-										<input
-											:id="formId('phases_1')"
-											v-model.number="selectedPhases"
-											class="form-check-input"
-											type="radio"
-											:name="formId('phases')"
-											:value="1"
-											@change="changePhasesConfigured"
-										/>
-										<label class="form-check-label" :for="formId('phases_1')">
-											{{
-												$t(
-													"main.loadpointSettings.phasesConfigured.phases_1"
-												)
-											}}
-											<small>
-												{{
-													$t(
-														"main.loadpointSettings.phasesConfigured.phases_1_hint",
-														{ min: minPower1p, max: maxPower1p }
-													)
-												}}
-											</small>
-										</label>
-									</div>
-									<div class="form-check">
+									<div
+										v-for="phases in phasesOptions"
+										:key="phases"
+										class="form-check"
+									>
 										<input
-											:id="formId('phases_3')"
+											:id="formId(`phases_${phases}`)"
 											v-model.number="selectedPhases"
 											class="form-check-input"
 											type="radio"
 											:name="formId('phases')"
-											:value="3"
+											:value="phases"
 											@change="changePhasesConfigured"
 										/>
-										<label class="form-check-label" :for="formId('phases_3')">
+										<label
+											class="form-check-label"
+											:for="formId(`phases_${phases}`)"
+										>
 											{{
 												$t(
-													"main.loadpointSettings.phasesConfigured.phases_3"
+													`main.loadpointSettings.phasesConfigured.phases_${phases}`
 												)
 											}}
-											<small>
+											<small v-if="phases > 0">
 												{{
 													$t(
-														"main.loadpointSettings.phasesConfigured.phases_3_hint",
-														{ min: minPower3p, max: maxPower3p }
+														`main.loadpointSettings.phasesConfigured.phases_${phases}_hint`,
+														{
+															min: minPowerPhases(phases),
+															max: maxPowerPhases(phases),
+														}
 													)
 												}}
 											</small>
@@ -111,7 +73,7 @@
 								</div>
 							</div>
 
-							<div v-if="$hiddenFeatures()" class="mb-3 row">
+							<div class="mb-3 row">
 								<label
 									:for="formId('maxcurrent')"
 									class="col-sm-4 col-form-label pt-0 pt-sm-2"
@@ -137,7 +99,7 @@
 								</div>
 							</div>
 
-							<div v-if="$hiddenFeatures()" class="mb-3 row">
+							<div class="mb-3 row">
 								<label
 									:for="formId('mincurrent')"
 									class="col-sm-4 col-form-label pt-0 pt-sm-2"
@@ -163,12 +125,6 @@
 								</div>
 							</div>
 						</div>
-						<p class="small mt-3 text-muted mb-0">
-							<strong class="text-evcc">
-								{{ $t("main.loadpointSettings.disclaimerHint") }}
-							</strong>
-							{{ $t("main.loadpointSettings.disclaimerText") }}
-						</p>
 					</div>
 				</div>
 			</div>
@@ -177,8 +133,6 @@
 </template>
 
 <script>
-import "@h2d2/shopicons/es/bold/lightning";
-import "@h2d2/shopicons/es/bold/car3";
 import formatter from "../mixins/formatter";
 
 const V = 230;
@@ -203,6 +157,7 @@ export default {
 		id: [String, Number],
 		phasesConfigured: Number,
 		phasesActive: Number,
+		phases1p3p: Boolean,
 		minSoc: Number,
 		maxCurrent: Number,
 		minCurrent: Number,
@@ -217,47 +172,29 @@ export default {
 		};
 	},
 	computed: {
-		maxPower1p: function () {
-			return this.fmtKw(this.maxCurrent * V);
-		},
-		minPower1p: function () {
-			return this.fmtKw(this.minCurrent * V);
-		},
-		maxPower3p: function () {
-			return this.fmtKw(this.maxCurrent * V * 3);
-		},
-		minPower3p: function () {
-			return this.fmtKw(this.minCurrent * V * 3);
+		phasesOptions: function () {
+			if (this.phases1p3p) {
+				return [PHASES_AUTO, PHASES_3, PHASES_1];
+			}
+			return [PHASES_3, PHASES_1];
 		},
 		maxPower: function () {
-			switch (this.phasesConfigured) {
-				case PHASES_AUTO:
-					return this.maxPower3p;
-				case PHASES_3:
-					return this.maxPower3p;
-				case PHASES_1:
-					return this.maxPower1p;
-				default:
-					return this.fmtKw(this.maxCurrent * V * this.phasesActive);
+			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 () {
-			switch (this.phasesConfigured) {
-				case PHASES_AUTO:
-					return this.minPower1p;
-				case PHASES_3:
-					return this.minPower3p;
-				case PHASES_1:
-					return this.minPower1p;
-				default:
-					return this.fmtKw(this.minCurrent * V * this.phasesActive);
+			if (this.phasesConfigured === PHASES_AUTO) {
+				return this.minPowerPhases(1);
 			}
-		},
-		showConfigurablePhases: function () {
-			return [PHASES_AUTO, PHASES_3, PHASES_1].includes(this.phasesConfigured);
-		},
-		showCurrentSettings: function () {
-			return this.$hiddenFeatures();
+			if ([PHASES_3, PHASES_1].includes(this.phasesConfigured)) {
+				return this.minPowerPhases(this.phasesConfigured);
+			}
+			return this.fmtKw(this.minCurrent * V * this.phasesActive);
 		},
 		minCurrentOptions: function () {
 			const opt1 = [...range(Math.floor(this.maxCurrent), 1), 0.5, 0.25, 0.125];
@@ -287,6 +224,12 @@ export default {
 		},
 	},
 	methods: {
+		maxPowerPhases: function (phases) {
+			return this.fmtKw(this.maxCurrent * V * phases);
+		},
+		minPowerPhases: function (phases) {
+			return this.fmtKw(this.minCurrent * V * phases);
+		},
 		formId: function (name) {
 			return `loadpoint_${this.id}_${name}`;
 		},
diff --git a/assets/js/i18n.js b/assets/js/i18n.js
index 481a4573..2b89ed93 100644
--- a/assets/js/i18n.js
+++ b/assets/js/i18n.js
@@ -35,15 +35,7 @@ export const LOCALES = {
 };
 
 export const DEFAULT_LOCALE = "en";
-
-function getBrowserLocale() {
-  const navigatorLocale =
-    navigator.languages !== undefined ? navigator.languages[0] : navigator.language;
-  if (!navigatorLocale) {
-    return undefined;
-  }
-  return navigatorLocale.trim().split(/-|_/)[0];
-}
+const DEFAULT_BROWSER_LOCALE = document.querySelector("html").getAttribute("lang");
 
 export function getLocalePreference() {
   return settings.locale;
@@ -51,7 +43,7 @@ export function getLocalePreference() {
 
 export function removeLocalePreference(i18n) {
   settings.locale = null;
-  setI18nLanguage(i18n, getBrowserLocale());
+  setI18nLanguage(i18n, DEFAULT_BROWSER_LOCALE);
   ensureCurrentLocaleMessages(i18n);
 }
 
@@ -66,7 +58,7 @@ export function setLocalePreference(i18n, locale) {
 }
 
 function getLocale() {
-  return getLocalePreference() || getBrowserLocale();
+  return getLocalePreference() || DEFAULT_BROWSER_LOCALE;
 }
 
 export default function setupI18n() {
diff --git a/cmd/demo.yaml b/cmd/demo.yaml
index e87045f5..bac2efb8 100644
--- a/cmd/demo.yaml
+++ b/cmd/demo.yaml
@@ -236,7 +236,6 @@ loadpoints:
   - title: Carport
     charger: charger_1
     mode: pv
-    phases: 1
     meter: meter_charger_1
     vehicle: vehicle_1
   - title: Garage
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 47bc5fac..2fb543d1 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -14,6 +14,7 @@ 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)
 
diff --git a/core/loadpoint.go b/core/loadpoint.go
index abdd7ee4..ee70d4b1 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -108,21 +108,25 @@ type Loadpoint struct {
 	vmu   sync.RWMutex   // guard vehicle
 	Mode_ api.ChargeMode `mapstructure:"mode"` // Default charge mode, used for disconnect
 
-	Title_           string `mapstructure:"title"`    // UI title
-	Priority_        int    `mapstructure:"priority"` // Priority
-	ConfiguredPhases int    `mapstructure:"phases"`   // Charger configured phase mode 0/1/3
-	ChargerRef       string `mapstructure:"charger"`  // Charger reference
-	VehicleRef       string `mapstructure:"vehicle"`  // Vehicle reference
-	MeterRef         string `mapstructure:"meter"`    // Charge meter reference
-	Soc              SocConfig
-	Enable, Disable  ThresholdConfig
-
-	MinCurrent    float64       // PV mode: start current	Min+PV mode: min current
-	MaxCurrent    float64       // Max allowed current. Physically ensured by the charger
-	GuardDuration time.Duration // charger enable/disable minimum holding time
-
-	limitSoc    int     // Session limit for soc
-	limitEnergy float64 // Session limit for energy
+	Title_          string `mapstructure:"title"`    // UI title
+	Priority_       int    `mapstructure:"priority"` // Priority
+	ChargerRef      string `mapstructure:"charger"`  // Charger reference
+	VehicleRef      string `mapstructure:"vehicle"`  // Vehicle reference
+	MeterRef        string `mapstructure:"meter"`    // Charge meter reference
+	Soc             SocConfig
+	Enable, Disable ThresholdConfig
+	GuardDuration   time.Duration // charger enable/disable minimum holding time
+
+	// TODO deprecated
+	ConfiguredPhases_ int     `mapstructure:"phases"`
+	MinCurrent_       float64 `mapstructure:"minCurrent"`
+	MaxCurrent_       float64 `mapstructure:"maxCurrent"`
+
+	minCurrent       float64 // PV mode: start current	Min+PV mode: min current
+	maxCurrent       float64 // Max allowed current. Physically ensured by the charger
+	configuredPhases int     // Charger configured phase mode 0/1/3
+	limitSoc         int     // Session limit for soc
+	limitEnergy      float64 // Session limit for energy
 
 	mode                api.ChargeMode
 	enabled             bool      // Charger enabled state
@@ -200,14 +204,6 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 		lp.Soc.Poll.Mode = pollCharging
 	}
 
-	if lp.MinCurrent == 0 {
-		lp.log.WARN.Println("minCurrent must not be zero")
-	}
-
-	if lp.MaxCurrent < lp.MinCurrent {
-		lp.log.WARN.Println("maxCurrent must be larger than minCurrent")
-	}
-
 	if lp.MeterRef != "" {
 		dev, err := config.Meters().ByName(lp.MeterRef)
 		if err != nil {
@@ -235,17 +231,29 @@ func NewLoadpointFromConfig(log *util.Logger, settings *Settings, other map[stri
 	lp.charger = dev.Instance()
 	lp.configureChargerType(lp.charger)
 
-	// setup fixed phases:
-	// - simple charger starts with phases config if specified or 3p
-	// - switchable charger starts at 0p since we don't know the current setting
-	if _, ok := lp.charger.(api.PhaseSwitcher); !ok {
-		if lp.ConfiguredPhases == 0 {
-			lp.ConfiguredPhases = 3
-			lp.log.WARN.Println("phases not configured, assuming 3p")
+	// phase switching defaults based on charger capabilities
+	if !lp.hasPhaseSwitching() {
+		lp.configuredPhases = 3
+	}
+
+	// TODO deprecated
+	if lp.MinCurrent_ > 0 {
+		lp.log.WARN.Println("deprecated: minCurrent setting is ignored, please remove")
+		if _, err := lp.settings.Float(keys.MinCurrent); err != nil {
+			lp.settings.SetFloat(keys.MinCurrent, lp.MinCurrent_)
+		}
+	}
+	if lp.MaxCurrent_ > 0 {
+		lp.log.WARN.Println("deprecated: maxcurrent setting is ignored, please remove")
+		if _, err := lp.settings.Float(keys.MaxCurrent); err != nil {
+			lp.settings.SetFloat(keys.MaxCurrent, lp.MaxCurrent_)
+		}
+	}
+	if lp.ConfiguredPhases_ > 0 {
+		lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
+		if _, err := lp.settings.Int(keys.PhasesConfigured); err != nil {
+			lp.settings.SetInt(keys.PhasesConfigured, int64(lp.ConfiguredPhases_))
 		}
-		lp.phases = lp.ConfiguredPhases
-	} else if lp.ConfiguredPhases != 0 {
-		lp.log.WARN.Printf("locking phase config to %dp for switchable charger", lp.ConfiguredPhases)
 	}
 
 	// validate thresholds
@@ -275,8 +283,8 @@ func NewLoadpoint(log *util.Logger, settings *Settings) *Loadpoint {
 		bus:        bus,      // event bus
 		mode:       api.ModeOff,
 		status:     api.StatusNone,
-		MinCurrent: 6,  // A
-		MaxCurrent: 16, // A
+		minCurrent: 6,  // A
+		maxCurrent: 16, // A
 		Soc: SocConfig{
 			Poll: PollConfig{
 				Interval: pollInterval,
@@ -300,6 +308,15 @@ func (lp *Loadpoint) restoreSettings() {
 	if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
 		lp.setMode(api.ChargeMode(v))
 	}
+	if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
+		lp.setConfiguredPhases(int(v))
+	}
+	if v, err := lp.settings.Float(keys.MinCurrent); err == nil && v > 0 {
+		lp.setMinCurrent(v)
+	}
+	if v, err := lp.settings.Float(keys.MaxCurrent); err == nil && v > 0 {
+		lp.setMaxCurrent(v)
+	}
 	if v, err := lp.settings.Int(keys.LimitSoc); err == nil && v > 0 {
 		lp.setLimitSoc(int(v))
 	}
@@ -571,7 +588,8 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish(keys.EnableThreshold, lp.Enable.Threshold)
 	lp.publish(keys.DisableThreshold, lp.Disable.Threshold)
 
-	lp.setConfiguredPhases(lp.ConfiguredPhases)
+	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
+	lp.publish(keys.Phases1p3p, lp.hasPhaseSwitching())
 	lp.publish(keys.PhasesEnabled, lp.phases)
 	lp.publish(keys.PhasesActive, lp.ActivePhases())
 	lp.publishTimer(phaseTimer, 0, timerInactive)
@@ -970,17 +988,16 @@ func (lp *Loadpoint) resetPhaseTimer() {
 
 // scalePhasesRequired validates if fixed phase configuration matches enabled phases
 func (lp *Loadpoint) scalePhasesRequired() bool {
-	_, ok := lp.charger.(api.PhaseSwitcher)
-	return ok && lp.ConfiguredPhases != 0 && lp.ConfiguredPhases != lp.GetPhases()
+	return lp.hasPhaseSwitching() && lp.configuredPhases != 0 && lp.configuredPhases != lp.GetPhases()
 }
 
 // scalePhasesIfAvailable scales if api.PhaseSwitcher is available
 func (lp *Loadpoint) scalePhasesIfAvailable(phases int) error {
-	if lp.ConfiguredPhases != 0 {
-		phases = lp.ConfiguredPhases
+	if lp.configuredPhases != 0 {
+		phases = lp.configuredPhases
 	}
 
-	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
+	if lp.hasPhaseSwitching() {
 		return lp.scalePhases(phases)
 	}
 
@@ -1041,7 +1058,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) bo
 	var waiting bool
 	activePhases := lp.ActivePhases()
 	availablePower := lp.chargePower - sitePower
-	scalable := (sitePower > 0 || !lp.enabled) && activePhases > 1 && lp.ConfiguredPhases < 3
+	scalable := (sitePower > 0 || !lp.enabled) && activePhases > 1 && lp.configuredPhases < 3
 
 	// scale down phases
 	if targetCurrent := powerToCurrent(availablePower, activePhases); targetCurrent < minCurrent && scalable {
@@ -1137,7 +1154,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower float64, batter
 	maxCurrent := lp.effectiveMaxCurrent()
 
 	// switch phases up/down
-	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
+	if lp.hasPhaseSwitching() {
 		_ = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
 	}
 
@@ -1294,7 +1311,7 @@ func (lp *Loadpoint) updateChargeCurrents() {
 
 // updateChargeVoltages uses PhaseVoltages interface to count phases with nominal grid voltage
 func (lp *Loadpoint) updateChargeVoltages() {
-	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
+	if lp.hasPhaseSwitching() {
 		return // we don't need the voltages
 	}
 
@@ -1563,7 +1580,7 @@ func (lp *Loadpoint) Update(sitePower float64, autoCharge, batteryBuffered, batt
 		err = lp.setLimit(0, false)
 
 	case lp.scalePhasesRequired():
-		err = lp.scalePhases(lp.ConfiguredPhases)
+		err = lp.scalePhases(lp.configuredPhases)
 
 	case lp.remoteControlled(loadpoint.RemoteHardDisable):
 		remoteDisabled = loadpoint.RemoteHardDisable
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 9f245a06..19c967df 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -35,11 +35,11 @@ type API interface {
 	// GetMinCurrent returns the min charging current
 	GetMinCurrent() float64
 	// SetMinCurrent sets the min charging current
-	SetMinCurrent(float64)
+	SetMinCurrent(float64) error
 	// GetMaxCurrent returns the max charging current
 	GetMaxCurrent() float64
 	// SetMaxCurrent sets the max charging current
-	SetMaxCurrent(float64)
+	SetMaxCurrent(float64) error
 
 	// GetMode returns the charge mode
 	GetMode() api.ChargeMode
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 3d69145f..58ef9b27 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -480,9 +480,11 @@ func (mr *MockAPIMockRecorder) SetLimitSoc(arg0 any) *gomock.Call {
 }
 
 // SetMaxCurrent mocks base method.
-func (m *MockAPI) SetMaxCurrent(arg0 float64) {
+func (m *MockAPI) SetMaxCurrent(arg0 float64) error {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "SetMaxCurrent", arg0)
+	ret := m.ctrl.Call(m, "SetMaxCurrent", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
 }
 
 // SetMaxCurrent indicates an expected call of SetMaxCurrent.
@@ -492,9 +494,11 @@ func (mr *MockAPIMockRecorder) SetMaxCurrent(arg0 any) *gomock.Call {
 }
 
 // SetMinCurrent mocks base method.
-func (m *MockAPI) SetMinCurrent(arg0 float64) {
+func (m *MockAPI) SetMinCurrent(arg0 float64) error {
 	m.ctrl.T.Helper()
-	m.ctrl.Call(m, "SetMinCurrent", arg0)
+	ret := m.ctrl.Call(m, "SetMinCurrent", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
 }
 
 // SetMinCurrent indicates an expected call of SetMinCurrent.
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 8b88ba74..4606eac5 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -108,7 +108,7 @@ func (lp *Loadpoint) GetPhases() int {
 // SetPhases sets loadpoint enabled phases
 func (lp *Loadpoint) SetPhases(phases int) error {
 	// limit auto mode (phases=0) to scalable charger
-	if _, ok := lp.charger.(api.PhaseSwitcher); !ok && phases == 0 {
+	if !lp.hasPhaseSwitching() && phases == 0 {
 		return fmt.Errorf("invalid number of phases: %d", phases)
 	}
 
@@ -118,10 +118,13 @@ func (lp *Loadpoint) SetPhases(phases int) error {
 
 	// set new default
 	lp.log.DEBUG.Println("set phases:", phases)
+
+	lp.Lock()
 	lp.setConfiguredPhases(phases)
+	lp.Unlock()
 
 	// apply immediately if not 1p3p
-	if _, ok := lp.charger.(api.PhaseSwitcher); !ok {
+	if !lp.hasPhaseSwitching() {
 		lp.setPhases(phases)
 	}
 
@@ -324,40 +327,62 @@ func (lp *Loadpoint) GetChargePowerFlexibility() float64 {
 func (lp *Loadpoint) GetMinCurrent() float64 {
 	lp.RLock()
 	defer lp.RUnlock()
-	return lp.MinCurrent
+	return lp.minCurrent
+}
+
+// setMinCurrent sets the min loadpoint current (no mutex)
+func (lp *Loadpoint) setMinCurrent(current float64) {
+	lp.minCurrent = current
+	lp.publish(keys.MinCurrent, lp.minCurrent)
+	lp.settings.SetFloat(keys.MinCurrent, lp.minCurrent)
 }
 
 // SetMinCurrent sets the min loadpoint current
-func (lp *Loadpoint) SetMinCurrent(current float64) {
+func (lp *Loadpoint) SetMinCurrent(current float64) error {
 	lp.Lock()
 	defer lp.Unlock()
 
-	lp.log.DEBUG.Println("set min current:", current)
+	if current > lp.maxCurrent {
+		return errors.New("min current must be smaller or equal than max current")
+	}
 
-	if current != lp.MinCurrent {
-		lp.MinCurrent = current
-		lp.publish(keys.MinCurrent, lp.MinCurrent)
+	lp.log.DEBUG.Println("set min current:", current)
+	if current != lp.minCurrent {
+		lp.setMinCurrent(current)
 	}
+
+	return nil
 }
 
 // GetMaxCurrent returns the max loadpoint current
 func (lp *Loadpoint) GetMaxCurrent() float64 {
 	lp.RLock()
 	defer lp.RUnlock()
-	return lp.MaxCurrent
+	return lp.maxCurrent
+}
+
+// setMaxCurrent sets the max loadpoint current
+func (lp *Loadpoint) setMaxCurrent(current float64) {
+	lp.maxCurrent = current
+	lp.publish(keys.MaxCurrent, lp.maxCurrent)
+	lp.settings.SetFloat(keys.MaxCurrent, lp.maxCurrent)
 }
 
 // SetMaxCurrent sets the max loadpoint current
-func (lp *Loadpoint) SetMaxCurrent(current float64) {
+func (lp *Loadpoint) SetMaxCurrent(current float64) error {
 	lp.Lock()
 	defer lp.Unlock()
 
-	lp.log.DEBUG.Println("set max current:", current)
+	if current < lp.minCurrent {
+		return errors.New("max current must be greater or equal than min current")
+	}
 
-	if current != lp.MaxCurrent {
-		lp.MaxCurrent = current
-		lp.publish(keys.MaxCurrent, lp.MaxCurrent)
+	lp.log.DEBUG.Println("set max current:", current)
+	if current != lp.maxCurrent {
+		lp.setMaxCurrent(current)
 	}
+
+	return nil
 }
 
 // GetMinPower returns the min loadpoint power for a single phase
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index e82b8801..b2b96d69 100644
--- a/core/loadpoint_phases.go
+++ b/core/loadpoint_phases.go
@@ -7,17 +7,9 @@ import (
 
 // setConfiguredPhases sets the default phase configuration
 func (lp *Loadpoint) setConfiguredPhases(phases int) {
-	lp.Lock()
-	defer lp.Unlock()
-
-	lp.ConfiguredPhases = phases
-
-	// publish 1p3p capability and phase configuration
-	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
-		lp.publish(keys.PhasesConfigured, lp.ConfiguredPhases)
-	} else {
-		lp.publish(keys.PhasesConfigured, nil)
-	}
+	lp.configuredPhases = phases
+	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
+	lp.settings.SetInt(keys.PhasesConfigured, int64(lp.configuredPhases))
 }
 
 // setPhases sets the number of enabled phases without modifying the charger
@@ -93,8 +85,8 @@ func (lp *Loadpoint) maxActivePhases() int {
 	}
 
 	// if 1p3p supported then assume configured limit or 3p
-	if _, ok := lp.charger.(api.PhaseSwitcher); ok {
-		physical = lp.ConfiguredPhases
+	if lp.hasPhaseSwitching() {
+		physical = lp.configuredPhases
 	}
 
 	return min(expect(vehicle), expect(physical), expect(measured))
@@ -107,3 +99,8 @@ func (lp *Loadpoint) getVehiclePhases() int {
 
 	return 0
 }
+
+func (lp *Loadpoint) hasPhaseSwitching() bool {
+	_, ok := lp.charger.(api.PhaseSwitcher)
+	return ok
+}
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 3a056114..0cbbc90b 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -95,6 +95,9 @@ func (lp *Loadpoint) plannerActive() (active bool) {
 	}()
 
 	planTime := lp.EffectivePlanTime()
+	if planTime.IsZero() {
+		return false
+	}
 	if lp.clock.Until(planTime) < 0 && !lp.planActive {
 		lp.deletePlan()
 		return false
diff --git a/evcc.dist.yaml b/evcc.dist.yaml
index 8d80dcbe..9f091d31 100644
--- a/evcc.dist.yaml
+++ b/evcc.dist.yaml
@@ -116,9 +116,6 @@ loadpoints:
     charger: wallbe # charger
     meter: charge # charge meter
     mode: "off" # default charge mode to apply when vehicle is disconnected; use "off" to disable by default if charger is publicly available
-    phases: 3 # electrical connection (normal charger: default 3 for 3 phase, 1p3p charger: 0 for "auto" or 1/3 for fixed phases)
-    minCurrent: 6 # minimum charge current (default 6A)
-    maxCurrent: 16 # maximum charge current (default 16A)
 
     # remaining settings are experts-only and best left at default values
     priority: 0 # relative priority for concurrent charging in PV mode with multiple loadpoints (higher values have higher priority)
diff --git a/go.mod b/go.mod
index a57bfa95..0e8dcf78 100644
--- a/go.mod
+++ b/go.mod
@@ -54,7 +54,7 @@ require (
 	github.com/jinzhu/now v1.1.5
 	github.com/joeshaw/carwings v0.0.0-20230425210048-195f7e10e966
 	github.com/joho/godotenv v1.5.1
-	github.com/jpfielding/go-http-digest v0.0.0-20211006141426-fbc93758452e
+	github.com/jpfielding/go-http-digest v0.0.0-20240123121450-cffc47d5d6d8
 	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
 	github.com/koron/go-ssdp v0.0.4
 	github.com/korylprince/ipnetgen v1.0.1
diff --git a/go.sum b/go.sum
index 01149f50..44723c20 100644
--- a/go.sum
+++ b/go.sum
@@ -371,8 +371,8 @@ github.com/joeshaw/carwings v0.0.0-20230425210048-195f7e10e966/go.mod h1:tB0Olpi
 github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
 github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
 github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
-github.com/jpfielding/go-http-digest v0.0.0-20211006141426-fbc93758452e h1:iLmt7czLE9ophoU4xZcckVNCXp+auTvVSQ9MGTWbgBY=
-github.com/jpfielding/go-http-digest v0.0.0-20211006141426-fbc93758452e/go.mod h1:oLt6zF2euTyCg2Cxz01B3VC5SJcPpKpYnOAqvO+480s=
+github.com/jpfielding/go-http-digest v0.0.0-20240123121450-cffc47d5d6d8 h1:9gc1rAm+gorqjxc8gBxX8pc+48S3hzzL/aO705Yul8E=
+github.com/jpfielding/go-http-digest v0.0.0-20240123121450-cffc47d5d6d8/go.mod h1:oLt6zF2euTyCg2Cxz01B3VC5SJcPpKpYnOAqvO+480s=
 github.com/jpfielding/gowirelog v0.0.0-20200123170752-df8f8dccb721/go.mod h1:R8b6Hefiy6MSxbJ8dbF9Gkaa/LwFh9meFV0i9hkR5i8=
 github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA=
 github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4=
diff --git a/i18n/ca.toml b/i18n/ca.toml
index a4a22980..290b558f 100644
--- a/i18n/ca.toml
+++ b/i18n/ca.toml
@@ -154,8 +154,6 @@ solar = "Solar"
 [main.loadpointSettings]
 currents = "Corrent de càrrega"
 default = "defecte"
-disclaimerHint = "Nota:"
-disclaimerText = "Els canvis encara no són persistents i es restableixen al iniciar el servei."
 title = "Configuracions {0}"
 vehicle = "Vehicle"
 
diff --git a/i18n/cs.toml b/i18n/cs.toml
index 3408a482..b2509d7c 100644
--- a/i18n/cs.toml
+++ b/i18n/cs.toml
@@ -154,8 +154,6 @@ solar = "Slunce"
 [main.loadpointSettings]
 currents = "Nabíjecí proud"
 default = "výchozí"
-disclaimerHint = "Poznámka:"
-disclaimerText = "Po restartovaní serveru budou zmeny ztraceny"
 title = "Nastavení {0}"
 vehicle = "Vozidlo"
 
diff --git a/i18n/da.toml b/i18n/da.toml
index 2ae35a4f..99004f01 100644
--- a/i18n/da.toml
+++ b/i18n/da.toml
@@ -211,8 +211,6 @@ solar = "solenergi"
 [main.loadpointSettings]
 currents = "Ladestrøm"
 default = "standard"
-disclaimerHint = "Bemærk:"
-disclaimerText = "Ændringer vil gå tabt, efter at serveren er genstartet."
 onlyForSocBasedCharging = "Disse indstillinger er kun tilgængelig for køretøjer med kendt opladningsniveau"
 title = "Indstillinger {0}"
 vehicle = "Køretøj"
diff --git a/i18n/de.toml b/i18n/de.toml
index 8fbc6e5d..1f4d26eb 100644
--- a/i18n/de.toml
+++ b/i18n/de.toml
@@ -213,8 +213,6 @@ solar = "Sonne"
 [main.loadpointSettings]
 currents = "Ladestrom"
 default = "default"
-disclaimerHint = "Hinweis:"
-disclaimerText = "Änderungen gehen nach einem Neustart des Servers verloren."
 onlyForSocBasedCharging = "Diese Optionen sind nur für Fahrzeuge mit bekanntem Ladestand verfügbar."
 title = "Einstellungen {0}"
 vehicle = "Fahrzeug"
diff --git a/i18n/en.toml b/i18n/en.toml
index 59d233d0..b1a25fcb 100644
--- a/i18n/en.toml
+++ b/i18n/en.toml
@@ -210,8 +210,6 @@ solar = "Solar"
 [main.loadpointSettings]
 currents = "Charging current"
 default = "default"
-disclaimerHint = "Note:"
-disclaimerText = "Changes will be lost after the server is restarted."
 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 fe63635d..5b9c8ad2 100644
--- a/i18n/es.toml
+++ b/i18n/es.toml
@@ -222,8 +222,6 @@ solar = "Solar"
 [main.loadpointSettings]
 currents = "Corriente de carga"
 default = "defecto"
-disclaimerHint = "Nota:"
-disclaimerText = "Actualmente, los cambios aún no son persistentes y se restablecen al iniciar el servicio."
 onlyForSocBasedCharging = "Estas opciones sólo están disponibles para vehículos con nivel de carga conocido"
 title = "Configuraciones {0}"
 vehicle = "Vehículo"
diff --git a/i18n/fi.toml b/i18n/fi.toml
index 1c44bf27..1747d414 100644
--- a/i18n/fi.toml
+++ b/i18n/fi.toml
@@ -199,8 +199,6 @@ solar = "Aurinkoenergia"
 [main.loadpointSettings]
 currents = "Latausvirta"
 default = "oletus"
-disclaimerHint = "Huomautus:"
-disclaimerText = "Muutokset eivät ole voimassa serverin uudelleen käynnistyksen jälkeen."
 title = "Asetukset {0}"
 vehicle = "Ajoneuvo"
 
diff --git a/i18n/fr.toml b/i18n/fr.toml
index d5edb2c5..7c7d6873 100644
--- a/i18n/fr.toml
+++ b/i18n/fr.toml
@@ -216,8 +216,6 @@ solar = "Solaire"
 [main.loadpointSettings]
 currents = "Courant de charge"
 default = "défaut"
-disclaimerHint = "Remarque:"
-disclaimerText = "Les modifications seront perdues après le redémarrage du serveur."
 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 ae5eaf8f..e1f04c72 100644
--- a/i18n/hr.toml
+++ b/i18n/hr.toml
@@ -218,8 +218,6 @@ solar = "Solarno"
 [main.loadpointSettings]
 currents = "Struja napajanja"
 default = "zadano"
-disclaimerHint = "Bilješka:"
-disclaimerText = "Promjene će se izgubiti nakon ponovnog pokretanja poslužitelja."
 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 2ff43bb4..1857abb4 100644
--- a/i18n/it.toml
+++ b/i18n/it.toml
@@ -101,8 +101,6 @@ remoteDisabledSoft = "{source}: Ricarica FV adattiva disabilitata"
 [main.loadpointSettings]
 currents = "Corrente di carica"
 default = "default"
-disclaimerHint = "Nota:"
-disclaimerText = "Le modifiche andranno perse dopo il riavvio del server."
 title = "Impostazioni {0}"
 vehicle = "Veicolo"
 
diff --git a/i18n/lb.toml b/i18n/lb.toml
index df103430..0a1a7667 100644
--- a/i18n/lb.toml
+++ b/i18n/lb.toml
@@ -94,8 +94,6 @@ power = "Leeschtung"
 [main.loadpointSettings]
 currents = "Ladestroum"
 default = "Standard"
-disclaimerHint = "Notiz:"
-disclaimerText = "Ännerunge ginn verluer nodeems de Server nei gestart ass."
 title = "Astellungen {0}"
 vehicle = "Gefier"
 
diff --git a/i18n/lt.toml b/i18n/lt.toml
index 70056010..3231f0ee 100644
--- a/i18n/lt.toml
+++ b/i18n/lt.toml
@@ -216,8 +216,6 @@ solar = "Saulės"
 [main.loadpointSettings]
 currents = "Įkrovimo srovė"
 default = "standartiškai"
-disclaimerHint = "Pastaba:"
-disclaimerText = "Šie pakeitimai neišlieka po evcc serverio restarto."
 onlyForSocBasedCharging = "Šie pasirinkimai galimi tik automobiliams su žinomu įkrovos lygiu."
 title = "Nustatymai {0}"
 vehicle = "Automobilis"
diff --git a/i18n/nl.toml b/i18n/nl.toml
index d7ea44a1..baecd794 100644
--- a/i18n/nl.toml
+++ b/i18n/nl.toml
@@ -195,8 +195,6 @@ solar = "Zon"
 [main.loadpointSettings]
 currents = "Laadstroom"
 default = "standaard"
-disclaimerHint = "Let op:"
-disclaimerText = "Wijzigingen gaan verloren als de server herstart."
 title = "Instellingen {0}"
 vehicle = "Voertuig"
 
diff --git a/i18n/no.toml b/i18n/no.toml
index f01c3840..43296d46 100644
--- a/i18n/no.toml
+++ b/i18n/no.toml
@@ -192,8 +192,6 @@ solar = "Solenergi"
 [main.loadpointSettings]
 currents = "Ladestrøm"
 default = "forvalg"
-disclaimerHint = "Merknad:"
-disclaimerText = "Endringer vil gå tapt ved omstart av serveren"
 title = "Innstillinger for {0}"
 vehicle = "Kjøretøy"
 
diff --git a/i18n/pl.toml b/i18n/pl.toml
index 1e7d9d3c..196c666a 100644
--- a/i18n/pl.toml
+++ b/i18n/pl.toml
@@ -217,8 +217,6 @@ solar = "Słoneczna"
 [main.loadpointSettings]
 currents = "Prąd ładowania"
 default = "domyślny"
-disclaimerHint = "Uwaga:"
-disclaimerText = "Zmiany zostaną zresetowane po restarcie serwera."
 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 e54a2c31..5c081fec 100644
--- a/i18n/pt.toml
+++ b/i18n/pt.toml
@@ -216,8 +216,6 @@ solar = "Sol"
 [main.loadpointSettings]
 currents = "Corrente de carga"
 default = "predefinado"
-disclaimerHint = "Aviso:"
-disclaimerText = "As alterações serão perdidas depois que o servidor for reiniciado."
 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 3cb0c7ae..0a478fd5 100644
--- a/i18n/ro.toml
+++ b/i18n/ro.toml
@@ -160,8 +160,6 @@ power = "Putere"
 [main.loadpointSettings]
 currents = "Curent încărcare"
 default = "default"
-disclaimerHint = "Nota:"
-disclaimerText = "Modificările se vor pierde după restartul serverului."
 title = "Setări {0}"
 vehicle = "Vehicul"
 
diff --git a/i18n/ru.toml b/i18n/ru.toml
index df3f1c9f..ce68153d 100644
--- a/i18n/ru.toml
+++ b/i18n/ru.toml
@@ -123,8 +123,6 @@ power = "Мощность"
 [main.loadpointSettings]
 currents = "Ток зарядки"
 default = "по умолчанию"
-disclaimerHint = "Заметка:"
-disclaimerText = "Изменения будут потеряны после перезагрузки сервера"
 title = "Настройки {0}"
 vehicle = "Транспортное средство"
 
diff --git a/i18n/sl.toml b/i18n/sl.toml
index 3fb54c91..8ee0afb6 100644
--- a/i18n/sl.toml
+++ b/i18n/sl.toml
@@ -211,8 +211,6 @@ solar = "Sončna energija"
 [main.loadpointSettings]
 currents = "Polnilni tok"
 default = "privzeto"
-disclaimerHint = "Opomba:"
-disclaimerText = "Po ponovnem zagonu strežnika bodo spremembe izgubljene."
 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 49a90df1..8b0181f9 100644
--- a/i18n/sv.toml
+++ b/i18n/sv.toml
@@ -211,8 +211,6 @@ solar = "Solenergi"
 [main.loadpointSettings]
 currents = "Laddningsström"
 default = "förvald"
-disclaimerHint = "Anmärkning:"
-disclaimerText = "Ändringarna försvinner när servern startas om."
 onlyForSocBasedCharging = "Dessa inställningar är endast tillgängliga för fordon med känd laddnivå"
 title = "Inställningar {0}"
 vehicle = "Fordon"
diff --git a/i18n/uk.toml b/i18n/uk.toml
index 2a09796a..2f575d06 100644
--- a/i18n/uk.toml
+++ b/i18n/uk.toml
@@ -188,8 +188,6 @@ solar = "Сонячна"
 [main.loadpointSettings]
 currents = "Струм зарядки"
 default = "за замовчуванням"
-disclaimerHint = "Примітка:"
-disclaimerText = "Зміни будуть втрачені після перезапуску сервера."
 title = "Налаштування {0}"
 vehicle = "Транспортний засіб"
 
diff --git a/i18n/zh-Hans.toml b/i18n/zh-Hans.toml
index 8e0b019d..77ab9a14 100644
--- a/i18n/zh-Hans.toml
+++ b/i18n/zh-Hans.toml
@@ -198,8 +198,6 @@ solar = "太阳能"
 [main.loadpointSettings]
 currents = "充电货币"
 default = "默认"
-disclaimerHint = "笔记:"
-disclaimerText = "服务器重新启动后,更改将丢失."
 title = "设置 {0}"
 vehicle = "车辆"
 
diff --git a/meter/goodwe-wifi.go b/meter/goodwe-wifi.go
new file mode 100644
index 00000000..26652e21
--- /dev/null
+++ b/meter/goodwe-wifi.go
@@ -0,0 +1,73 @@
+package meter
+
+import (
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/meter/goodwe"
+	"github.com/evcc-io/evcc/util"
+)
+
+type goodWeWiFi struct {
+	*goodwe.Server
+	usage string
+	uri   string
+}
+
+func init() {
+	registry.Add("goodwe-wifi", NewGoodWeWifiFromConfig)
+}
+
+//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 {
+		capacity   `mapstructure:",squash"`
+		URI, Usage string
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	return NewGoodWeWiFi(cc.URI, cc.Usage)
+}
+
+func NewGoodWeWiFi(uri string, usage string) (api.Meter, error) {
+	instance, err := goodwe.Instance()
+	if err != nil {
+		return nil, err
+	}
+
+	res := &goodWeWiFi{
+		Server: instance,
+		usage:  usage,
+		uri:    uri,
+	}
+
+	instance.AddInverter(uri)
+
+	// decorate api.BatterySoc
+	var batterySoc func() (float64, error)
+	if usage == "battery" {
+		batterySoc = res.batterySoc
+	}
+
+	return decorateGoodWeWifi(res, batterySoc), nil
+}
+
+func (m *goodWeWiFi) CurrentPower() (float64, error) {
+	data := m.GetInverter(m.uri)
+
+	switch m.usage {
+	case "grid":
+		return data.NetPower, nil
+	case "pv":
+		return data.PvPower, nil
+	case "battery":
+		return data.BatteryPower, nil
+	}
+	return 0, api.ErrNotAvailable
+}
+
+func (m *goodWeWiFi) batterySoc() (float64, error) {
+	return m.GetInverter(m.uri).Soc, nil
+}
diff --git a/meter/goodwe-wifi_decorators.go b/meter/goodwe-wifi_decorators.go
new file mode 100644
index 00000000..e44f0d9b
--- /dev/null
+++ b/meter/goodwe-wifi_decorators.go
@@ -0,0 +1,35 @@
+package meter
+
+// Code generated by github.com/evcc-io/evcc/cmd/tools/decorate.go. DO NOT EDIT.
+
+import (
+	"github.com/evcc-io/evcc/api"
+)
+
+func decorateGoodWeWifi(base *goodWeWiFi, battery func() (float64, error)) api.Meter {
+	switch {
+	case battery == nil:
+		return base
+
+	case battery != nil:
+		return &struct {
+			*goodWeWiFi
+			api.Battery
+		}{
+			goodWeWiFi: base,
+			Battery: &decorateGoodWeWifiBatteryImpl{
+				battery: battery,
+			},
+		}
+	}
+
+	return nil
+}
+
+type decorateGoodWeWifiBatteryImpl struct {
+	battery func() (float64, error)
+}
+
+func (impl *decorateGoodWeWifiBatteryImpl) Soc() (float64, error) {
+	return impl.battery()
+}
diff --git a/meter/goodwe/server.go b/meter/goodwe/server.go
new file mode 100644
index 00000000..9a847c3a
--- /dev/null
+++ b/meter/goodwe/server.go
@@ -0,0 +1,109 @@
+package goodwe
+
+import (
+	"encoding/binary"
+	"net"
+	"sync"
+	"time"
+)
+
+var (
+	instance *Server
+	mu       sync.RWMutex
+)
+
+func Instance() (*Server, error) {
+	mu.Lock()
+	defer mu.Unlock()
+
+	if instance != nil {
+		return instance, nil
+	}
+
+	instance = &Server{
+		inverters: make(map[string]Inverter),
+	}
+
+	addr, err := net.ResolveUDPAddr("udp", "0.0.0.0:8899")
+	if err != nil {
+		return nil, err
+	}
+
+	instance.conn, err = net.ListenUDP("udp", addr)
+	if err != nil {
+		return nil, err
+	}
+
+	go instance.listen()
+	go instance.readData()
+
+	return instance, err
+}
+
+func (m *Server) AddInverter(ip string) {
+	mu.Lock()
+	defer mu.Unlock()
+	m.inverters[ip] = Inverter{IP: ip}
+}
+
+func (m *Server) GetInverter(uri string) Inverter {
+	mu.RLock()
+	defer mu.RUnlock()
+	return m.inverters[uri]
+}
+
+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
+		}
+		time.Sleep(5 * time.Second)
+		if _, err := m.conn.WriteToUDP([]byte{0xF7, 0x03, 0x90, 0x88, 0x00, 0x0D, 0x3D, 0xB3}, addr); err != nil {
+			return
+		}
+	}
+	m.readData()
+}
+
+func (m *Server) listen() {
+	for {
+		buf := make([]byte, 1024)
+		_, addr, err := m.conn.ReadFromUDP(buf)
+		if err != nil {
+			continue
+		}
+
+		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
+		}
+
+		if buf[4] == 26 {
+			inverter := m.inverters[ip]
+			inverter.Soc = float64(buf[20])
+			m.inverters[ip] = inverter
+		}
+		mu.Unlock()
+	}
+}
diff --git a/meter/goodwe/types.go b/meter/goodwe/types.go
new file mode 100644
index 00000000..06bb9986
--- /dev/null
+++ b/meter/goodwe/types.go
@@ -0,0 +1,18 @@
+package goodwe
+
+import (
+	"net"
+)
+
+type Server struct {
+	conn      *net.UDPConn
+	inverters map[string]Inverter
+}
+
+type Inverter struct {
+	IP           string
+	PvPower      float64
+	NetPower     float64
+	BatteryPower float64
+	Soc          float64
+}
diff --git a/server/http.go b/server/http.go
index ccb796cb..313c683d 100644
--- a/server/http.go
+++ b/server/http.go
@@ -134,8 +134,8 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, cache *util.Cache) {
 			"mode":             {[]string{"POST", "OPTIONS"}, "/mode/{value:[a-z]+}", chargeModeHandler(lp)},
 			"limitsoc":         {[]string{"POST", "OPTIONS"}, "/limitsoc/{value:[0-9]+}", intHandler(pass(lp.SetLimitSoc), lp.GetLimitSoc)},
 			"limitenergy":      {[]string{"POST", "OPTIONS"}, "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
-			"mincurrent":       {[]string{"POST", "OPTIONS"}, "/mincurrent/{value:[0-9.]+}", floatHandler(pass(lp.SetMinCurrent), lp.GetMinCurrent)},
-			"maxcurrent":       {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9.]+}", floatHandler(pass(lp.SetMaxCurrent), lp.GetMaxCurrent)},
+			"mincurrent":       {[]string{"POST", "OPTIONS"}, "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
+			"maxcurrent":       {[]string{"POST", "OPTIONS"}, "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
 			"phases":           {[]string{"POST", "OPTIONS"}, "/phases/{value:[0-9]+}", phasesHandler(lp)},
 			"plan":             {[]string{"GET"}, "/plan", planHandler(lp)},
 			"planpreview":      {[]string{"GET"}, "/plan/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.-]+}", planPreviewHandler(lp)},
diff --git a/server/http_site_handler.go b/server/http_site_handler.go
index 39ffb4bd..ca615038 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -18,10 +18,22 @@ import (
 	"github.com/evcc-io/evcc/util/jq"
 	"github.com/gorilla/mux"
 	"github.com/itchyny/gojq"
+	"golang.org/x/text/language"
 )
 
 var ignoreState = []string{"releaseNotes"} // excessive size
 
+// getPreferredLanguage returns the preferred language as two letter code
+func getPreferredLanguage(header string) string {
+	languages, _, err := language.ParseAcceptLanguage(header)
+	if err != nil || len(languages) == 0 {
+		return "en"
+	}
+
+	base, _ := languages[0].Base()
+	return base.String()
+}
+
 func indexHandler() http.HandlerFunc {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
 		w.Header().Set("Content-Type", "text/html; charset=UTF-8")
@@ -39,9 +51,12 @@ func indexHandler() http.HandlerFunc {
 			log.FATAL.Fatal("httpd: failed to create main page template:", err.Error())
 		}
 
+		defaultLang := getPreferredLanguage(r.Header.Get("Accept-Language"))
+
 		if err := t.Execute(w, map[string]interface{}{
-			"Version": Version,
-			"Commit":  Commit,
+			"Version":     Version,
+			"Commit":      Commit,
+			"DefaultLang": defaultLang,
 		}); err != nil {
 			log.ERROR.Println("httpd: failed to render main page:", err.Error())
 		}
diff --git a/templates/definition/meter/fronius-solarapi-v1.yaml b/templates/definition/meter/fronius-solarapi-v1.yaml
index 643f2eb0..463543ab 100644
--- a/templates/definition/meter/fronius-solarapi-v1.yaml
+++ b/templates/definition/meter/fronius-solarapi-v1.yaml
@@ -3,6 +3,7 @@ products:
   - brand: Fronius
     description:
       generic: Solar API V1
+capabilities: ["battery-control"]
 params:
   - name: usage
     choice: ["grid", "pv", "battery"]
@@ -10,6 +11,10 @@ params:
   - name: host
   - name: capacity
     advanced: true
+  - name: user
+    required: false
+  - name: password
+    required: false
 render: |
   type: custom
   power:
@@ -27,6 +32,45 @@ render: |
     source: http
     uri: http://{{ .host }}/solar_api/v1/GetPowerFlowRealtimeData.fcgi
     jq: .Body.Data.Inverters."1".SOC
+  batterymode:
+    source: switch
+    switch:
+    - case: 1 # normal
+      set:
+        source: http
+        uri: http://{{ .host }}/config/timeofuse
+        method: POST
+        headers:
+        - content-type: application/json
+        auth:
+          type: digest
+          user: {{ .user }}
+          password: {{ .password }}
+        body: '{"timeofuse":[]}'
+    - case: 2 # hold
+      set:
+        source: http
+        uri: http://{{ .host }}/config/timeofuse
+        method: POST
+        headers:
+        - content-type: application/json
+        auth:
+          type: digest
+          user: {{ .user }}
+          password: {{ .password }}
+        body: '{"timeofuse":[{"Active":true,"Power":0,"ScheduleType":"DISCHARGE_MAX","TimeTable":{"Start":"00:00","End":"23:59"},"Weekdays":{"Mon":true,"Tue":true,"Wed":true,"Thu":true,"Fri":true,"Sat":true,"Sun":true}}]}'
+    - case: 3 # charge
+      set:
+        source: http
+        uri: http://{{ .host }}/config/timeofuse
+        method: POST
+        headers:
+        - content-type: application/json
+        auth:
+          type: digest
+          user: {{ .user }}
+          password: {{ .password }}
+        body: '{"timeofuse":[]}'
   {{- if .capacity }}
   capacity: {{ .capacity }} # kWh
   {{- end }}
diff --git a/templates/definition/meter/goodwe-wifi.yaml b/templates/definition/meter/goodwe-wifi.yaml
new file mode 100644
index 00000000..093637dc
--- /dev/null
+++ b/templates/definition/meter/goodwe-wifi.yaml
@@ -0,0 +1,17 @@
+template: goodwe-wifi
+products:
+  - brand: GoodWe
+    description:
+      generic: GoodWe over Wifi
+params:
+  - name: usage
+    choice: ["grid", "pv", "battery"]
+    allinone: true
+  - name: uri
+    description:
+      en: IP address of the GoodWe inverter.
+      de: IP-Adresse des GoodWe Wechselrichters.
+render: |
+  type: goodwe-wifi
+  usage: {{ .usage }}
+  uri: {{ .uri }}


Test patch:
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 363aa2e4..9e05d37d 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -87,7 +87,7 @@ func TestMaxActivePhases(t *testing.T) {
 			vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1)
 
 			lp := &Loadpoint{
-				ConfiguredPhases: dflt, // fixed phases or default
+				configuredPhases: dflt, // fixed phases or default
 				vehicle:          vehicle,
 				phases:           tc.physical,
 				measuredPhases:   tc.measuredPhases,
@@ -187,10 +187,10 @@ func TestPvScalePhases(t *testing.T) {
 			progress:         NewProgress(0, 10), // silence nil panics
 			wakeUpTimer:      NewTimer(),         // silence nil panics
 			mode:             api.ModeNow,
-			MinCurrent:       minA,
-			MaxCurrent:       maxA,
+			minCurrent:       minA,
+			maxCurrent:       maxA,
 			vehicle:          vehicle,
-			ConfiguredPhases: 0, // allow switching
+			configuredPhases: 0, // allow switching
 			phases:           tc.physical,
 			status:           api.StatusC,
 		}
@@ -366,8 +366,8 @@ func TestPvScalePhasesTimer(t *testing.T) {
 			log:            util.NewLogger("foo"),
 			clock:          clock,
 			charger:        charger,
-			MinCurrent:     minA,
-			MaxCurrent:     maxA,
+			minCurrent:     minA,
+			maxCurrent:     maxA,
 			phases:         tc.phases,
 			measuredPhases: tc.measuredPhases,
 			status:         api.StatusC,
@@ -431,8 +431,8 @@ func TestScalePhasesIfAvailable(t *testing.T) {
 				plainCharger,
 				phaseCharger,
 			},
-			MinCurrent:       minA,
-			ConfiguredPhases: tc.dflt,     // fixed phases or default
+			minCurrent:       minA,
+			configuredPhases: tc.dflt,     // fixed phases or default
 			phases:           tc.physical, // current phase status
 		}
 
diff --git a/core/loadpoint_test.go b/core/loadpoint_test.go
index 9807928a..aee70d13 100644
--- a/core/loadpoint_test.go
+++ b/core/loadpoint_test.go
@@ -75,7 +75,7 @@ func attachListeners(t *testing.T, lp *Loadpoint) {
 
 	if charger, ok := lp.charger.(*api.MockCharger); ok && charger != nil {
 		charger.EXPECT().Enabled().Return(true, nil)
-		charger.EXPECT().MaxCurrent(int64(lp.MinCurrent)).Return(nil)
+		charger.EXPECT().MaxCurrent(int64(lp.minCurrent)).Return(nil)
 	}
 
 	uiChan, pushChan, lpChan := createChannels(t)
@@ -88,8 +88,8 @@ func TestNew(t *testing.T) {
 	if lp.phases != 0 {
 		t.Errorf("Phases %v", lp.phases)
 	}
-	if lp.MaxCurrent != maxA {
-		t.Errorf("MaxCurrent %v", lp.MaxCurrent)
+	if lp.maxCurrent != maxA {
+		t.Errorf("MaxCurrent %v", lp.maxCurrent)
 	}
 	if lp.status != api.StatusNone {
 		t.Errorf("status %v", lp.status)
@@ -164,8 +164,8 @@ func TestUpdatePowerZero(t *testing.T) {
 			chargeTimer:   &Null{}, // silence nil panics
 			wakeUpTimer:   NewTimer(),
 			sessionEnergy: NewEnergyMetrics(),
-			MinCurrent:    minA,
-			MaxCurrent:    maxA,
+			minCurrent:    minA,
+			maxCurrent:    maxA,
 			phases:        1,
 			status:        tc.status, // no status change
 		}
@@ -311,8 +311,8 @@ func TestPVHysteresis(t *testing.T) {
 				log:            util.NewLogger("foo"),
 				clock:          clck,
 				charger:        charger,
-				MinCurrent:     minA,
-				MaxCurrent:     maxA,
+				minCurrent:     minA,
+				maxCurrent:     maxA,
 				phases:         phases,
 				measuredPhases: phases,
 				Enable: ThresholdConfig{
@@ -359,8 +359,8 @@ func TestPVHysteresisForStatusOtherThanC(t *testing.T) {
 	lp := &Loadpoint{
 		log:            util.NewLogger("foo"),
 		clock:          clck,
-		MinCurrent:     minA,
-		MaxCurrent:     maxA,
+		minCurrent:     minA,
+		maxCurrent:     maxA,
 		phases:         phases,
 		measuredPhases: phases,
 	}
@@ -401,8 +401,8 @@ func TestDisableAndEnableAtTargetSoc(t *testing.T) {
 		progress:    NewProgress(0, 10), // silence nil panics
 		wakeUpTimer: NewTimer(),         // silence nil panics
 		// coordinator:   coordinator.NewDummy(), // silence nil panics
-		MinCurrent:    minA,
-		MaxCurrent:    maxA,
+		minCurrent:    minA,
+		maxCurrent:    maxA,
 		vehicle:       vehicle,      // needed for targetSoc check
 		socEstimator:  socEstimator, // instead of vehicle: vehicle,
 		mode:          api.ModeNow,
@@ -479,8 +479,8 @@ func TestSetModeAndSocAtDisconnect(t *testing.T) {
 		chargeTimer:   &Null{}, // silence nil panics
 		wakeUpTimer:   NewTimer(),
 		sessionEnergy: NewEnergyMetrics(),
-		MinCurrent:    minA,
-		MaxCurrent:    maxA,
+		minCurrent:    minA,
+		maxCurrent:    maxA,
 		status:        api.StatusC,
 		Mode_:         api.ModeOff, // default mode
 	}
@@ -550,8 +550,8 @@ func TestChargedEnergyAtDisconnect(t *testing.T) {
 		chargeTimer:   &Null{}, // silence nil panics
 		wakeUpTimer:   NewTimer(),
 		sessionEnergy: NewEnergyMetrics(),
-		MinCurrent:    minA,
-		MaxCurrent:    maxA,
+		minCurrent:    minA,
+		maxCurrent:    maxA,
 		status:        api.StatusC,
 	}
 
diff --git a/core/loadpoint_vehicle_test.go b/core/loadpoint_vehicle_test.go
index 7fa779b2..927293de 100644
--- a/core/loadpoint_vehicle_test.go
+++ b/core/loadpoint_vehicle_test.go
@@ -47,8 +47,8 @@ func TestPublishSocAndRange(t *testing.T) {
 		chargeTimer:   &Null{}, // silence nil panics
 		socEstimator:  soc.NewEstimator(log, charger, vehicle, false),
 		sessionEnergy: NewEnergyMetrics(),
-		MinCurrent:    minA,
-		MaxCurrent:    maxA,
+		minCurrent:    minA,
+		maxCurrent:    maxA,
 		phases:        1,
 		mode:          api.ModeNow,
 	}
@@ -252,8 +252,8 @@ func TestReconnectVehicle(t *testing.T) {
 				chargeTimer:   &Null{}, // silence nil panics
 				wakeUpTimer:   NewTimer(),
 				sessionEnergy: NewEnergyMetrics(),
-				MinCurrent:    minA,
-				MaxCurrent:    maxA,
+				minCurrent:    minA,
+				maxCurrent:    maxA,
 				phases:        1,
 				mode:          api.ModeNow,
 			}
diff --git a/tests/basics.spec.js b/tests/basics.spec.js
index 91898f34..af3b5711 100644
--- a/tests/basics.spec.js
+++ b/tests/basics.spec.js
@@ -56,3 +56,18 @@ test.describe("session info", async () => {
     await expect(page.getByTestId("sessionInfoLabel").first()).toContainText("Solar");
   });
 });
+
+test.describe("loadpoint settings", async () => {
+  test("phase selection", async ({ page }) => {
+    await page.getByTestId("loadpoint-settings-button").nth(1).click();
+    await expect(page.getByLabel("auto-switching")).not.toBeVisible();
+    await expect(page.getByLabel("3 phase")).toBeChecked();
+    await expect(page.getByLabel("1 phase")).not.toBeChecked();
+    await expect(page.getByText("~ 11.0 kW")).toBeVisible();
+    await expect(page.getByText("~ 4.1 kW")).toBeVisible();
+
+    await page.getByLabel("1 phase").click();
+    await expect(page.getByText("~ 3.7 kW")).toBeVisible();
+    await expect(page.getByText("~ 1.4 kW")).toBeVisible();
+  });
+});
diff --git a/tests/heating.spec.js b/tests/heating.spec.js
index fc6bed1c..e88b16b7 100644
--- a/tests/heating.spec.js
+++ b/tests/heating.spec.js
@@ -21,7 +21,7 @@ test.describe("loadpoint", async () => {
   test("change limit in 1° steps", async ({ page }) => {
     await page.getByTestId("limit-soc").getByRole("combobox").selectOption("69.0°C");
     await expect(page.getByTestId("limit-soc")).toContainText("69.0°C");
-    page.reload();
+    await page.reload();
     await expect(page.getByTestId("limit-soc")).toContainText("69.0°C");
   });
 });