Repository: evcc-io/evcc
Pull request #18269 (2025-01-18 15:01:20)

Add solar forecast (Solcast/Forecast.Solar)
@naltatis as simplified alternative to https://github.com/evcc-io/evcc/pull/9454 and without integrating the forecast into planning, it might be nice to displace a solar forecast (in W). Short-term it would be nice to collect and log the data for forecast/actual comparison, later use it for forecasting.

Is this something you'd be interested in including?

```
From                   To                     Price/Cost
2025-01-18 10:19:34    2025-01-18 10:19:34    0.000
2025-01-18 11:00:00    2025-01-18 11:00:00    678.000
2025-01-18 12:00:00    2025-01-18 12:00:00    1108.000
2025-01-18 13:00:00    2025-01-18 13:00:00    1390.000
2025-01-18 14:00:00    2025-01-18 14:00:00    1508.000
2025-01-18 15:00:00    2025-01-18 15:00:00    1578.000
2025-01-18 16:00:00    2025-01-18 16:00:00    1851.000
2025-01-18 17:00:00    2025-01-18 17:00:00    1861.000
2025-01-18 18:00:00    2025-01-18 18:00:00    1227.000
2025-01-18 18:42:40    2025-01-18 18:42:40    0.000
2025-01-19 10:18:30    2025-01-19 10:18:30    0.000
2025-01-19 11:00:00    2025-01-19 11:00:00    1377.000
2025-01-19 12:00:00    2025-01-19 12:00:00    2200.000
2025-01-19 13:00:00    2025-01-19 13:00:00    2767.000
2025-01-19 14:00:00    2025-01-19 14:00:00    3055.000
2025-01-19 15:00:00    2025-01-19 15:00:00    2995.000
2025-01-19 16:00:00    2025-01-19 16:00:00    2679.000
2025-01-19 17:00:00    2025-01-19 17:00:00    2025.000
2025-01-19 18:00:00    2025-01-19 18:00:00    1279.000
2025-01-19 18:44:21    2025-01-19 18:44:21    0.000
```

TODO

- [x] `solar` tariff type @andig 
- [x] request/udpate `solar` @andig 
- [x] `solar` docs group @naltatis  > `needs documentation` tag regelt

Out of scope

- [ ] ui @naltatis 
- [ ] integrate with planner
- [ ] multi-solar

naltatis: Good idea to start with a display only integration. Makes things a lot easier.
I'll think about how to visualize/integrate this data in a first iteration. A simple "expected kWh of solar energy in the next 24h" information in the main UI could be a good entry point.

Patch:
diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index 4e2ce2c0..d21127c8 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -140,6 +140,7 @@ type Tariffs struct {
 	FeedIn   config.Typed
 	Co2      config.Typed
 	Planner  config.Typed
+	Solar    config.Typed
 }
 
 type Network struct {
diff --git a/api/tariff.go b/api/tariff.go
index dcb5f6db..07bba41f 100644
--- a/api/tariff.go
+++ b/api/tariff.go
@@ -1,6 +1,7 @@
 package api
 
 //go:generate enumer -type TariffType -trimprefix TariffType -transform=lower -text
+//go:generate enumer -type TariffUsage -trimprefix TariffUsage -transform=lower
 
 type TariffType int
 
@@ -10,4 +11,16 @@ const (
 	TariffTypePriceDynamic
 	TariffTypePriceForecast
 	TariffTypeCo2
+	TariffTypeSolar
+)
+
+type TariffUsage int
+
+const (
+	_ TariffUsage = iota
+	TariffUsageCo2
+	TariffUsageFeedin
+	TariffUsageGrid
+	TariffUsagePlanner
+	TariffUsageSolar
 )
diff --git a/api/tarifftype_enumer.go b/api/tarifftype_enumer.go
index b44f8cd7..12272d9b 100644
--- a/api/tarifftype_enumer.go
+++ b/api/tarifftype_enumer.go
@@ -7,11 +7,11 @@ import (
 	"strings"
 )
 
-const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2"
+const _TariffTypeName = "pricestaticpricedynamicpriceforecastco2solar"
 
-var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39}
+var _TariffTypeIndex = [...]uint8{0, 11, 23, 36, 39, 44}
 
-const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2"
+const _TariffTypeLowerName = "pricestaticpricedynamicpriceforecastco2solar"
 
 func (i TariffType) String() string {
 	i -= 1
@@ -29,9 +29,10 @@ func _TariffTypeNoOp() {
 	_ = x[TariffTypePriceDynamic-(2)]
 	_ = x[TariffTypePriceForecast-(3)]
 	_ = x[TariffTypeCo2-(4)]
+	_ = x[TariffTypeSolar-(5)]
 }
 
-var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2}
+var _TariffTypeValues = []TariffType{TariffTypePriceStatic, TariffTypePriceDynamic, TariffTypePriceForecast, TariffTypeCo2, TariffTypeSolar}
 
 var _TariffTypeNameToValueMap = map[string]TariffType{
 	_TariffTypeName[0:11]:       TariffTypePriceStatic,
@@ -42,6 +43,8 @@ var _TariffTypeNameToValueMap = map[string]TariffType{
 	_TariffTypeLowerName[23:36]: TariffTypePriceForecast,
 	_TariffTypeName[36:39]:      TariffTypeCo2,
 	_TariffTypeLowerName[36:39]: TariffTypeCo2,
+	_TariffTypeName[39:44]:      TariffTypeSolar,
+	_TariffTypeLowerName[39:44]: TariffTypeSolar,
 }
 
 var _TariffTypeNames = []string{
@@ -49,6 +52,7 @@ var _TariffTypeNames = []string{
 	_TariffTypeName[11:23],
 	_TariffTypeName[23:36],
 	_TariffTypeName[36:39],
+	_TariffTypeName[39:44],
 }
 
 // TariffTypeString retrieves an enum value from the enum constants string name.
diff --git a/api/tariffusage_enumer.go b/api/tariffusage_enumer.go
new file mode 100644
index 00000000..fcd32e6e
--- /dev/null
+++ b/api/tariffusage_enumer.go
@@ -0,0 +1,91 @@
+// Code generated by "enumer -type TariffUsage -trimprefix TariffUsage -transform=lower"; DO NOT EDIT.
+
+package api
+
+import (
+	"fmt"
+	"strings"
+)
+
+const _TariffUsageName = "co2feedingridplannersolar"
+
+var _TariffUsageIndex = [...]uint8{0, 3, 9, 13, 20, 25}
+
+const _TariffUsageLowerName = "co2feedingridplannersolar"
+
+func (i TariffUsage) String() string {
+	i -= 1
+	if i < 0 || i >= TariffUsage(len(_TariffUsageIndex)-1) {
+		return fmt.Sprintf("TariffUsage(%d)", i+1)
+	}
+	return _TariffUsageName[_TariffUsageIndex[i]:_TariffUsageIndex[i+1]]
+}
+
+// An "invalid array index" compiler error signifies that the constant values have changed.
+// Re-run the stringer command to generate them again.
+func _TariffUsageNoOp() {
+	var x [1]struct{}
+	_ = x[TariffUsageCo2-(1)]
+	_ = x[TariffUsageFeedin-(2)]
+	_ = x[TariffUsageGrid-(3)]
+	_ = x[TariffUsagePlanner-(4)]
+	_ = x[TariffUsageSolar-(5)]
+}
+
+var _TariffUsageValues = []TariffUsage{TariffUsageCo2, TariffUsageFeedin, TariffUsageGrid, TariffUsagePlanner, TariffUsageSolar}
+
+var _TariffUsageNameToValueMap = map[string]TariffUsage{
+	_TariffUsageName[0:3]:        TariffUsageCo2,
+	_TariffUsageLowerName[0:3]:   TariffUsageCo2,
+	_TariffUsageName[3:9]:        TariffUsageFeedin,
+	_TariffUsageLowerName[3:9]:   TariffUsageFeedin,
+	_TariffUsageName[9:13]:       TariffUsageGrid,
+	_TariffUsageLowerName[9:13]:  TariffUsageGrid,
+	_TariffUsageName[13:20]:      TariffUsagePlanner,
+	_TariffUsageLowerName[13:20]: TariffUsagePlanner,
+	_TariffUsageName[20:25]:      TariffUsageSolar,
+	_TariffUsageLowerName[20:25]: TariffUsageSolar,
+}
+
+var _TariffUsageNames = []string{
+	_TariffUsageName[0:3],
+	_TariffUsageName[3:9],
+	_TariffUsageName[9:13],
+	_TariffUsageName[13:20],
+	_TariffUsageName[20:25],
+}
+
+// TariffUsageString retrieves an enum value from the enum constants string name.
+// Throws an error if the param is not part of the enum.
+func TariffUsageString(s string) (TariffUsage, error) {
+	if val, ok := _TariffUsageNameToValueMap[s]; ok {
+		return val, nil
+	}
+
+	if val, ok := _TariffUsageNameToValueMap[strings.ToLower(s)]; ok {
+		return val, nil
+	}
+	return 0, fmt.Errorf("%s does not belong to TariffUsage values", s)
+}
+
+// TariffUsageValues returns all values of the enum
+func TariffUsageValues() []TariffUsage {
+	return _TariffUsageValues
+}
+
+// TariffUsageStrings returns a slice of all String values of the enum
+func TariffUsageStrings() []string {
+	strs := make([]string, len(_TariffUsageNames))
+	copy(strs, _TariffUsageNames)
+	return strs
+}
+
+// IsATariffUsage returns "true" if the value is listed in the enum definition. "false" otherwise
+func (i TariffUsage) IsATariffUsage() bool {
+	for _, v := range _TariffUsageValues {
+		if i == v {
+			return true
+		}
+	}
+	return false
+}
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..ea517042 100644
--- a/cmd/setup.go
+++ b/cmd/setup.go
@@ -755,11 +755,12 @@ func tariffInstance(name string, conf config.Typed) (api.Tariff, error) {
 	return instance, nil
 }
 
-func configureTariff(name string, conf config.Typed, t *api.Tariff) error {
+func configureTariff(u api.TariffUsage, conf config.Typed, t *api.Tariff) error {
 	if conf.Type == "" {
 		return nil
 	}
 
+	name := u.String()
 	res, err := tariffInstance(name, conf)
 	if err != nil {
 		return &DeviceError{name, err}
@@ -786,10 +787,11 @@ func configureTariffs(conf globalconfig.Tariffs) (*tariff.Tariffs, error) {
 	}
 
 	var eg errgroup.Group
-	eg.Go(func() error { return configureTariff("grid", conf.Grid, &tariffs.Grid) })
-	eg.Go(func() error { return configureTariff("feedin", conf.FeedIn, &tariffs.FeedIn) })
-	eg.Go(func() error { return configureTariff("co2", conf.Co2, &tariffs.Co2) })
-	eg.Go(func() error { return configureTariff("planner", conf.Planner, &tariffs.Planner) })
+	eg.Go(func() error { return configureTariff(api.TariffUsageGrid, conf.Grid, &tariffs.Grid) })
+	eg.Go(func() error { return configureTariff(api.TariffUsageFeedin, conf.FeedIn, &tariffs.FeedIn) })
+	eg.Go(func() error { return configureTariff(api.TariffUsageCo2, conf.Co2, &tariffs.Co2) })
+	eg.Go(func() error { return configureTariff(api.TariffUsagePlanner, conf.Planner, &tariffs.Planner) })
+	eg.Go(func() error { return configureTariff(api.TariffUsageSolar, conf.Solar, &tariffs.Solar) })
 
 	if err := eg.Wait(); err != nil {
 		return nil, &ClassError{ClassTariff, err}
@@ -862,7 +864,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 +947,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 +972,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/cmd/tariff.go b/cmd/tariff.go
index c4e949c5..c2fc5343 100644
--- a/cmd/tariff.go
+++ b/cmd/tariff.go
@@ -42,12 +42,14 @@ func runTariff(cmd *cobra.Command, args []string) {
 		name = args[0]
 	}
 
-	for key, tf := range map[string]api.Tariff{
-		"grid":    tariffs.Grid,
-		"feedin":  tariffs.FeedIn,
-		"co2":     tariffs.Co2,
-		"planner": tariffs.Planner,
+	for u, tf := range map[api.TariffUsage]api.Tariff{
+		api.TariffUsageGrid:    tariffs.Grid,
+		api.TariffUsageFeedin:  tariffs.FeedIn,
+		api.TariffUsageCo2:     tariffs.Co2,
+		api.TariffUsagePlanner: tariffs.Planner,
+		api.TariffUsageSolar:   tariffs.Solar,
 	} {
+		key := u.String()
 		if name != "" && key != name {
 			continue
 		}
@@ -65,8 +67,20 @@ func runTariff(cmd *cobra.Command, args []string) {
 			fatal(err)
 		}
 
+		unit := "Price/Cost"
+		switch tf.Type() {
+		case api.TariffTypeCo2:
+			unit += "Footprint (gCO2/kWh)"
+		case api.TariffTypeSolar:
+			unit = "Yield (W)"
+		default:
+			if c := conf.Tariffs.Currency; c != "" {
+				unit += fmt.Sprintf(" (%s/kWh)", c)
+			}
+		}
+
 		tw := tabwriter.NewWriter(os.Stdout, 0, 0, 4, ' ', 0)
-		fmt.Fprintln(tw, "From\tTo\tPrice/Cost")
+		fmt.Fprintln(tw, "From\tTo\t"+unit)
 		const format = "2006-01-02 15:04:05"
 
 		for _, r := range rates {
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/keys/site.go b/core/keys/site.go
index b121cf09..fe00adcc 100644
--- a/core/keys/site.go
+++ b/core/keys/site.go
@@ -26,6 +26,7 @@ const (
 	TariffGrid            = "tariffGrid"
 	TariffPriceHome       = "tariffPriceHome"
 	TariffPriceLoadpoints = "tariffPriceLoadpoints"
+	TariffSolar           = "tariffSolar"
 	Vehicles              = "vehicles"
 
 	// meters
diff --git a/core/loadpoint.go b/core/loadpoint.go
index b808279b..c85cbd75 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -80,11 +80,11 @@ type Loadpoint struct {
 	lpChan   chan<- *Loadpoint // update requests
 	log      *util.Logger
 
-	// exposed public configuration
-	sync.RWMutex // guard status
-
-	vmu sync.RWMutex // guard vehicle
+	rwMutex      int64        // count reentrant RWMutex
+	sync.RWMutex              // guard status
+	vmu          sync.RWMutex // guard vehicle
 
+	// exposed public configuration
 	CircuitRef string `mapstructure:"circuit"` // Circuit reference
 	ChargerRef string `mapstructure:"charger"` // Charger reference
 	VehicleRef string `mapstructure:"vehicle"` // Vehicle reference
@@ -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_)
-		}
-	}
-	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_))
-		}
+		lp.log.WARN.Printf("ignoring deprecated maxCurrent: %f. please configure via UI", lp.MaxCurrent_)
 	}
-	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)
-		}
+	if lp.GuardDuration_ > 0 {
+		lp.log.WARN.Printf("ignoring deprecated guardduration: %s. please configure via UI", lp.GuardDuration_)
 	}
-}
 
-// 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)
 				}
 			}
 		}
@@ -982,7 +947,7 @@ func (lp *Loadpoint) repeatingPlanning() bool {
 	if !lp.socBasedPlanning() {
 		return false
 	}
-	_, _, id := lp.nextVehiclePlan()
+	_, _, id := lp.NextVehiclePlan()
 	return id > 1
 }
 
@@ -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
@@ -1247,18 +1212,18 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in
 	// - https://github.com/evcc-io/evcc/issues/1572
 	// - https://github.com/evcc-io/evcc/issues/2230
 	// - https://github.com/evcc-io/evcc/issues/2613
-	measuredPhases := lp.getMeasuredPhases()
+	measuredPhases := lp.GetMeasuredPhases()
 	if phases > 0 && phases < measuredPhases {
 		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)
 
@@ -1287,7 +1252,7 @@ func (lp *Loadpoint) pvScalePhases(sitePower, minCurrent, maxCurrent float64) in
 		waiting = true
 	}
 
-	maxPhases := lp.maxActivePhases()
+	maxPhases := lp.MaxActivePhases()
 	target1pCurrent := powerToCurrent(availablePower, 1)
 	scalable = maxPhases > 1 && phases < maxPhases && target1pCurrent > maxCurrent
 
@@ -1352,13 +1317,13 @@ func (lp *Loadpoint) publishTimer(name string, delay time.Duration, action strin
 
 // boostPower returns the additional power that the loadpoint should draw from the battery
 func (lp *Loadpoint) boostPower(batteryBoostPower float64) float64 {
-	boost := lp.getBatteryBoost()
+	boost := lp.GetBatteryBoost()
 	if boost == boostDisabled {
 		return 0
 	}
 
 	// push demand to drain battery
-	delta := lp.effectiveStepPower()
+	delta := lp.EffectiveStepPower()
 
 	// start boosting by setting maximum power
 	if boost == boostStart {
@@ -1392,7 +1357,7 @@ func (lp *Loadpoint) pvMaxCurrent(mode api.ChargeMode, sitePower, batteryBoostPo
 	var scaledTo int
 	if lp.hasPhaseSwitching() && lp.phaseSwitchCompleted() {
 		scaledTo = lp.pvScalePhases(sitePower, minCurrent, maxCurrent)
-	} else if lp.getBatteryBoost() != boostDisabled {
+	} else if lp.GetBatteryBoost() != boostDisabled {
 		lp.log.DEBUG.Printf("!! pvScalePhases phasesSwitched: %v, %v", lp.phasesSwitched, time.Since(lp.phasesSwitched))
 	}
 
@@ -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 be135728..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
 
@@ -144,7 +146,7 @@ type API interface {
 	SetDisableDelay(delay time.Duration)
 
 	// GetBatteryBoost returns the battery boost
-	GetBatteryBoost() bool
+	GetBatteryBoost() int
 	// SetBatteryBoost sets the battery boost
 	SetBatteryBoost(enable bool) error
 
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 7664457d..0f150589 100644
--- a/core/loadpoint/mock.go
+++ b/core/loadpoint/mock.go
@@ -126,10 +126,10 @@ func (mr *MockAPIMockRecorder) EffectivePriority() *gomock.Call {
 }
 
 // GetBatteryBoost mocks base method.
-func (m *MockAPI) GetBatteryBoost() bool {
+func (m *MockAPI) GetBatteryBoost() int {
 	m.ctrl.T.Helper()
 	ret := m.ctrl.Call(m, "GetBatteryBoost")
-	ret0, _ := ret[0].(bool)
+	ret0, _ := ret[0].(int)
 	return ret0
 }
 
@@ -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 52c0c6ae..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()
 
@@ -466,18 +468,13 @@ func (lp *Loadpoint) SetDisableDelay(delay time.Duration) {
 	}
 }
 
-// getBatteryBoost returns the battery boost
-func (lp *Loadpoint) getBatteryBoost() int {
+// GetBatteryBoost returns the battery boost
+func (lp *Loadpoint) GetBatteryBoost() int {
 	lp.RLock()
 	defer lp.RUnlock()
 	return lp.batteryBoost
 }
 
-// GetBatteryBoost returns the battery boost
-func (lp *Loadpoint) GetBatteryBoost() bool {
-	return lp.getBatteryBoost() > 0
-}
-
 // setBatteryBoost returns the battery boost
 func (lp *Loadpoint) setBatteryBoost(boost int) {
 	lp.Lock()
@@ -635,11 +632,15 @@ func (lp *Loadpoint) SetMaxCurrent(current float64) error {
 
 // GetMinPower returns the min loadpoint power for a single phase
 func (lp *Loadpoint) GetMinPower() float64 {
+	lp.Lock()
+	defer lp.Unlock()
 	return Voltage * lp.effectiveMinCurrent()
 }
 
 // GetMaxPower returns the max loadpoint power taking vehicle capabilities and phase scaling into account
 func (lp *Loadpoint) GetMaxPower() float64 {
+	lp.Lock()
+	defer lp.Unlock()
 	return Voltage * lp.effectiveMaxCurrent() * float64(lp.maxActivePhases())
 }
 
diff --git a/core/loadpoint_effective.go b/core/loadpoint_effective.go
index d810b02f..393e8514 100644
--- a/core/loadpoint_effective.go
+++ b/core/loadpoint_effective.go
@@ -40,7 +40,7 @@ type plan struct {
 
 func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
 	for i, p := range plans {
-		requiredDuration := lp.GetPlanRequiredDuration(float64(p.Soc), maxPower)
+		requiredDuration := lp.getPlanRequiredDuration(float64(p.Soc), maxPower)
 		plans[i].Start = p.End.Add(-requiredDuration)
 	}
 
@@ -56,6 +56,13 @@ func (lp *Loadpoint) nextActivePlan(maxPower float64, plans []plan) *plan {
 	return nil
 }
 
+// NextVehiclePlan returns the next vehicle plan time, soc and id
+func (lp *Loadpoint) NextVehiclePlan() (time.Time, int, int) {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.nextVehiclePlan()
+}
+
 // nextVehiclePlan returns the next vehicle plan time, soc and id
 func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
 	if v := lp.GetVehicle(); v != nil {
@@ -91,14 +98,14 @@ func (lp *Loadpoint) nextVehiclePlan() (time.Time, int, int) {
 
 // EffectivePlanSoc returns the soc target for the current plan
 func (lp *Loadpoint) EffectivePlanSoc() int {
-	_, soc, _ := lp.nextVehiclePlan()
+	_, soc, _ := lp.NextVehiclePlan()
 	return soc
 }
 
 // EffectivePlanId returns the id for the current plan
 func (lp *Loadpoint) EffectivePlanId() int {
 	if lp.socBasedPlanning() {
-		_, _, id := lp.nextVehiclePlan()
+		_, _, id := lp.NextVehiclePlan()
 		return id
 	}
 	if lp.planEnergy > 0 {
@@ -111,7 +118,7 @@ func (lp *Loadpoint) EffectivePlanId() int {
 // EffectivePlanTime returns the effective plan time
 func (lp *Loadpoint) EffectivePlanTime() time.Time {
 	if lp.socBasedPlanning() {
-		ts, _, _ := lp.nextVehiclePlan()
+		ts, _, _ := lp.NextVehiclePlan()
 		return ts
 	}
 
@@ -194,13 +201,15 @@ func (lp *Loadpoint) effectiveLimitSoc() int {
 	return 100
 }
 
-// effectiveStepPower returns the effective step power for the currently active phases
-func (lp *Loadpoint) effectiveStepPower() float64 {
+// EffectiveStepPower returns the effective step power for the currently active phases
+func (lp *Loadpoint) EffectiveStepPower() float64 {
 	return Voltage * float64(lp.ActivePhases())
 }
 
 // EffectiveMinPower returns the effective min power for the minimum active phases
 func (lp *Loadpoint) EffectiveMinPower() float64 {
+	lp.RLock()
+	defer lp.RUnlock()
 	return Voltage * lp.effectiveMinCurrent() * float64(lp.minActivePhases())
 }
 
diff --git a/core/loadpoint_mutex.go b/core/loadpoint_mutex.go
new file mode 100644
index 00000000..b5a8cebb
--- /dev/null
+++ b/core/loadpoint_mutex.go
@@ -0,0 +1,34 @@
+package core
+
+import (
+	"sync/atomic"
+	"testing"
+)
+
+func (lp *Loadpoint) RLock() {
+	if testing.Testing() && atomic.AddInt64(&lp.rwMutex, 1) > 1 {
+		panic("reentrant RLock")
+	}
+	lp.RWMutex.RLock()
+}
+
+func (lp *Loadpoint) RUnlock() {
+	if testing.Testing() {
+		atomic.AddInt64(&lp.rwMutex, -1)
+	}
+	lp.RWMutex.RUnlock()
+}
+
+func (lp *Loadpoint) Lock() {
+	if testing.Testing() && atomic.AddInt64(&lp.rwMutex, 1) > 1 {
+		panic("reentrant Lock")
+	}
+	lp.RWMutex.Lock()
+}
+
+func (lp *Loadpoint) Unlock() {
+	if testing.Testing() {
+		atomic.AddInt64(&lp.rwMutex, -1)
+	}
+	lp.RWMutex.Unlock()
+}
diff --git a/core/loadpoint_phases.go b/core/loadpoint_phases.go
index ad81983d..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,19 +38,28 @@ 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
+func (lp *Loadpoint) GetMeasuredPhases() int {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.getMeasuredPhases()
 }
 
 // getMeasuredPhases provides synchronized access to measuredPhases
 func (lp *Loadpoint) getMeasuredPhases() int {
-	lp.RLock()
-	defer lp.RUnlock()
 	return lp.measuredPhases
 }
 
@@ -60,7 +76,15 @@ func expect(phases int) 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()
+	lp.Lock()
+	defer lp.Unlock()
+	return lp.activePhases()
+}
+
+// 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.phases
 	vehicle := lp.getVehiclePhases()
 	measured := lp.getMeasuredPhases()
 	charger := lp.getChargerPhysicalPhases()
@@ -75,23 +99,32 @@ func (lp *Loadpoint) ActivePhases() int {
 	return active
 }
 
-// minActivePhases returns the minimum number of active phases for the loadpoint.
-func (lp *Loadpoint) minActivePhases() int {
+// MinActivePhases returns the minimum number of active phases for the loadpoint.
+func (lp *Loadpoint) MinActivePhases() int {
 	lp.RLock()
-	configuredPhases := lp.configuredPhases
-	lp.RUnlock()
+	defer lp.RUnlock()
+	return lp.minActivePhases()
+}
 
-	// 1p3p supported or limit 1p
-	if lp.hasPhaseSwitching() || configuredPhases == 1 {
+// minActivePhases returns the minimum number of active phases for the loadpoint.
+func (lp *Loadpoint) minActivePhases() int {
+	if lp.hasPhaseSwitching() || lp.phasesConfigured == 1 {
 		return 1
 	}
 
 	return lp.maxActivePhases()
 }
 
+// MaxActivePhases returns the maximum number of active phases for the loadpoint.
+func (lp *Loadpoint) MaxActivePhases() int {
+	lp.RLock()
+	defer lp.RUnlock()
+	return lp.maxActivePhases()
+}
+
 // 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()
@@ -103,9 +136,7 @@ func (lp *Loadpoint) maxActivePhases() int {
 
 	// if 1p3p supported then assume configured limit or 3p
 	if lp.hasPhaseSwitching() {
-		lp.RLock()
-		physical = lp.configuredPhases
-		lp.RUnlock()
+		physical = lp.phasesConfigured
 	}
 
 	return min(expect(vehicle), expect(physical), expect(measured), expect(charger))
diff --git a/core/loadpoint_plan.go b/core/loadpoint_plan.go
index 6442fe69..60f6acf9 100644
--- a/core/loadpoint_plan.go
+++ b/core/loadpoint_plan.go
@@ -48,7 +48,11 @@ func (lp *Loadpoint) remainingPlanEnergy(planEnergy float64) float64 {
 func (lp *Loadpoint) GetPlanRequiredDuration(goal, maxPower float64) time.Duration {
 	lp.RLock()
 	defer lp.RUnlock()
+	return lp.getPlanRequiredDuration(goal, maxPower)
+}
 
+// getPlanRequiredDuration is the estimated total charging duration
+func (lp *Loadpoint) getPlanRequiredDuration(goal, maxPower float64) time.Duration {
 	if lp.socBasedPlanning() {
 		if lp.socEstimator == nil {
 			return 0
diff --git a/core/site.go b/core/site.go
index 27a6ff86..7e0f5ccd 100644
--- a/core/site.go
+++ b/core/site.go
@@ -151,7 +151,7 @@ func (site *Site) Boot(log *util.Logger, loadpoints []*Loadpoint, tariffs *tarif
 		})
 	}
 
-	tariff := site.GetTariff(PlannerTariff)
+	tariff := site.GetTariff(api.TariffUsagePlanner)
 
 	// give loadpoints access to vehicles and database
 	for _, lp := range loadpoints {
@@ -757,8 +757,8 @@ func (site *Site) greenShare(powerFrom float64, powerTo float64) float64 {
 
 // effectivePrice calculates the real energy price based on self-produced and grid-imported energy.
 func (site *Site) effectivePrice(greenShare float64) *float64 {
-	if grid, err := site.tariffs.CurrentGridPrice(); err == nil {
-		feedin, err := site.tariffs.CurrentFeedInPrice()
+	if grid, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageGrid)); err == nil {
+		feedin, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageFeedin))
 		if err != nil {
 			feedin = 0
 		}
@@ -770,7 +770,7 @@ func (site *Site) effectivePrice(greenShare float64) *float64 {
 
 // effectiveCo2 calculates the amount of emitted co2 based on self-produced and grid-imported energy.
 func (site *Site) effectiveCo2(greenShare float64) *float64 {
-	if co2, err := site.tariffs.CurrentCo2(); err == nil {
+	if co2, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageCo2)); err == nil {
 		effCo2 := co2 * (1 - greenShare)
 		return &effCo2
 	}
@@ -781,26 +781,29 @@ func (site *Site) publishTariffs(greenShareHome float64, greenShareLoadpoints fl
 	site.publish(keys.GreenShareHome, greenShareHome)
 	site.publish(keys.GreenShareLoadpoints, greenShareLoadpoints)
 
-	if gridPrice, err := site.tariffs.CurrentGridPrice(); err == nil {
-		site.publish(keys.TariffGrid, gridPrice)
+	if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageGrid)); err == nil {
+		site.publish(keys.TariffGrid, v)
 	}
-	if feedInPrice, err := site.tariffs.CurrentFeedInPrice(); err == nil {
-		site.publish(keys.TariffFeedIn, feedInPrice)
+	if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageFeedin)); err == nil {
+		site.publish(keys.TariffFeedIn, v)
 	}
-	if co2, err := site.tariffs.CurrentCo2(); err == nil {
-		site.publish(keys.TariffCo2, co2)
+	if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageCo2)); err == nil {
+		site.publish(keys.TariffCo2, v)
 	}
-	if price := site.effectivePrice(greenShareHome); price != nil {
-		site.publish(keys.TariffPriceHome, price)
+	if v, err := tariff.CurrentPrice(site.GetTariff(api.TariffUsageSolar)); err == nil {
+		site.publish(keys.TariffSolar, v)
 	}
-	if co2 := site.effectiveCo2(greenShareHome); co2 != nil {
-		site.publish(keys.TariffCo2Home, co2)
+	if v := site.effectivePrice(greenShareHome); v != nil {
+		site.publish(keys.TariffPriceHome, v)
 	}
-	if price := site.effectivePrice(greenShareLoadpoints); price != nil {
-		site.publish(keys.TariffPriceLoadpoints, price)
+	if v := site.effectiveCo2(greenShareHome); v != nil {
+		site.publish(keys.TariffCo2Home, v)
 	}
-	if co2 := site.effectiveCo2(greenShareLoadpoints); co2 != nil {
-		site.publish(keys.TariffCo2Loadpoints, co2)
+	if v := site.effectivePrice(greenShareLoadpoints); v != nil {
+		site.publish(keys.TariffPriceLoadpoints, v)
+	}
+	if v := site.effectiveCo2(greenShareLoadpoints); v != nil {
+		site.publish(keys.TariffCo2Loadpoints, v)
 	}
 }
 
@@ -926,7 +929,7 @@ func (site *Site) prepare() {
 	site.publish(keys.ResidualPower, site.GetResidualPower())
 
 	site.publish(keys.Currency, site.tariffs.Currency)
-	if tariff := site.GetTariff(PlannerTariff); tariff != nil {
+	if tariff := site.GetTariff(api.TariffUsagePlanner); tariff != nil {
 		site.publish(keys.SmartCostType, tariff.Type())
 	} else {
 		site.publish(keys.SmartCostType, nil)
diff --git a/core/site/api.go b/core/site/api.go
index 56d5b4a4..f78d27aa 100644
--- a/core/site/api.go
+++ b/core/site/api.go
@@ -55,7 +55,7 @@ type API interface {
 	//
 
 	// GetTariff returns the respective tariff
-	GetTariff(string) api.Tariff
+	GetTariff(api.TariffUsage) api.Tariff
 
 	//
 	// battery control
diff --git a/core/site_api.go b/core/site_api.go
index 93ca3ef5..bf0b8bea 100644
--- a/core/site_api.go
+++ b/core/site_api.go
@@ -17,12 +17,6 @@ var _ site.API = (*Site)(nil)
 
 var ErrBatteryNotConfigured = errors.New("battery not configured")
 
-const (
-	GridTariff    = "grid"
-	FeedinTariff  = "feedin"
-	PlannerTariff = "planner"
-)
-
 // isConfigurable checks if the meter is configurable
 func isConfigurable(ref string) bool {
 	dev, _ := config.Meters().ByName(ref)
@@ -272,39 +266,10 @@ func (site *Site) SetResidualPower(power float64) error {
 }
 
 // GetTariff returns the respective tariff if configured or nil
-func (site *Site) GetTariff(tariff string) api.Tariff {
+func (site *Site) GetTariff(tariff api.TariffUsage) api.Tariff {
 	site.RLock()
 	defer site.RUnlock()
-
-	switch tariff {
-	case GridTariff:
-		return site.tariffs.Grid
-
-	case FeedinTariff:
-		return site.tariffs.FeedIn
-
-	case PlannerTariff:
-		switch {
-		case site.tariffs.Planner != nil:
-			// prio 0: manually set planner tariff
-			return site.tariffs.Planner
-
-		case site.tariffs.Grid != nil && site.tariffs.Grid.Type() == api.TariffTypePriceForecast:
-			// prio 1: grid tariff with forecast
-			return site.tariffs.Grid
-
-		case site.tariffs.Co2 != nil:
-			// prio 2: co2 tariff
-			return site.tariffs.Co2
-
-		default:
-			// prio 3: static grid tariff
-			return site.tariffs.Grid
-		}
-
-	default:
-		return nil
-	}
+	return site.tariffs.Get(tariff)
 }
 
 // GetBatteryDischargeControl returns the battery control mode (no discharge only)
diff --git a/core/site_battery.go b/core/site_battery.go
index 8b4895e2..dd287f89 100644
--- a/core/site_battery.go
+++ b/core/site_battery.go
@@ -78,7 +78,7 @@ func (site *Site) applyBatteryMode(mode api.BatteryMode) error {
 }
 
 func (site *Site) plannerRates() (api.Rates, error) {
-	tariff := site.GetTariff(PlannerTariff)
+	tariff := site.GetTariff(api.TariffUsagePlanner)
 	if tariff == nil || tariff.Type() == api.TariffTypePriceStatic {
 		return nil, nil
 	}
diff --git a/meter/rct.go b/meter/rct.go
index 6747b7f6..f27992dc 100644
--- a/meter/rct.go
+++ b/meter/rct.go
@@ -145,6 +145,10 @@ func NewRCT(uri, usage string, minSoc, maxSoc int, cache time.Duration, capacity
 				return m.conn.Write(rct.BatterySoCTargetMin, m.floatVal(float32(maxSoc)/100))
 
 			case api.BatteryCharge:
+				if err := m.conn.Write(rct.PowerMngUseGridPowerEnable, []byte{1}); err != nil {
+					return err
+				}
+
 				if err := m.conn.Write(rct.PowerMngBatteryPowerExternW, m.floatVal(float32(-10_000))); err != nil {
 					return err
 				}
diff --git a/server/http.go b/server/http.go
index 4a87b384..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)},
@@ -183,7 +183,7 @@ func (s *HTTPd) RegisterSiteHandlers(site site.API, valueChan chan<- util.Param)
 			"smartCost":            {"POST", "/smartcostlimit/{value:-?[0-9.]+}", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
 			"smartCostDelete":      {"DELETE", "/smartcostlimit", floatPtrHandler(pass(lp.SetSmartCostLimit), lp.GetSmartCostLimit)},
 			"priority":             {"POST", "/priority/{value:[0-9]+}", intHandler(pass(lp.SetPriority), lp.GetPriority)},
-			"batteryBoost":         {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, lp.GetBatteryBoost)},
+			"batteryBoost":         {"POST", "/batteryboost/{value:[01truefalse]+}", boolHandler(lp.SetBatteryBoost, func() bool { return lp.GetBatteryBoost() > 0 })},
 		}
 
 		for _, r := range routes {
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/http_site_handler.go b/server/http_site_handler.go
index 4f26430f..4eb017c9 100644
--- a/server/http_site_handler.go
+++ b/server/http_site_handler.go
@@ -251,7 +251,13 @@ func healthHandler(site site.API) http.HandlerFunc {
 func tariffHandler(site site.API) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
 		vars := mux.Vars(r)
-		tariff := vars["tariff"]
+		val := vars["tariff"]
+
+		tariff, err := api.TariffUsageString(val)
+		if err != nil {
+			jsonError(w, http.StatusNotFound, err)
+			return
+		}
 
 		t := site.GetTariff(tariff)
 		if t == nil {
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)},
diff --git a/tariff/solcast.go b/tariff/solcast.go
new file mode 100644
index 00000000..fb8d5828
--- /dev/null
+++ b/tariff/solcast.go
@@ -0,0 +1,121 @@
+package tariff
+
+import (
+	"errors"
+	"fmt"
+	"slices"
+	"sync"
+	"time"
+
+	"github.com/cenkalti/backoff/v4"
+	"github.com/evcc-io/evcc/api"
+	"github.com/evcc-io/evcc/tariff/solcast"
+	"github.com/evcc-io/evcc/util"
+	"github.com/evcc-io/evcc/util/request"
+	"github.com/evcc-io/evcc/util/transport"
+)
+
+type Solcast struct {
+	*request.Helper
+	log   *util.Logger
+	sites []string
+	data  *util.Monitor[api.Rates]
+}
+
+var _ api.Tariff = (*Solcast)(nil)
+
+func init() {
+	registry.Add("solcast", NewSolcastFromConfig)
+}
+
+func NewSolcastFromConfig(other map[string]interface{}) (api.Tariff, error) {
+	var cc struct {
+		Site  string
+		Token string
+	}
+
+	if err := util.DecodeOther(other, &cc); err != nil {
+		return nil, err
+	}
+
+	if cc.Site == "" {
+		return nil, errors.New("missing site id")
+	}
+	// TODO multiple sites
+	// if len(cc.Site) > 1 {
+	// 	return nil, errors.New("multiple sites not supported (yet)")
+	// }
+
+	if cc.Token == "" {
+		return nil, errors.New("missing token")
+	}
+
+	log := util.NewLogger("solcast").Redact(cc.Token)
+
+	t := &Solcast{
+		log:    log,
+		sites:  []string{cc.Site},
+		Helper: request.NewHelper(log),
+		data:   util.NewMonitor[api.Rates](2 * time.Hour),
+	}
+
+	t.Client.Transport = transport.BearerAuth(cc.Token, t.Client.Transport)
+
+	done := make(chan error)
+	go t.run(done)
+	err := <-done
+
+	return t, err
+}
+
+func (t *Solcast) run(done chan error) {
+	var once sync.Once
+
+	for ; true; <-time.Tick(time.Hour) {
+		var res solcast.Forecasts
+
+		if err := backoff.Retry(func() error {
+			for _, site := range t.sites {
+				uri := fmt.Sprintf("https://api.solcast.com.au/rooftop_sites/%s/forecasts?format=json", site)
+				if err := t.GetJSON(uri, &res); err != nil {
+					return err
+				}
+			}
+			return nil
+		}, bo()); err != nil {
+			once.Do(func() { done <- err })
+
+			t.log.ERROR.Println(err)
+			continue
+		}
+
+		once.Do(func() { close(done) })
+
+		data := make(api.Rates, 0, len(res.Forecasts))
+
+		for _, r := range res.Forecasts {
+			data = append(data, api.Rate{
+				Start: r.PeriodEnd.Add(-r.Period.Duration()).Local(),
+				End:   r.PeriodEnd.Local(),
+				Price: r.PvEstimate * 1e3,
+			})
+		}
+
+		mergeRates(t.data, data)
+		once.Do(func() { close(done) })
+	}
+}
+
+// Rates implements the api.Tariff interface
+func (t *Solcast) Rates() (api.Rates, error) {
+	var res api.Rates
+	err := t.data.GetFunc(func(val api.Rates) {
+		res = slices.Clone(val)
+	})
+	return res, err
+}
+
+// Type implements the api.Tariff interface
+func (t *Solcast) Type() api.TariffType {
+	return api.TariffTypeSolar
+}
diff --git a/tariff/solcast/types.go b/tariff/solcast/types.go
new file mode 100644
index 00000000..a37af2ef
--- /dev/null
+++ b/tariff/solcast/types.go
@@ -0,0 +1,34 @@
+package solcast
+
+import (
+	"time"
+
+	"github.com/dylanmei/iso8601"
+)
+
+type Forecasts struct {
+	Forecasts []Forecast
+}
+
+type Forecast struct {
+	PvEstimate   float64   `json:"pv_estimate"`
+	PvEstimate10 float64   `json:"pv_estimate10"`
+	PvEstimate90 float64   `json:"pv_estimate90"`
+	PeriodEnd    time.Time `json:"period_end"`
+	Period       Duration
+}
+
+type Duration time.Duration
+
+func (d *Duration) Duration() time.Duration {
+	return time.Duration(*d)
+}
+
+func (d *Duration) UnmarshalJSON(b []byte) error {
+	val, err := iso8601.ParseDuration(string(b))
+	if err != nil {
+		return err
+	}
+	*d = Duration(val)
+	return nil
+}
diff --git a/tariff/tariffs.go b/tariff/tariffs.go
index ffeb024c..0e621969 100644
--- a/tariff/tariffs.go
+++ b/tariff/tariffs.go
@@ -8,11 +8,11 @@ import (
 )
 
 type Tariffs struct {
-	Currency                   currency.Unit
-	Grid, FeedIn, Co2, Planner api.Tariff
+	Currency                          currency.Unit
+	Grid, FeedIn, Co2, Planner, Solar api.Tariff
 }
 
-func currentPrice(t api.Tariff) (float64, error) {
+func CurrentPrice(t api.Tariff) (float64, error) {
 	if t != nil {
 		if rr, err := t.Rates(); err == nil {
 			if r, err := rr.Current(time.Now()); err == nil {
@@ -23,20 +23,41 @@ func currentPrice(t api.Tariff) (float64, error) {
 	return 0, api.ErrNotAvailable
 }
 
-// CurrentGridPrice returns the current grid price.
-func (t *Tariffs) CurrentGridPrice() (float64, error) {
-	return currentPrice(t.Grid)
-}
+func (t *Tariffs) Get(u api.TariffUsage) api.Tariff {
+	switch u {
+	case api.TariffUsageCo2:
+		return t.Co2
 
-// CurrentFeedInPrice returns the current feed-in price.
-func (t *Tariffs) CurrentFeedInPrice() (float64, error) {
-	return currentPrice(t.FeedIn)
-}
+	case api.TariffUsageFeedin:
+		return t.FeedIn
+
+	case api.TariffUsageGrid:
+		return t.Grid
+
+	// TODO solar
+	case api.TariffUsagePlanner:
+		switch {
+		case t.Planner != nil:
+			// prio 0: manually set planner tariff
+			return t.Planner
+
+		case t.Grid != nil && t.Grid.Type() == api.TariffTypePriceForecast:
+			// prio 1: grid tariff with forecast
+			return t.Grid
 
-// CurrentCo2 determines the grids co2 emission.
-func (t *Tariffs) CurrentCo2() (float64, error) {
-	if t.Co2 != nil {
-		return currentPrice(t.Co2)
+		case t.Co2 != nil:
+			// prio 2: co2 tariff
+			return t.Co2
+
+		default:
+			// prio 3: static grid tariff
+			return t.Grid
+		}
+
+	case api.TariffUsageSolar:
+		return t.Solar
+
+	default:
+		return nil
 	}
-	return 0, api.ErrNotAvailable
 }
diff --git a/templates/definition/tariff/forecast-solar.yaml b/templates/definition/tariff/forecast-solar.yaml
new file mode 100644
index 00000000..e0b947ff
--- /dev/null
+++ b/templates/definition/tariff/forecast-solar.yaml
@@ -0,0 +1,31 @@
+template: forecast-solar
+products:
+  - brand: Forecast.Solar
+requirements:
+  evcc: ["skiptest"]
+group: solar
+params:
+  - name: lat
+    required: true
+  - name: lon
+    required: true
+  - name: dec
+    required: true
+  - name: az
+    required: true
+  - name: kwp
+    required: true
+  - name: apikey
+    advanced: true
+render: |
+  type: custom
+  tariff: solar
+  forecast:
+    source: http
+    uri: https://api.forecast.solar/{{ if .apikey }}{{ .apikey }}/{{ end }}estimate/{{ .lat }}/{{ .lon }}/{{ .dec }}/{{ .az }}/{{ .kwp }}?time=utc&no_sun=1
+    jq: |
+      [ .result.watts | to_entries.[] | {
+        "start": (.key | strptime("%FT%T%z") | strftime("%FT%TZ")),
+        "end":   (.key | strptime("%FT%T%z") | mktime+3600 | strftime("%FT%TZ")),
+        "price": .value
+      } ] | tostring
diff --git a/templates/definition/tariff/solcast.yaml b/templates/definition/tariff/solcast.yaml
new file mode 100644
index 00000000..270d5e0b
--- /dev/null
+++ b/templates/definition/tariff/solcast.yaml
@@ -0,0 +1,15 @@
+template: solcast
+products:
+  - brand: Solcast
+requirements:
+  evcc: ["skiptest"]
+group: solar
+params:
+  - name: site
+    required: true
+  - name: token
+    required: true
+render: |
+  type: solcast
+  site: {{ .site }}
+  token: {{ .token }}
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 6cf8293f..682ad728 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -606,3 +606,6 @@ devicegroups:
   co2:
     de: CO₂ Vorhersage
     en: CO₂ forecast
+  solar:
+    de: PV Vorhersage
+    en: PV forecast


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
 		}