Repository: evcc-io/evcc
Pull request #18638 (2025-02-06 17:36:20)

Loadpoint: cleanup phase configuration and drop deprecations
Fix https://github.com/evcc-io/evcc/issues/18606

naltatis: @andig ich hab das Wording jetzt konsequent auf `PhasesConfigured` (vorher teilweise `ConfiguredPhases`) umbenannt. Letzteres wäre sprachlich schöner, aber `phasesConfigured` ist der Key, den wir bislang auch schon in der DB verwenden. Daher hier aus Kompatiblitäts- und Einfachheitsgründen (kein Mapping!!) konsequent die bestehenden Schreibweise.
andig: @naltatis code ist eigentlich fein, aber Test will nicht- könntest Du bitte nochmal schauen?
andig: @naltatis der letzte Commit sollte eigentlich ein nop sein (außer fehlendem publish). Siehst Du warum der Test failed?
andig: Ok, ich sehe es im Test. M.E. darf die Logik nicht auf phases gehen sondern auf phasesConfigured! Bug im Test oder im UI?
naltatis: Das hat wirklich was in der Phasenauswahl via UI kaputt gemacht. Ich schau rein.

<img width="1190" alt="Bildschirmfoto 2025-02-08 um 15 18 53" src="https://github.com/user-attachments/assets/a0796004-4212-4473-960f-26abd06bd11e" />

naltatis: Mit dem letzten Commit hat sich das Publish Verhalten von `phasesActive` verändert. Bisher war es so, dass beim Umschalten der Configured Phasen via API der `phasesActive` sofort angepasst wurde. Das scheint jetzt nicht mehr der Fall zu sein.
andig: Das kann aber auch nicht an https://github.com/evcc-io/evcc/pull/18638/commits/20845dfe4731f41c01ec8b106984203c67762e9d liegen.
andig: Ich denke im UI ist das ein Logikfehler- hier muss auf die konfigurierten Phasen geschaut werden, nicht auf die aktiven?
naltatis: > hier muss auf die konfigurierten Phasen geschaut werden, nicht auf die aktiven

Würde ich vom Gefühl auch sagen. Bei 1p3p Charger tun wir das auch. Bei "klassischen" aber nicht. Ich kann das ändern. Mich irritiert aber, dass deine Änderung (ich sehe da auch keinen Fehler drin) diesen Test gebrochen hat. Ich kann mir das gerade nicht erklären. Scheint aber zu einer Verhaltensänderung zu führen.
andig: Grmpf. Wieso geht das immer noch nicht?
naltatis: @andig sieht aus als ob wir jetzt ein Locking Problem haben. Der `POST /api/config/loadpoints` bleibt stecken, wenn ich ihn mit einem Democharger anlegen möchte.
andig: Ich hab https://github.com/evcc-io/evcc/pull/18669/ in den master gezogen und hier gemerged.
andig: LGTM

Patch:
diff --git a/assets/js/components/Config/LoadpointModal.vue b/assets/js/components/Config/LoadpointModal.vue
index a52126ff..21a18918 100644
--- a/assets/js/components/Config/LoadpointModal.vue
+++ b/assets/js/components/Config/LoadpointModal.vue
@@ -384,7 +384,7 @@
 					>
 						<SelectGroup
 							id="loadpointParamPhases"
-							v-model="values.phases"
+							v-model="values.phasesConfigured"
 							class="w-100"
 							:options="[
 								{ value: 1, name: $t('config.loadpoint.phases1p') },
@@ -559,7 +559,7 @@ const nsPerMin = 60 * 1e9;
 
 const defaultValues = {
 	title: "",
-	phases: 3,
+	phasesConfigured: 3,
 	minCurrent: 6,
 	maxCurrent: 16,
 	priority: 0,
@@ -813,17 +813,17 @@ export default {
 			}
 		},
 		updatePhases() {
-			const { phases } = this.values;
+			const { phasesConfigured } = this.values;
 			if (this.chargerIsSinglePhase) {
-				this.values.phases = 1;
+				this.values.phasesConfigured = 1;
 				return;
 			}
 			if (this.chargerSupports1p3p && this.isNew) {
-				this.values.phases = 0; // automatic
+				this.values.phasesConfigured = 0; // automatic
 				return;
 			}
-			if (!this.chargerSupports1p3p && phases === 0) {
-				this.values.phases = 3; // no automatic switching, default to 3-phase
+			if (!this.chargerSupports1p3p && phasesConfigured === 0) {
+				this.values.phasesConfigured = 3; // no automatic switching, default to 3-phase
 				return;
 			}
 		},
diff --git a/assets/js/components/Loadpoint.vue b/assets/js/components/Loadpoint.vue
index 6aecbaa0..2e48b21f 100644
--- a/assets/js/components/Loadpoint.vue
+++ b/assets/js/components/Loadpoint.vue
@@ -180,7 +180,6 @@ export default {
 		chargeRemainingDuration: Number,
 
 		// other information
-		phases: Number,
 		phasesConfigured: Number,
 		phasesActive: Number,
 		chargerPhases1p3p: Boolean,
diff --git a/assets/js/components/LoadpointSettingsModal.vue b/assets/js/components/LoadpointSettingsModal.vue
index 94271c20..0c4fb59c 100644
--- a/assets/js/components/LoadpointSettingsModal.vue
+++ b/assets/js/components/LoadpointSettingsModal.vue
@@ -183,7 +183,6 @@ export default {
 	props: {
 		id: [String, Number],
 		phasesConfigured: Number,
-		phasesActive: Number,
 		chargerPhases1p3p: Boolean,
 		chargerSinglePhase: Boolean,
 		batteryBoost: Boolean,
@@ -237,7 +236,7 @@ export default {
 					return this.maxPowerPhases(this.phasesConfigured);
 				}
 			}
-			return this.fmtW(this.maxCurrent * V * this.phasesActive);
+			return this.fmtW(this.maxCurrent * V * this.phasesConfigured);
 		},
 		minPower() {
 			if (this.chargerPhases1p3p) {
@@ -248,7 +247,7 @@ export default {
 					return this.minPowerPhases(this.phasesConfigured);
 				}
 			}
-			return this.fmtW(this.minCurrent * V * this.phasesActive);
+			return this.fmtW(this.minCurrent * V * this.phasesConfigured);
 		},
 		minCurrentOptions() {
 			const opt1 = [...range(Math.floor(this.maxCurrent), 1), 0.5, 0.25, 0.125];
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index bec03cdf..53bcdade 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -42,6 +42,7 @@
 							:key="loadpoint.name"
 							:name="loadpoint.title"
 							:editable="!!loadpoint.id"
+							:error="deviceError('loadpoint', loadpoint.name)"
 							data-testid="loadpoint"
 							@edit="editLoadpoint(loadpoint.id)"
 						>
diff --git a/cmd/class_enumer.go b/cmd/class_enumer.go
index 68cb935c..475663f5 100644
--- a/cmd/class_enumer.go
+++ b/cmd/class_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship"
+const _ClassName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorshiploadpoint"
 
-var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 96, 105, 116}
+var _ClassIndex = [...]uint8{0, 10, 15, 22, 29, 35, 42, 46, 50, 58, 69, 74, 84, 86, 90, 96, 105, 116, 125}
 
-const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorship"
+const _ClassLowerName = "configfilemeterchargervehicletariffcircuitsitemqttdatabasemodbusproxyeebusjavascriptgohemsinfluxmessengersponsorshiploadpoint"
 
 func (i Class) String() string {
 	i -= 1
@@ -42,9 +42,10 @@ func _ClassNoOp() {
 	_ = x[ClassInflux-(15)]
 	_ = x[ClassMessenger-(16)]
 	_ = x[ClassSponsorship-(17)]
+	_ = x[ClassLoadpoint-(18)]
 }
 
-var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship}
+var _ClassValues = []Class{ClassConfigFile, ClassMeter, ClassCharger, ClassVehicle, ClassTariff, ClassCircuit, ClassSite, ClassMqtt, ClassDatabase, ClassModbusProxy, ClassEEBus, ClassJavascript, ClassGo, ClassHEMS, ClassInflux, ClassMessenger, ClassSponsorship, ClassLoadpoint}
 
 var _ClassNameToValueMap = map[string]Class{
 	_ClassName[0:10]:         ClassConfigFile,
@@ -81,6 +82,8 @@ var _ClassNameToValueMap = map[string]Class{
 	_ClassLowerName[96:105]:  ClassMessenger,
 	_ClassName[105:116]:      ClassSponsorship,
 	_ClassLowerName[105:116]: ClassSponsorship,
+	_ClassName[116:125]:      ClassLoadpoint,
+	_ClassLowerName[116:125]: ClassLoadpoint,
 }
 
 var _ClassNames = []string{
@@ -101,6 +104,7 @@ var _ClassNames = []string{
 	_ClassName[90:96],
 	_ClassName[96:105],
 	_ClassName[105:116],
+	_ClassName[116:125],
 }
 
 // ClassString retrieves an enum value from the enum constants string name.
diff --git a/cmd/error.go b/cmd/error.go
index 3d8c78e8..a027a97c 100644
--- a/cmd/error.go
+++ b/cmd/error.go
@@ -27,6 +27,7 @@ const (
 	ClassInflux
 	ClassMessenger
 	ClassSponsorship
+	ClassLoadpoint
 )
 
 // FatalError is an error that can be marshaled
@@ -57,7 +58,7 @@ type DeviceError struct {
 }
 
 func (e *DeviceError) Error() string {
-	return e.err.Error()
+	return "[" + e.Name + "] " + e.err.Error()
 }
 
 // ClassError indicates the class of devices that failed
@@ -67,7 +68,7 @@ type ClassError struct {
 }
 
 func (e *ClassError) Error() string {
-	return e.err.Error()
+	return e.Class.String() + " " + e.err.Error()
 }
 
 func (e ClassError) MarshalJSON() ([]byte, error) {
diff --git a/cmd/setup.go b/cmd/setup.go
index 99455381..4c35a6ad 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -862,7 +862,7 @@ func configureSiteAndLoadpoints(conf *globalconfig.All) (*core.Site, error) {
 	}
 
 	if err := configureLoadpoints(*conf); err != nil {
-		return nil, fmt.Errorf("failed configuring loadpoints: %w", err)
+		return nil, &ClassError{ClassLoadpoint, err}
 	}
 
 	tariffs, err := configureTariffs(conf.Tariffs)
@@ -945,7 +945,7 @@ func configureLoadpoints(conf globalconfig.All) error {
 
 		instance, err := core.NewLoadpointFromConfig(log, settings, cc.Other)
 		if err != nil {
-			return fmt.Errorf("failed configuring loadpoint: %w", err)
+			return &DeviceError{cc.Name, err}
 		}
 
 		if err := config.Loadpoints().Add(config.NewStaticDevice(cc, loadpoint.API(instance))); err != nil {
@@ -970,21 +970,21 @@ func configureLoadpoints(conf globalconfig.All) error {
 
 		dynamic, static, err := loadpoint.SplitConfig(cc.Other)
 		if err != nil {
-			return fmt.Errorf("failed configuring loadpoint: %w", err)
+			return &DeviceError{cc.Name, err}
 		}
 
 		instance, err := core.NewLoadpointFromConfig(log, settings, static)
 		if err != nil {
-			return fmt.Errorf("failed configuring loadpoint: %w", err)
-		}
-
-		if err := dynamic.Apply(instance); err != nil {
-			return err
+			return &DeviceError{cc.Name, err}
 		}
 
 		dev := config.NewConfigurableDevice[loadpoint.API](&conf, instance)
 		if err := config.Loadpoints().Add(dev); err != nil {
-			return err
+			return &DeviceError{cc.Name, err}
+		}
+
+		if err := dynamic.Apply(instance); err != nil {
+			return &DeviceError{cc.Name, err}
 		}
 	}
 
diff --git a/core/keys/loadpoint.go b/core/keys/loadpoint.go
index 543f914b..3892880d 100644
--- a/core/keys/loadpoint.go
+++ b/core/keys/loadpoint.go
@@ -19,9 +19,7 @@ const (
 	DisableDelay     = "disableDelay"
 	BatteryBoost     = "batteryBoost"
 
-	Phases           = "phases"           // configured phases (1/3, 0 for auto on 1p3p chargers, nil for plain chargers)
-	PhasesConfigured = "phasesConfigured" // TODO mirrors "phases" for UI purposes
-	PhasesEnabled    = "phasesEnabled"    // enabled phases (1/3)
+	PhasesConfigured = "phasesConfigured" // desired phase mode (0/1/3, 0 = automatic), user selection
 	PhasesActive     = "phasesActive"     // active phases as used by vehicle (1/2/3)
 
 	ChargerIcon         = "chargerIcon"         // charger icon for ui
diff --git a/core/loadpoint.go b/core/loadpoint.go
index f17e13f8..c85cbd75 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -93,20 +93,22 @@ type Loadpoint struct {
 	Soc             loadpoint.SocConfig
 	Enable, Disable loadpoint.ThresholdConfig
 
-	// TODO deprecated
-	DefaultMode    api.ChargeMode `mapstructure:"mode"`          // Default charge mode, used for disconnect
-	Title_         string         `mapstructure:"title"`         // UI title
-	Priority_      int            `mapstructure:"priority"`      // Priority
-	GuardDuration_ time.Duration  `mapstructure:"guardduration"` // charger enable/disable minimum holding time
-	Phases_        int            `mapstructure:"phases"`
-	MinCurrent_    float64        `mapstructure:"minCurrent"`
-	MaxCurrent_    float64        `mapstructure:"maxCurrent"`
+	// from yaml
+	DefaultMode api.ChargeMode `mapstructure:"mode"`     // Default charge mode, used for disconnect
+	Title       string         `mapstructure:"title"`    // UI title
+	Priority    int            `mapstructure:"priority"` // Priority
+
+	// from yaml, deprecated
+	GuardDuration_ time.Duration `mapstructure:"guardduration"` // ignored, present for compatibility
+	Phases_        int           `mapstructure:"phases"`        // ignored, present for compatibility
+	MinCurrent_    float64       `mapstructure:"minCurrent"`    // ignored, present for compatibility
+	MaxCurrent_    float64       `mapstructure:"maxCurrent"`    // ignored, present for compatibility
 
 	title            string   // UI title
 	priority         int      // Priority
 	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
+	phasesConfigured int      // Charger configured phase mode 0/1/3
 	limitSoc         int      // Session limit for soc
 	limitEnergy      float64  // Session limit for energy
 	smartCostLimit   *float64 // always charge if cost is below this value
@@ -223,13 +225,10 @@ func NewLoadpointFromConfig(log *util.Logger, settings settings.Settings, other
 
 	// phase switching defaults based on charger capabilities
 	if !lp.hasPhaseSwitching() {
-		lp.configuredPhases = 3
+		lp.phasesConfigured = 3
 		lp.phases = 3
 	}
 
-	// TODO deprecated
-	lp.migrateSettings()
-
 	// validate thresholds
 	if lp.Enable.Threshold > lp.Disable.Threshold {
 		lp.log.WARN.Printf("PV mode enable threshold (%.0fW) is larger than disable threshold (%.0fW)", lp.Enable.Threshold, lp.Disable.Threshold)
@@ -275,73 +274,43 @@ func NewLoadpoint(log *util.Logger, settings settings.Settings) *Loadpoint {
 	return lp
 }
 
-// migrateSettings migrates loadpoint settings
-func (lp *Loadpoint) migrateSettings() {
-	// One-time migrations MUST be mirrored in restoreSettings
-	if lp.DefaultMode != "" {
-		lp.log.WARN.Println("deprecated: mode setting is ignored, please remove")
-		if _, err := lp.settings.String(keys.Mode); err != nil {
-			lp.settings.SetString(keys.Mode, string(lp.DefaultMode))
-		}
+// restoreSettings restores loadpoint settings
+func (lp *Loadpoint) restoreSettings() {
+	if testing.Testing() {
+		return
 	}
-	if lp.Title_ != "" {
-		lp.log.WARN.Println("deprecated: title setting is ignored, please remove")
-		if _, err := lp.settings.String(keys.Title); err != nil {
-			lp.settings.SetString(keys.Title, lp.Title_)
-		}
+
+	// from yaml
+	if lp.Title != "" {
+		lp.setTitle(lp.Title)
 	}
-	if lp.Priority_ > 0 {
-		lp.log.WARN.Println("deprecated: priority setting is ignored, please remove")
-		if _, err := lp.settings.String(keys.Priority); err != nil {
-			lp.settings.SetInt(keys.Priority, int64(lp.Priority_))
-		}
+	if lp.Priority > 0 {
+		lp.setPriority(lp.Priority)
+	}
+
+	// deprecated yaml properties
+	if lp.Phases_ > 0 {
+		lp.log.WARN.Printf("ignoring deprecated phases: %d. please configure via UI", lp.Phases_)
 	}
 	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_)
-		}
+		lp.log.WARN.Printf("ignoring deprecated minCurrent: %f. please configure via UI", 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_)
-		}
+		lp.log.WARN.Printf("ignoring deprecated maxCurrent: %f. please configure via UI", lp.MaxCurrent_)
 	}
-	if lp.Phases_ > 0 {
-		lp.log.WARN.Println("deprecated: phases setting is ignored, please remove")
-		if _, err := lp.settings.Int(keys.Phases); err != nil {
-			lp.settings.SetInt(keys.Phases, int64(lp.Phases_))
-		}
+	if lp.GuardDuration_ > 0 {
+		lp.log.WARN.Printf("ignoring deprecated guardduration: %s. please configure via UI", lp.GuardDuration_)
 	}
-	if lp.Soc.Estimate != nil || lp.Soc.Poll.Mode != loadpoint.PollCharging || lp.Soc.Poll.Interval != 0 {
-		lp.log.WARN.Println("deprecated: soc setting is ignored, please remove")
-		if _, err := lp.settings.String(keys.Soc); err != nil {
-			lp.settings.SetJson(keys.Soc, lp.Soc)
-		}
-	}
-}
 
-// restoreSettings restores loadpoint settings
-func (lp *Loadpoint) restoreSettings() {
-	if testing.Testing() {
-		return
-	}
-	if v, err := lp.settings.String(keys.Title); err == nil && v != "" {
-		lp.setTitle(v)
-	}
+	// restore runtime configuration (database & yaml LPs)
 	if v, err := lp.settings.String(keys.Mode); err == nil && v != "" {
 		lp.setMode(api.ChargeMode(v))
 	}
 	if v, err := lp.settings.Int(keys.Priority); err == nil && v > 0 {
 		lp.setPriority(int(v))
 	}
-	if v, err := lp.settings.Int(keys.Phases); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
-		lp.setConfiguredPhases(int(v))
-		// for 1p3p charger, we don't know the physical state yet (phases == 0), so don't touch it
-		if !lp.hasPhaseSwitching() {
-			lp.phases = lp.configuredPhases
-		}
+	if v, err := lp.settings.Int(keys.PhasesConfigured); err == nil && (v > 0 || lp.hasPhaseSwitching()) {
+		lp.setPhasesConfigured(int(v))
 	}
 	if v, err := lp.settings.Float(keys.MinCurrent); err == nil && v > 0 {
 		lp.setMinCurrent(v)
@@ -534,7 +503,7 @@ func (lp *Loadpoint) evVehicleDisconnectHandler() {
 	lp.clearSession()
 
 	// phases are unknown when vehicle disconnects
-	lp.resetMeasuredPhases()
+	lp.ResetMeasuredPhases()
 
 	// energy and duration
 	lp.energyMetrics.Publish("session", lp)
@@ -647,20 +616,16 @@ func (lp *Loadpoint) Prepare(uiChan chan<- util.Param, pushChan chan<- push.Even
 	lp.publish(keys.DisableDelay, lp.Disable.Delay)
 
 	if phases := lp.getChargerPhysicalPhases(); phases != 0 {
-		if lp.configuredPhases != phases && lp.configuredPhases != 0 {
-			lp.log.WARN.Printf("configured phases %d do not match physical phases %d", lp.configuredPhases, phases)
+		if lp.phasesConfigured != phases && lp.phasesConfigured != 0 {
+			lp.log.WARN.Printf("configured phases %d do not match physical phases %d", lp.phasesConfigured, phases)
 		}
 		lp.phases = phases
-		lp.configuredPhases = phases
-		lp.publish(keys.Phases, phases)
-	} else {
-		lp.publish(keys.Phases, nil)
+		lp.phasesConfigured = phases
 	}
 
-	lp.publish(keys.PhasesConfigured, lp.configuredPhases)
+	lp.publish(keys.PhasesConfigured, lp.phasesConfigured)
 	lp.publish(keys.ChargerPhases1p3p, lp.hasPhaseSwitching())
 	lp.publish(keys.ChargerSinglePhase, lp.getChargerPhysicalPhases() == 1)
-	lp.publish(keys.PhasesEnabled, lp.phases)
 	lp.publish(keys.PhasesActive, lp.ActivePhases())
 	lp.publish(keys.SmartCostLimit, lp.smartCostLimit)
 	lp.publishTimer(phaseTimer, 0, timerInactive)
@@ -815,7 +780,7 @@ func (lp *Loadpoint) syncCharger() error {
 				if chargerPhases, err = pg.GetPhases(); err == nil {
 					if chargerPhases > 0 && chargerPhases != phases {
 						lp.log.WARN.Printf("charger logic error: phases mismatch (got %d, expected %d)", chargerPhases, phases)
-						lp.setPhases(chargerPhases)
+						lp.SetPhases(chargerPhases)
 					}
 				} else {
 					if errors.Is(err, api.ErrNotAvailable) {
@@ -829,7 +794,7 @@ func (lp *Loadpoint) syncCharger() error {
 			if !isPg || errors.Is(err, api.ErrNotAvailable) {
 				if chargerPhases > phases {
 					lp.log.WARN.Printf("charger logic error: phases mismatch (got %d measured, expected %d)", chargerPhases, phases)
-					lp.setPhases(chargerPhases)
+					lp.SetPhases(chargerPhases)
 				}
 			}
 		}
@@ -1188,13 +1153,13 @@ func (lp *Loadpoint) resetPhaseTimer() {
 
 // scalePhasesRequired validates if fixed phase configuration matches enabled phases
 func (lp *Loadpoint) scalePhasesRequired() bool {
-	return lp.hasPhaseSwitching() && lp.configuredPhases != 0 && lp.configuredPhases != lp.GetPhases()
+	return lp.hasPhaseSwitching() && lp.phasesConfigured != 0 && lp.phasesConfigured != 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.phasesConfigured != 0 {
+		phases = lp.phasesConfigured
 	}
 
 	if lp.hasPhaseSwitching() {
@@ -1224,7 +1189,7 @@ func (lp *Loadpoint) scalePhases(phases int) error {
 		lp.phasesSwitched = lp.clock.Now()
 
 		// update setting and reset timer
-		lp.setPhases(phases)
+		lp.SetPhases(phases)
 	}
 
 	return nil
@@ -1252,13 +1217,13 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in
 		if lp.chargerUpdateCompleted() && lp.phaseSwitchCompleted() {
 			lp.log.WARN.Printf("ignoring inconsistent phases: %dp < %dp observed active", phases, measuredPhases)
 		}
-		lp.resetMeasuredPhases()
+		lp.ResetMeasuredPhases()
 	}
 
 	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.phasesConfigured < 3
 
 	lp.log.DEBUG.Printf("!! pvScalePhases DOWN activePhases: %d, available power: %.0fW, scalable: %t", activePhases, availablePower, scalable)
 
@@ -1604,7 +1569,7 @@ func (lp *Loadpoint) updateChargeVoltages() {
 
 	if phases >= 1 {
 		lp.log.DEBUG.Printf("detected connected phases: %dp", phases)
-		lp.setPhases(phases)
+		lp.SetPhases(phases)
 	}
 }
 
@@ -1852,7 +1817,7 @@ func (lp *Loadpoint) Update(sitePower, batteryBoostPower float64, rates api.Rate
 		err = lp.setLimit(0)
 
 	case lp.scalePhasesRequired():
-		err = lp.scalePhases(lp.configuredPhases)
+		err = lp.scalePhases(lp.phasesConfigured)
 
 	case lp.remoteControlled(loadpoint.RemoteHardDisable):
 		remoteDisabled = loadpoint.RemoteHardDisable
diff --git a/core/loadpoint/api.go b/core/loadpoint/api.go
index 3870b9b0..e3fd0e9c 100644
--- a/core/loadpoint/api.go
+++ b/core/loadpoint/api.go
@@ -68,8 +68,10 @@ type API interface {
 	SetDefaultMode(api.ChargeMode)
 	// GetPhases returns the enabled phases
 	GetPhases() int
-	// SetPhases sets the enabled phases
-	SetPhases(int) error
+	// GetPhasesConfigured returns the configured phases
+	GetPhasesConfigured() int
+	// SetPhasesConfigured sets the configured phases
+	SetPhasesConfigured(int) error
 	// ActivePhases returns the active phases for the current vehicle
 	ActivePhases() int
 
diff --git a/core/loadpoint/config.go b/core/loadpoint/config.go
index 6834a222..f69936ba 100644
--- a/core/loadpoint/config.go
+++ b/core/loadpoint/config.go
@@ -17,17 +17,17 @@ type StaticConfig struct {
 
 type DynamicConfig struct {
 	// dynamic config
-	Title          string    `json:"title"`
-	DefaultMode    string    `json:"defaultMode"`
-	Priority       int       `json:"priority"`
-	Phases         int       `json:"phases"`
-	MinCurrent     float64   `json:"minCurrent"`
-	MaxCurrent     float64   `json:"maxCurrent"`
-	SmartCostLimit *float64  `json:"smartCostLimit"`
-	PlanEnergy     float64   `json:"planEnergy"`
-	PlanTime       time.Time `json:"planTime"`
-	LimitEnergy    float64   `json:"limitEnergy"`
-	LimitSoc       int       `json:"limitSoc"`
+	Title            string    `json:"title"`
+	DefaultMode      string    `json:"defaultMode"`
+	Priority         int       `json:"priority"`
+	PhasesConfigured int       `json:"phasesConfigured"`
+	MinCurrent       float64   `json:"minCurrent"`
+	MaxCurrent       float64   `json:"maxCurrent"`
+	SmartCostLimit   *float64  `json:"smartCostLimit"`
+	PlanEnergy       float64   `json:"planEnergy"`
+	PlanTime         time.Time `json:"planTime"`
+	LimitEnergy      float64   `json:"limitEnergy"`
+	LimitSoc         int       `json:"limitSoc"`
 
 	Thresholds ThresholdsConfig `json:"thresholds"`
 	Soc        SocConfig        `json:"soc"`
@@ -69,7 +69,7 @@ func (payload DynamicConfig) Apply(lp API) error {
 	}
 
 	if err == nil {
-		err = lp.SetPhases(payload.Phases)
+		err = lp.SetPhasesConfigured(payload.PhasesConfigured)
 	}
 
 	if err == nil && payload.MinCurrent != 0 {
diff --git a/core/loadpoint/mock.go b/core/loadpoint/mock.go
index 5db8d755..0f150589 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -405,6 +405,20 @@ func (mr *MockAPIMockRecorder) GetPhases() *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPhases", reflect.TypeOf((*MockAPI)(nil).GetPhases))
 }
 
+// GetPhasesConfigured mocks base method.
+func (m *MockAPI) GetPhasesConfigured() int {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetPhasesConfigured")
+	ret0, _ := ret[0].(int)
+	return ret0
+}
+
+// GetPhasesConfigured indicates an expected call of GetPhasesConfigured.
+func (mr *MockAPIMockRecorder) GetPhasesConfigured() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetPhasesConfigured", reflect.TypeOf((*MockAPI)(nil).GetPhasesConfigured))
+}
+
 // GetPlan mocks base method.
 func (m *MockAPI) GetPlan(targetTime time.Time, requiredDuration time.Duration) (api.Rates, error) {
 	m.ctrl.T.Helper()
@@ -780,18 +794,18 @@ func (mr *MockAPIMockRecorder) SetMode(arg0 any) *gomock.Call {
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetMode", reflect.TypeOf((*MockAPI)(nil).SetMode), arg0)
 }
 
-// SetPhases mocks base method.
-func (m *MockAPI) SetPhases(arg0 int) error {
+// SetPhasesConfigured mocks base method.
+func (m *MockAPI) SetPhasesConfigured(arg0 int) error {
 	m.ctrl.T.Helper()
-	ret := m.ctrl.Call(m, "SetPhases", arg0)
+	ret := m.ctrl.Call(m, "SetPhasesConfigured", arg0)
 	ret0, _ := ret[0].(error)
 	return ret0
 }
 
-// SetPhases indicates an expected call of SetPhases.
-func (mr *MockAPIMockRecorder) SetPhases(arg0 any) *gomock.Call {
+// SetPhasesConfigured indicates an expected call of SetPhasesConfigured.
+func (mr *MockAPIMockRecorder) SetPhasesConfigured(arg0 any) *gomock.Call {
 	mr.mock.ctrl.T.Helper()
-	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPhases", reflect.TypeOf((*MockAPI)(nil).SetPhases), arg0)
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "SetPhasesConfigured", reflect.TypeOf((*MockAPI)(nil).SetPhasesConfigured), arg0)
 }
 
 // SetPlanEnergy mocks base method.
diff --git a/core/loadpoint_api.go b/core/loadpoint_api.go
index 333abc19..486c835d 100644
--- a/core/loadpoint_api.go
+++ b/core/loadpoint_api.go
@@ -166,25 +166,27 @@ func (lp *Loadpoint) SetPriority(prio int) {
 
 	lp.log.DEBUG.Println("set priority:", prio)
 
-	if lp.Priority_ != prio {
+	if lp.Priority != prio {
 		lp.setPriority(prio)
 	}
 }
 
-// GetPhases returns loadpoint enabled phases
+// GetPhases returns the enabled phases
 func (lp *Loadpoint) GetPhases() int {
 	lp.RLock()
 	defer lp.RUnlock()
-	return lp.getPhases()
+	return lp.phases
 }
 
-// getPhases returns loadpoint enabled phases
-func (lp *Loadpoint) getPhases() int {
-	return lp.phases
+// GetPhasesConfigured returns the configured phases
+func (lp *Loadpoint) GetPhasesConfigured() int {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.phasesConfigured
 }
 
-// SetPhases sets loadpoint enabled phases
-func (lp *Loadpoint) SetPhases(phases int) error {
+// SetPhasesConfigured sets the configured phases
+func (lp *Loadpoint) SetPhasesConfigured(phases int) error {
 	// limit auto mode (phases=0) to scalable charger
 	if !lp.hasPhaseSwitching() && phases == 0 {
 		return fmt.Errorf("charger does not support phase switching")
@@ -198,13 +200,13 @@ func (lp *Loadpoint) SetPhases(phases int) error {
 	lp.log.DEBUG.Println("set phases:", phases)
 
 	lp.Lock()
-	lp.setConfiguredPhases(phases)
-	lp.Unlock()
+	lp.setPhasesConfigured(phases)
 
 	// apply immediately if not 1p3p
 	if !lp.hasPhaseSwitching() {
 		lp.setPhases(phases)
 	}
+	lp.Unlock()
 
 	lp.requestUpdate()
 
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index abcc44c6..f0e9bbb3 100644
--- a/core/loadpoint_phases.go
+++ b/core/loadpoint_phases.go
@@ -5,23 +5,30 @@ import (
 	"github.com/evcc-io/evcc/core/keys"
 )
 
-// setConfiguredPhases sets the default phase configuration
-func (lp *Loadpoint) setConfiguredPhases(phases int) {
-	lp.configuredPhases = phases
-	lp.publish(keys.Phases, lp.configuredPhases)
-	lp.publish(keys.PhasesConfigured, lp.configuredPhases) // TODO remove
-	lp.settings.SetInt(keys.Phases, int64(lp.configuredPhases))
+// setPhasesConfigured sets the default phase configuration
+func (lp *Loadpoint) setPhasesConfigured(phases int) {
+	lp.phasesConfigured = phases
+	lp.publish(keys.PhasesConfigured, lp.phasesConfigured)
+	lp.settings.SetInt(keys.PhasesConfigured, int64(lp.phasesConfigured))
+
+	// configured phases are actual phases for non-1p3p charger
+	// for 1p3p charger, configuration does not mean that the physical state has changed, so don't touch it
+	if !lp.hasPhaseSwitching() {
+		lp.setPhases(phases)
+	}
+}
+
+// SetPhases sets the number of enabled phases without modifying the charger
+func (lp *Loadpoint) SetPhases(phases int) {
+	lp.Lock()
+	defer lp.Unlock()
+	lp.setPhases(phases)
 }
 
 // setPhases sets the number of enabled phases without modifying the charger
 func (lp *Loadpoint) setPhases(phases int) {
-	if lp.GetPhases() != phases {
-		lp.Lock()
+	if lp.phases != phases {
 		lp.phases = phases
-		lp.Unlock()
-
-		// publish updated phase configuration
-		lp.publish(keys.PhasesEnabled, lp.phases)
 
 		// reset timer to disabled state
 		lp.resetPhaseTimer()
@@ -31,13 +38,17 @@ func (lp *Loadpoint) setPhases(phases int) {
 	}
 }
 
+// ResetMeasuredPhases resets measured phases to unknown on vehicle disconnect, phase switch or phase api call
+func (lp *Loadpoint) ResetMeasuredPhases() {
+	lp.Lock()
+	defer lp.Unlock()
+	lp.resetMeasuredPhases()
+}
+
 // resetMeasuredPhases resets measured phases to unknown on vehicle disconnect, phase switch or phase api call
 func (lp *Loadpoint) resetMeasuredPhases() {
-	lp.Lock()
 	lp.measuredPhases = 0
-	lp.Unlock()
-
-	lp.publish(keys.PhasesActive, lp.ActivePhases())
+	lp.publish(keys.PhasesActive, lp.activePhases())
 }
 
 // GetMeasuredPhases provides synchronized access to measuredPhases
@@ -73,7 +84,7 @@ func (lp *Loadpoint) ActivePhases() int {
 // activePhases returns the number of expectedly active phases for the meter.
 // If unknown for 1p3p chargers during startup it will assume 3p.
 func (lp *Loadpoint) activePhases() int {
-	physical := lp.getPhases()
+	physical := lp.phases
 	vehicle := lp.getVehiclePhases()
 	measured := lp.getMeasuredPhases()
 	charger := lp.getChargerPhysicalPhases()
@@ -97,8 +108,7 @@ func (lp *Loadpoint) MinActivePhases() int {
 
 // minActivePhases returns the minimum number of active phases for the loadpoint.
 func (lp *Loadpoint) minActivePhases() int {
-	// 1p3p supported or limit 1p
-	if lp.hasPhaseSwitching() || lp.configuredPhases == 1 {
+	if lp.hasPhaseSwitching() || lp.phasesConfigured == 1 {
 		return 1
 	}
 
@@ -114,7 +124,7 @@ func (lp *Loadpoint) MaxActivePhases() int {
 
 // maxActivePhases returns the maximum number of active phases for the loadpoint.
 func (lp *Loadpoint) maxActivePhases() int {
-	physical := lp.getPhases()
+	physical := lp.phases
 	measured := lp.getMeasuredPhases()
 	vehicle := lp.getVehiclePhases()
 	charger := lp.getChargerPhysicalPhases()
@@ -126,7 +136,7 @@ func (lp *Loadpoint) maxActivePhases() int {
 
 	// if 1p3p supported then assume configured limit or 3p
 	if lp.hasPhaseSwitching() {
-		physical = lp.configuredPhases
+		physical = lp.phasesConfigured
 	}
 
 	return min(expect(vehicle), expect(physical), expect(measured), expect(charger))
diff --git a/server/http.go b/server/http.go
index ef9a053f..e8fbd821 100644
--- a/server/http.go
+++ b/server/http.go
@@ -166,7 +166,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 			"limitenergy":          {"POST", "/limitenergy/{value:[0-9.]+}", floatHandler(pass(lp.SetLimitEnergy), lp.GetLimitEnergy)},
 			"mincurrent":           {"POST", "/mincurrent/{value:[0-9.]+}", floatHandler(lp.SetMinCurrent, lp.GetMinCurrent)},
 			"maxcurrent":           {"POST", "/maxcurrent/{value:[0-9.]+}", floatHandler(lp.SetMaxCurrent, lp.GetMaxCurrent)},
-			"phases":               {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhases, lp.GetPhases)},
+			"phases":               {"POST", "/phases/{value:[0-9]+}", intHandler(lp.SetPhasesConfigured, lp.GetPhasesConfigured)},
 			"plan":                 {"GET", "/plan", planHandler(lp)},
 			"staticPlanPreview":    {"GET", "/plan/static/preview/{type:(?:soc|energy)}/{value:[0-9.]+}/{time:[0-9TZ:.+-]+}", staticPlanPreviewHandler(lp)},
 			"repeatingPlanPreview": {"GET", "/plan/repeating/preview/{soc:[0-9]+}/{weekdays:[0-6,]+}/{time:[0-2][0-9]:[0-5][0-9]}/{tz:[a-zA-Z0-9_./:-]+}", repeatingPlanPreviewHandler(lp)},
diff --git a/server/http_config_loadpoint_handler.go b/server/http_config_loadpoint_handler.go
index 4896b0f1..16e7dc6c 100644
--- a/server/http_config_loadpoint_handler.go
+++ b/server/http_config_loadpoint_handler.go
@@ -28,19 +28,19 @@ func getLoadpointStaticConfig(lp loadpoint.API) loadpoint.StaticConfig {
 func getLoadpointDynamicConfig(lp loadpoint.API) loadpoint.DynamicConfig {
 	planTime, planEnergy := lp.GetPlanEnergy()
 	return loadpoint.DynamicConfig{
-		Title:          lp.GetTitle(),
-		DefaultMode:    string(lp.GetDefaultMode()),
-		Priority:       lp.GetPriority(),
-		Phases:         lp.GetPhases(),
-		MinCurrent:     lp.GetMinCurrent(),
-		MaxCurrent:     lp.GetMaxCurrent(),
-		SmartCostLimit: lp.GetSmartCostLimit(),
-		Thresholds:     lp.GetThresholds(),
-		Soc:            lp.GetSocConfig(),
-		PlanEnergy:     planEnergy,
-		PlanTime:       planTime,
-		LimitEnergy:    lp.GetLimitEnergy(),
-		LimitSoc:       lp.GetLimitSoc(),
+		Title:            lp.GetTitle(),
+		DefaultMode:      string(lp.GetDefaultMode()),
+		Priority:         lp.GetPriority(),
+		PhasesConfigured: lp.GetPhasesConfigured(),
+		MinCurrent:       lp.GetMinCurrent(),
+		MaxCurrent:       lp.GetMaxCurrent(),
+		SmartCostLimit:   lp.GetSmartCostLimit(),
+		Thresholds:       lp.GetThresholds(),
+		Soc:              lp.GetSocConfig(),
+		PlanEnergy:       planEnergy,
+		PlanTime:         planTime,
+		LimitEnergy:      lp.GetLimitEnergy(),
+		LimitSoc:         lp.GetLimitSoc(),
 	}
 }
 
diff --git a/server/mqtt.go b/server/mqtt.go
index 3e0fd735..f40b8e8d 100644
--- a/server/mqtt.go
+++ b/server/mqtt.go
@@ -201,7 +201,7 @@ func (m *MQTT) listenSiteSetters(topic string, site site.API) error {
 func (m *MQTT) listenLoadpointSetters(topic string, site site.API, lp loadpoint.API) error {
 	for _, s := range []setter{
 		{"mode", setterFunc(api.ChargeModeString, pass(lp.SetMode))},
-		{"phases", intSetter(lp.SetPhases)},
+		{"phases", intSetter(lp.SetPhasesConfigured)},
 		{"limitSoc", intSetter(pass(lp.SetLimitSoc))},
 		{"priority", intSetter(pass(lp.SetPriority))},
 		{"minCurrent", floatSetter(lp.SetMinCurrent)},


Test patch:
diff --git a/cmd/error_test.go b/cmd/error_test.go
index e05045db..b67ea4ad 100644
--- a/cmd/error_test.go
+++ b/cmd/error_test.go
@@ -18,5 +18,5 @@ func TestError(t *testing.T) {
 
 	b, err := res.MarshalJSON()
 	require.NoError(t, err)
-	require.Equal(t, `{"class":"meter","device":"0815","error":"foo"}`, string(b))
+	require.Equal(t, `{"class":"meter","device":"0815","error":"[0815] foo"}`, string(b))
 }
diff --git a/core/loadpoint_phases_test.go b/core/loadpoint_phases_test.go
index 4dbb6b02..6fdd78cf 100644
--- a/core/loadpoint_phases_test.go
+++ b/core/loadpoint_phases_test.go
@@ -82,7 +82,7 @@ func TestMaxActivePhases(t *testing.T) {
 			vehicle.EXPECT().Phases().Return(tc.vehicle).MinTimes(1)
 
 			lp := &Loadpoint{
-				configuredPhases: configured, // fixed phases or default
+				phasesConfigured: configured, // fixed phases or default
 				vehicle:          vehicle,
 				phases:           tc.physical,
 				measuredPhases:   tc.measuredPhases,
@@ -134,7 +134,7 @@ func TestMinActivePhases(t *testing.T) {
 			vehicle.EXPECT().Phases().Return(tc.vehicle).AnyTimes()
 
 			lp := &Loadpoint{
-				configuredPhases: configured, // fixed phases or default
+				phasesConfigured: configured, // fixed phases or default
 				vehicle:          vehicle,
 				phases:           tc.physical,
 				measuredPhases:   tc.measuredPhases,
@@ -231,7 +231,7 @@ func TestPvScalePhases(t *testing.T) {
 			minCurrent:       minA,
 			maxCurrent:       maxA,
 			vehicle:          vehicle,
-			configuredPhases: 0, // allow switching
+			phasesConfigured: 0, // allow switching
 			phases:           tc.physical,
 			status:           api.StatusC,
 		}
@@ -463,7 +463,7 @@ func TestScalePhasesIfAvailable(t *testing.T) {
 				phaseCharger,
 			},
 			minCurrent:       minA,
-			configuredPhases: tc.dflt,     // fixed phases or default
+			phasesConfigured: tc.dflt,     // fixed phases or default
 			phases:           tc.physical, // current phase status
 		}