Repository: evcc-io/evcc (go)

Issue:
Config UI: Keep MQTT CA/certificate/key on edit
### Describe the bug

If you have created a mqtt integration and going to edit it again. CA/Certificate/Key field are shown empty, even if they were configured before. This would be OK if saving those won't delete them from settings. 

### Steps to reproduce

1. Create a MQTT integration and configure CA/Cert and Key
2. Restart to activate this settings
3. Edit the MQTT integration e.g. by changing the topic. 
4. Restart to activate the changes
5. Server won't come up because of missing certificates. 
...


### Configuration details

```yaml
.
```


### Log details

```text
.
```


### What type of operating system are you running?

Linux

### Nightly build

- [X] I have verified that the issue is reproducible with the latest nightly build

### Version

evcc 0.131.4

Pull request description:
Config UI: keep mqtt and influx secrets on edit
fixes #17084 

Passwörter und Schlüssel wurden bei Json Settings wie mqtt und influx bei jedem Bearbeiten überschrieben.
Ich habe die Redacted Funktionen angepasst, sodass jetzt der Websocket und die API alle geheimen Werte mit *** übergeben. Ist aber nicht festgesetzt, kann auch für einzelne Werte angepasst werden.
Ich habe die Get Endpoints für die Settings wieder aktiviert und frage diese beim Bearbeiten ab um die aktuelle Config zu haben.

Beim aktualisieren der Config, vergleiche ich die neue Config mit der alten redacted Config. Wenn vom Frontend genau der selbe Werte zurückkommt wie hingeschickt wurden, wird der alte Wert aus der schon vorhandenen Config genommen. Dadurch kann die Redacted Funktion jederzeit angepasst werden und der Vergleich funktioniert immer noch.

Was haltet ihr von dieser Lösung? Wenn jemand eine bessere Idee hat immer her damit :)
 
TODO:

- [x] add e2e test @naltatis 
- [x] revert to using `state` @leon558 

Patch:
diff --git a/api/globalconfig/types.go b/api/globalconfig/types.go
index 03365666..0cc6d5ae 100644
--- a/api/globalconfig/types.go
+++ b/api/globalconfig/types.go
@@ -7,7 +7,6 @@ import (
 	"time"
 
 	"github.com/evcc-io/evcc/api"
-	"github.com/evcc-io/evcc/plugin/mqtt"
 	"github.com/evcc-io/evcc/push"
 	"github.com/evcc-io/evcc/server/eebus"
 	"github.com/evcc-io/evcc/util/config"
@@ -72,26 +71,47 @@ func (c Hems) Redacted() any {
 
 var _ api.Redactor = (*Mqtt)(nil)
 
+func masked(s any) string {
+	if s != "" {
+		return "***"
+	}
+	return ""
+}
+
 type Mqtt struct {
-	mqtt.Config `mapstructure:",squash"`
-	Topic       string `json:"topic"`
+	Broker     string `json:"broker"`
+	Topic      string `json:"topic"`
+	User       string `json:"user"`
+	ClientID   string `json:"clientID"`
+	Insecure   bool   `json:"insecure"`
+	Password   string `json:"password"`
+	CaCert     string `json:"caCert"`
+	ClientCert string `json:"clientCert"`
+	ClientKey  string `json:"clientKey"`
 }
 
 // Redacted implements the redactor interface used by the tee publisher
 func (m Mqtt) Redacted() any {
-	// TODO add masked password
 	return struct {
-		Broker   string `json:"broker"`
-		Topic    string `json:"topic"`
-		User     string `json:"user,omitempty"`
-		ClientID string `json:"clientID,omitempty"`
-		Insecure bool   `json:"insecure,omitempty"`
+		Broker     string `json:"broker"`
+		Topic      string `json:"topic"`
+		User       string `json:"user,omitempty"`
+		ClientID   string `json:"clientID,omitempty"`
+		Insecure   bool   `json:"insecure,omitempty"`
+		Password   string `json:"password,omitempty"`
+		CaCert     string `json:"caCert,omitempty"`
+		ClientCert string `json:"clientCert,omitempty"`
+		ClientKey  string `json:"clientKey,omitempty"`
 	}{
-		Broker:   m.Broker,
-		Topic:    m.Topic,
-		User:     m.User,
-		ClientID: m.ClientID,
-		Insecure: m.Insecure,
+		Broker:     m.Broker,
+		Topic:      m.Topic,
+		User:       m.User,
+		ClientID:   m.ClientID,
+		Insecure:   m.Insecure,
+		Password:   masked(m.Password),
+		CaCert:     masked(m.CaCert),
+		ClientCert: masked(m.ClientCert),
+		ClientKey:  masked(m.ClientKey),
 	}
 }
 
@@ -108,19 +128,22 @@ type Influx struct {
 
 // Redacted implements the redactor interface used by the tee publisher
 func (c Influx) Redacted() any {
-	// TODO add masked password
 	return struct {
 		URL      string `json:"url"`
 		Database string `json:"database"`
 		Org      string `json:"org"`
 		User     string `json:"user"`
 		Insecure bool   `json:"insecure"`
+		Password string `json:"password,omitempty"`
+		Token    string `json:"token,omitempty"`
 	}{
 		URL:      c.URL,
 		Database: c.Database,
 		Org:      c.Org,
 		User:     c.User,
 		Insecure: c.Insecure,
+		Password: masked(c.Password),
+		Token:    masked(c.Token),
 	}
 }
 
diff --git a/assets/js/api.js b/assets/js/api.js
index 146af401..7c893543 100644
--- a/assets/js/api.js
+++ b/assets/js/api.js
@@ -60,3 +60,9 @@ export const i18n = axios.create({
     Accept: "application/toml",
   },
 });
+
+export const allowClientError = {
+  validateStatus(status) {
+    return status >= 200 && status < 500;
+  },
+};
diff --git a/assets/js/components/ChargingPlans/PlansSettings.vue b/assets/js/components/ChargingPlans/PlansSettings.vue
index 9b1365e2..5f964d69 100644
--- a/assets/js/components/ChargingPlans/PlansSettings.vue
+++ b/assets/js/components/ChargingPlans/PlansSettings.vue
@@ -69,7 +69,7 @@ import RepeatingSettings from "./PlansRepeatingSettings.vue";
 import Warnings from "./Warnings.vue";
 import formatter from "../../mixins/formatter.js";
 import collector from "../../mixins/collector.js";
-import api from "../../api.js";
+import api, { allowClientError } from "../../api.js";
 import CustomSelect from "../Helper/CustomSelect.vue";
 import deepEqual from "../../utils/deepEqual.js";
 
@@ -287,11 +287,7 @@ export default {
 				return;
 			}
 
-			const tariffRes = await api.get(`tariff/planner`, {
-				validateStatus(status) {
-					return status >= 200 && status < 500;
-				},
-			});
+			const tariffRes = await api.get(`tariff/planner`, allowClientError);
 			if (tariffRes.status === 404) {
 				this.tariff = { rates: [] };
 			} else {
diff --git a/assets/js/components/Config/MeterModal.vue b/assets/js/components/Config/MeterModal.vue
index fd482a4d..e51219ff 100644
--- a/assets/js/components/Config/MeterModal.vue
+++ b/assets/js/components/Config/MeterModal.vue
@@ -198,7 +198,7 @@ function sleep(ms) {
 const CUSTOM_FIELDS = ["usage", "modbus"];
 
 const defaultIcons = {
-	aux: "aux",
+	aux: "smartconsumer",
 	ext: "meter",
 };
 
diff --git a/assets/js/components/Savings/Savings.vue b/assets/js/components/Savings/Savings.vue
index 1b489650..30dad89a 100644
--- a/assets/js/components/Savings/Savings.vue
+++ b/assets/js/components/Savings/Savings.vue
@@ -211,7 +211,7 @@ import TelemetrySettings from "../TelemetrySettings.vue";
 import CustomSelect from "../Helper/CustomSelect.vue";
 import co2Reference from "./co2Reference.js";
 import settings from "../../settings.js";
-import api from "../../api.js";
+import api, { allowClientError } from "../../api.js";
 import { docsPrefix } from "../../i18n.js";
 
 export default {
@@ -321,9 +321,7 @@ export default {
 		},
 		async updateReferenceGrid() {
 			try {
-				const res = await api.get(`tariff/grid`, {
-					validateStatus: (status) => status >= 200 && status < 500,
-				});
+				const res = await api.get(`tariff/grid`, allowClientError);
 				const { rates } = res.data.result;
 				this.referenceGrid =
 					rates.reduce((acc, slot) => {
diff --git a/assets/js/components/Tariff/SmartCostLimit.vue b/assets/js/components/Tariff/SmartCostLimit.vue
index 12215fc0..145d5fc9 100644
--- a/assets/js/components/Tariff/SmartCostLimit.vue
+++ b/assets/js/components/Tariff/SmartCostLimit.vue
@@ -86,7 +86,7 @@
 import formatter from "../../mixins/formatter.js";
 import TariffChart from "./TariffChart.vue";
 import { CO2_TYPE } from "../../units.js";
-import api from "../../api.js";
+import api, { allowClientError } from "../../api.js";
 
 export default {
 	name: "SmartCostLimit",
@@ -258,8 +258,11 @@ export default {
 		},
 		async updateTariff() {
 			try {
-				this.tariff = (await api.get(`tariff/planner`)).data.result;
-				this.startTime = new Date();
+				const tariffRes = await api.get(`tariff/planner`, allowClientError);
+				if (tariffRes.status === 200) {
+					this.tariff = tariffRes.data.result;
+					this.startTime = new Date();
+				}
 			} catch (e) {
 				console.error(e);
 			}
diff --git a/assets/js/components/VehicleIcon/Aux.vue b/assets/js/components/VehicleIcon/SmartConsumer.vue
similarity index 98%
rename from assets/js/components/VehicleIcon/Aux.vue
rename to assets/js/components/VehicleIcon/SmartConsumer.vue
index 39da377f..f79989f6 100644
--- a/assets/js/components/VehicleIcon/Aux.vue
+++ b/assets/js/components/VehicleIcon/SmartConsumer.vue
@@ -9,6 +9,6 @@
 
 <script>
 export default {
-	name: "Aux",
+	name: "SmartConsumer",
 };
 </script>
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.stories.js b/assets/js/components/VehicleIcon/VehicleIcon.stories.js
index 685243b4..90fa7ed4 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.stories.js
+++ b/assets/js/components/VehicleIcon/VehicleIcon.stories.js
@@ -2,7 +2,6 @@ import VehicleIcon from "./VehicleIcon.vue";
 
 const icons = [
   "car",
-  "aux",
   "bike",
   "bus",
   "moped",
@@ -36,6 +35,7 @@ const icons = [
   "laundry2",
   "machine",
   "meter",
+  "smartconsumer",
   "microwave",
   "pump",
   "tool",
diff --git a/assets/js/components/VehicleIcon/VehicleIcon.vue b/assets/js/components/VehicleIcon/VehicleIcon.vue
index b5e2dd34..3ce609b1 100644
--- a/assets/js/components/VehicleIcon/VehicleIcon.vue
+++ b/assets/js/components/VehicleIcon/VehicleIcon.vue
@@ -8,7 +8,6 @@ import "@h2d2/shopicons/es/regular/car3";
 import MultiIcon from "../MultiIcon";
 
 import airpurifier from "./Airpurifier.vue";
-import aux from "./Aux.vue";
 import battery from "./Battery.vue";
 import bike from "./Bike.vue";
 import bulb from "./Bulb.vue";
@@ -40,6 +39,7 @@ import rickshaw from "./Rickshaw.vue";
 import rocket from "./Rocket.vue";
 import scooter from "./Scooter.vue";
 import shuttle from "./Shuttle.vue";
+import smartconsumer from "./SmartConsumer.vue";
 import taxi from "./Taxi.vue";
 import tool from "./Tool.vue";
 import tractor from "./Tractor.vue";
@@ -48,7 +48,6 @@ import waterheater from "./WaterHeater.vue";
 
 const icons = {
 	airpurifier,
-	aux,
 	battery,
 	bike,
 	bulb,
@@ -81,6 +80,7 @@ const icons = {
 	rocket,
 	scooter,
 	shuttle,
+	smartconsumer,
 	taxi,
 	tool,
 	tractor,
diff --git a/assets/js/views/Config.vue b/assets/js/views/Config.vue
index 4391f318..985b1492 100644
--- a/assets/js/views/Config.vue
+++ b/assets/js/views/Config.vue
@@ -193,7 +193,7 @@
 							@edit="editMeter(meter.id, 'aux')"
 						>
 							<template #icon>
-								<VehicleIcon :name="meter.deviceIcon || 'aux'" />
+								<VehicleIcon :name="meter.deviceIcon || 'smartconsumer'" />
 							</template>
 							<template #tags>
 								<DeviceTags :tags="deviceTags('meter', meter.name)" />
diff --git a/core/loadpoint.go b/core/loadpoint.go
index f9475a50..6d37b97a 100644
--- a/core/loadpoint.go
+++ b/core/loadpoint.go
@@ -742,7 +742,7 @@ func (lp *Loadpoint) syncCharger() error {
 				// smallest adjustment most PWM-Controllers can do is: 100%÷256×0,6A = 0.234A
 				if delta := math.Abs(lp.chargeCurrent - current); delta > 0.23 {
 					if shouldBeConsistent && delta >= 1 {
-						lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA, expected %.3gA)", current, lp.chargeCurrent)
+						lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA, expected %.3gA) - make sure your interval is at least 30s", current, lp.chargeCurrent)
 					}
 					lp.chargeCurrent = current
 					lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
@@ -757,7 +757,7 @@ func (lp *Loadpoint) syncCharger() error {
 			// validate if current too high by more than 1A (https://github.com/evcc-io/evcc/issues/14731)
 			if current := lp.GetMaxPhaseCurrent(); current > lp.chargeCurrent+1.0 {
 				if shouldBeConsistent {
-					lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA measured, expected %.3gA)", current, lp.chargeCurrent)
+					lp.log.WARN.Printf("charger logic error: current mismatch (got %.3gA measured, expected %.3gA) - make sure your interval is at least 30s", current, lp.chargeCurrent)
 				}
 				lp.chargeCurrent = current
 				lp.bus.Publish(evChargeCurrent, lp.chargeCurrent)
diff --git a/server/http.go b/server/http.go
index 468353b2..eb733933 100644
--- a/server/http.go
+++ b/server/http.go
@@ -271,8 +271,7 @@ func (s *HTTPd) RegisterSystemHandler(site *core.Site, valueChan chan<- util.Par
 			keys.Mqtt:    func() any { return new(globalconfig.Mqtt) },    // has default
 			keys.Influx:  func() any { return new(globalconfig.Influx) },
 		} {
-			// routes[key] = route{Method: "GET", Pattern: "/" + key, HandlerFunc: settingsGetJsonHandler(key, fun())}
-			routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, valueChan, fun())}
+			routes["update"+key] = route{Method: "POST", Pattern: "/" + key, HandlerFunc: settingsSetJsonHandler(key, valueChan, fun)}
 			routes["delete"+key] = route{Method: "DELETE", Pattern: "/" + key, HandlerFunc: settingsDeleteJsonHandler(key, valueChan, fun())}
 		}
 
diff --git a/server/http_global_settings_handler.go b/server/http_global_settings_handler.go
index 7c46a623..9a178873 100644
--- a/server/http_global_settings_handler.go
+++ b/server/http_global_settings_handler.go
@@ -9,8 +9,11 @@ import (
 	"strings"
 	"time"
 
+	"github.com/evcc-io/evcc/api"
 	"github.com/evcc-io/evcc/server/db/settings"
 	"github.com/evcc-io/evcc/util"
+	"github.com/fatih/structs"
+	"github.com/go-viper/mapstructure/v2"
 	"github.com/gorilla/mux"
 	"gopkg.in/yaml.v3"
 )
@@ -72,19 +75,9 @@ func settingsSetYamlHandler(key string, other, struc any) http.HandlerFunc {
 	}
 }
 
-// func settingsGetJsonHandler(key string, struc any) http.HandlerFunc {
-// 	return func(w http.ResponseWriter, r *http.Request) {
-// 		if err := settings.Json(key, &struc); err != nil && err != settings.ErrNotFound {
-// 			jsonError(w, http.StatusInternalServerError, err)
-// 			return
-// 		}
-
-// 		jsonResult(w, struc)
-// 	}
-// }
-
-func settingsSetJsonHandler(key string, valueChan chan<- util.Param, struc any) http.HandlerFunc {
+func settingsSetJsonHandler(key string, valueChan chan<- util.Param, newStruc func() any) http.HandlerFunc {
 	return func(w http.ResponseWriter, r *http.Request) {
+		struc := newStruc()
 		dec := json.NewDecoder(r.Body)
 		dec.DisallowUnknownFields()
 		if err := dec.Decode(&struc); err != nil {
@@ -92,6 +85,14 @@ func settingsSetJsonHandler(key string, valueChan chan<- util.Param, struc any)
 			return
 		}
 
+		oldStruc := newStruc()
+		if err := settings.Json(key, &oldStruc); err == nil {
+			if err := mergeSettings(oldStruc, struc); err != nil {
+				jsonError(w, http.StatusInternalServerError, err)
+				return
+			}
+		}
+
 		settings.SetJson(key, struc)
 		setConfigDirty()
 
@@ -111,3 +112,24 @@ func settingsDeleteJsonHandler(key string, valueChan chan<- util.Param, struc an
 		jsonResult(w, true)
 	}
 }
+
+func mergeSettings(old any, new any) error {
+	redactable, ok := old.(api.Redactor)
+	if !ok {
+		return nil
+	}
+
+	newMap := structs.Map(new)
+	oldMap := structs.Map(old)
+	redactedMap := structs.Map(redactable.Redacted())
+
+	for k, v := range newMap {
+		if rv, ok := redactedMap[k]; ok && v == rv {
+			if ov, ok := oldMap[k]; ok {
+				newMap[k] = ov
+			}
+		}
+	}
+
+	return mapstructure.Decode(newMap, &new)
+}
diff --git a/templates/definition/charger/demo-charger.yaml b/templates/definition/charger/demo-charger.yaml
index 03da11b2..f9e5c12b 100644
--- a/templates/definition/charger/demo-charger.yaml
+++ b/templates/definition/charger/demo-charger.yaml
@@ -16,6 +16,7 @@ params:
     type: choice
     choice: [A, B, C]
     default: A
+    required: true
   - name: power
     description:
       de: Leistung
diff --git a/templates/definition/charger/emsesp.yaml b/templates/definition/charger/emsesp.yaml
index 803bfeb1..3996580a 100644
--- a/templates/definition/charger/emsesp.yaml
+++ b/templates/definition/charger/emsesp.yaml
@@ -17,7 +17,7 @@ params:
   - name: token
   - name: tempsource
     type: choice
-    choice: ["", "warmwater"]
+    choice: ["warmwater"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/idm.yaml b/templates/definition/charger/idm.yaml
index dda225ea..bcaa40e7 100644
--- a/templates/definition/charger/idm.yaml
+++ b/templates/definition/charger/idm.yaml
@@ -9,7 +9,7 @@ params:
     choice: ["tcpip"]
   - name: tempsource
     type: choice
-    choice: ["", "warmwater_top", "warmwater_bottom", "buffer"]
+    choice: ["warmwater_top", "warmwater_bottom", "buffer"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/kermi.yaml b/templates/definition/charger/kermi.yaml
index 6f00bf4f..dcbc4382 100644
--- a/templates/definition/charger/kermi.yaml
+++ b/templates/definition/charger/kermi.yaml
@@ -13,7 +13,7 @@ params:
   - name: host
   - name: tempsource
     type: choice
-    choice: ["", "warmwater", "buffer"]
+    choice: ["warmwater", "buffer"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/lambda-zewotherm.yaml b/templates/definition/charger/lambda-zewotherm.yaml
index 1512ab30..7a07e008 100644
--- a/templates/definition/charger/lambda-zewotherm.yaml
+++ b/templates/definition/charger/lambda-zewotherm.yaml
@@ -26,7 +26,7 @@ params:
   - name: host
   - name: tempsource
     type: choice
-    choice: ["", "warmwater_top", "warmwater_bottom", "buffer_top", "buffer_bottom"]
+    choice: ["warmwater_top", "warmwater_bottom", "buffer_top", "buffer_bottom"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/stiebel-lwa.yaml b/templates/definition/charger/stiebel-lwa.yaml
index e2cf3ada..3c62528e 100644
--- a/templates/definition/charger/stiebel-lwa.yaml
+++ b/templates/definition/charger/stiebel-lwa.yaml
@@ -14,7 +14,7 @@ params:
     choice: ["tcpip"]
   - name: tempsource
     type: choice
-    choice: ["", "warmwater"]
+    choice: ["warmwater"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/stiebel-wpm.yaml b/templates/definition/charger/stiebel-wpm.yaml
index cdb34264..484524ad 100644
--- a/templates/definition/charger/stiebel-wpm.yaml
+++ b/templates/definition/charger/stiebel-wpm.yaml
@@ -11,7 +11,7 @@ params:
     choice: ["tcpip"]
   - name: tempsource
     type: choice
-    choice: ["", "warmwater", "buffer"]
+    choice: ["warmwater", "buffer"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/charger/weishaupt-wpm.yaml b/templates/definition/charger/weishaupt-wpm.yaml
index 0da1828f..843062c3 100644
--- a/templates/definition/charger/weishaupt-wpm.yaml
+++ b/templates/definition/charger/weishaupt-wpm.yaml
@@ -12,7 +12,7 @@ params:
     choice: ["tcpip"]
   - name: tempsource
     type: choice
-    choice: ["", "warmwater", "buffer"]
+    choice: ["warmwater", "buffer"]
     description:
       de: "Temperaturquelle"
       en: "Temperature source"
diff --git a/templates/definition/meter/sonnenbatterie.yaml b/templates/definition/meter/sonnenbatterie.yaml
index 00097d64..97277ff7 100644
--- a/templates/definition/meter/sonnenbatterie.yaml
+++ b/templates/definition/meter/sonnenbatterie.yaml
@@ -34,6 +34,7 @@ params:
     type: choice
     choice: ["self-consumption", "time-of-use"]
     default: self-consumption
+    required: true
     advanced: true
     help:
       de: Standardmodus für die aktive Batteriesteuerung
diff --git a/templates/definition/meter/sungrow-hybrid.yaml b/templates/definition/meter/sungrow-hybrid.yaml
index 6e1e3b85..7b61d03d 100644
--- a/templates/definition/meter/sungrow-hybrid.yaml
+++ b/templates/definition/meter/sungrow-hybrid.yaml
@@ -190,7 +190,7 @@ render: |
         # Reset max battery discharge power
         - source: const
           {{- if .maxchargepower }}
-          value: {{ .maxchargepower }}
+          value: {{ div .maxchargepower 10 }}
           {{- else }}
           value: 1060 # 10.6kW, max allowed value for register
           {{- end }}
diff --git a/templates/definition/meter/zendure.yaml b/templates/definition/meter/zendure.yaml
index 0136965a..a8b5c944 100644
--- a/templates/definition/meter/zendure.yaml
+++ b/templates/definition/meter/zendure.yaml
@@ -21,6 +21,7 @@ params:
     type: choice
     choice: ["EU", "Global"]
     default: EU
+    required: true
   - name: capacity
     default: 2
     advanced: true
diff --git a/templates/definition/tariff/awattar.yaml b/templates/definition/tariff/awattar.yaml
index ae409624..af44c45b 100644
--- a/templates/definition/tariff/awattar.yaml
+++ b/templates/definition/tariff/awattar.yaml
@@ -8,6 +8,7 @@ params:
     example: AT
     type: choice
     choice: ["DE", "AT"]
+    required: true
   - preset: tariff-base
 render: |
   type: awattar
diff --git a/templates/definition/tariff/elering.yaml b/templates/definition/tariff/elering.yaml
index a346c7b1..f3bc3d6e 100644
--- a/templates/definition/tariff/elering.yaml
+++ b/templates/definition/tariff/elering.yaml
@@ -11,6 +11,7 @@ params:
     example: ee
     type: choice
     choice: ["ee", "lt", "lv", "fi"]
+    required: true
   - preset: tariff-base
 render: |
   type: elering
diff --git a/templates/definition/tariff/energinet.yaml b/templates/definition/tariff/energinet.yaml
index 15b92ac6..68b63424 100644
--- a/templates/definition/tariff/energinet.yaml
+++ b/templates/definition/tariff/energinet.yaml
@@ -10,6 +10,7 @@ params:
     example: dk1
     type: choice
     choice: ["dk1", "dk2"]
+    required: true
   - preset: tariff-base
 render: |
   type: energinet
diff --git a/templates/definition/tariff/nordpool.yaml b/templates/definition/tariff/nordpool.yaml
index 30887443..46b3bc88 100644
--- a/templates/definition/tariff/nordpool.yaml
+++ b/templates/definition/tariff/nordpool.yaml
@@ -42,6 +42,7 @@ params:
     default: EUR
     type: choice
     choice: ["DKK", "EUR", "NOK", "PLN", "RON", "SEK"]
+    required: true
   - preset: tariff-base
 render: |
   type: custom
diff --git a/templates/definition/tariff/spottyenergy.yaml b/templates/definition/tariff/spottyenergy.yaml
index 62cfd295..aed28ed1 100644
--- a/templates/definition/tariff/spottyenergy.yaml
+++ b/templates/definition/tariff/spottyenergy.yaml
@@ -1,6 +1,8 @@
 template: spottyenergy
 products:
   - brand: Spotty Energie
+requirements:
+  evcc: ["skiptest"]
 group: price
 countries: ["AT"]
 params:
diff --git a/templates/definition/vehicle/bmw.yaml b/templates/definition/vehicle/bmw.yaml
index be57f390..48d5f956 100644
--- a/templates/definition/vehicle/bmw.yaml
+++ b/templates/definition/vehicle/bmw.yaml
@@ -18,6 +18,7 @@ params:
     type: choice
     choice: ["EU", "NA"]
     default: EU
+    required: true
     advanced: true
   - name: hcaptcha
     required: true
diff --git a/templates/definition/vehicle/ford.yaml b/templates/definition/vehicle/ford.yaml
index 14004f1f..f2978464 100644
--- a/templates/definition/vehicle/ford.yaml
+++ b/templates/definition/vehicle/ford.yaml
@@ -14,6 +14,7 @@ params:
     type: choice
     choice: ["com", "de"]
     default: com
+    required: true
 render: |
   type: ford
   {{ include "vehicle-base" . }}
diff --git a/templates/definition/vehicle/mg.yaml b/templates/definition/vehicle/mg.yaml
index 498f1cf0..008e126c 100644
--- a/templates/definition/vehicle/mg.yaml
+++ b/templates/definition/vehicle/mg.yaml
@@ -12,6 +12,7 @@ params:
     type: choice
     choice: ["EU", "AU"]
     default: EU
+    required: true
     advanced: true
 render: |
   type: mg
diff --git a/templates/definition/vehicle/mini.yaml b/templates/definition/vehicle/mini.yaml
index 8611f871..490b9e8d 100644
--- a/templates/definition/vehicle/mini.yaml
+++ b/templates/definition/vehicle/mini.yaml
@@ -18,6 +18,7 @@ params:
     type: choice
     choice: ["EU", "NA"]
     default: EU
+    required: true
     advanced: true
   - name: hcaptcha
     required: true
diff --git a/util/templates/defaults.yaml b/util/templates/defaults.yaml
index 1d774087..2ab2bb18 100644
--- a/util/templates/defaults.yaml
+++ b/util/templates/defaults.yaml
@@ -269,6 +269,7 @@ params:
       - meter
       - microwave
       - pump
+      - smartconsumer
       - tool
       - waterheater
 
diff --git a/vehicle/skoda/provider.go b/vehicle/skoda/provider.go
index 35079302..706979cc 100644
--- a/vehicle/skoda/provider.go
+++ b/vehicle/skoda/provider.go
@@ -128,7 +128,10 @@ var _ api.SocLimiter = (*Provider)(nil)
 func (v *Provider) GetLimitSoc() (int64, error) {
 	res, err := v.chargerG()
 	if err == nil {
-		return int64(res.Settings.TargetStateOfChargeInPercent), nil
+		if res.Settings.TargetStateOfChargeInPercent == nil {
+			return 0, api.ErrNotAvailable
+		}
+		return int64(*res.Settings.TargetStateOfChargeInPercent), nil
 	}
 
 	return 0, err
diff --git a/vehicle/skoda/types.go b/vehicle/skoda/types.go
index 780a6fe6..3a5a40f6 100644
--- a/vehicle/skoda/types.go
+++ b/vehicle/skoda/types.go
@@ -62,7 +62,7 @@ type ChargerResponse struct {
 type SettingsResponse struct {
 	AutoUnlockPlugWhenCharged    string `json:"autoUnlockPlugWhenCharged"`
 	MaxChargeCurrentAc           string `json:"maxChargeCurrentAc"`
-	TargetStateOfChargeInPercent int    `json:"targetStateOfChargeInPercent"`
+	TargetStateOfChargeInPercent *int   `json:"targetStateOfChargeInPercent"`
 }
 
 // ChargerResponse is the /v2/air-conditioning/<vin> api


Test patch:
diff --git a/server/http_global_settings_handler_test.go b/server/http_global_settings_handler_test.go
new file mode 100644
index 00000000..84f3ccd3
--- /dev/null
+++ b/server/http_global_settings_handler_test.go
@@ -0,0 +1,62 @@
+package server
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMergeSettings(t *testing.T) {
+	tests := []struct {
+		old      any
+		new      *RedactedStruct
+		expected *RedactedStruct
+	}{
+		{
+			old:      nil,
+			new:      &RedactedStruct{"newValue1", 42},
+			expected: &RedactedStruct{"newValue1", 42},
+		},
+		{
+			old:      &TestStruct{"oldValue1", 24},
+			new:      &RedactedStruct{"newValue1", 42},
+			expected: &RedactedStruct{"newValue1", 42},
+		},
+		{
+			old:      &RedactedStruct{"oldValue1", 24},
+			new:      &RedactedStruct{"redacted", 42},
+			expected: &RedactedStruct{"oldValue1", 42},
+		},
+		{
+			old:      &RedactedStruct{"oldValue1", 24},
+			new:      &RedactedStruct{"newValue1", 42},
+			expected: &RedactedStruct{"newValue1", 42},
+		},
+	}
+
+	for _, tc := range tests {
+		mergeSettings(tc.old, tc.new)
+		assert.Equal(t, tc.expected.Field1, tc.new.Field1)
+		assert.Equal(t, tc.expected.Field2, tc.new.Field2)
+	}
+}
+
+type TestStruct struct {
+	Field1 string
+	Field2 int
+}
+
+type RedactedStruct struct {
+	Field1 string
+	Field2 int
+}
+
+func (t *RedactedStruct) Redacted() any {
+	return struct {
+		Field1 string
+		Field2 int
+	}{
+		Field1: "redacted",
+		Field2: t.Field2,
+	}
+}
diff --git a/tests/config-mqtt.spec.js b/tests/config-mqtt.spec.js
index 98f16929..47c107b5 100644
--- a/tests/config-mqtt.spec.js
+++ b/tests/config-mqtt.spec.js
@@ -16,6 +16,14 @@ test.beforeEach(async ({ page }) => {
 test.afterEach(async () => {
   await stop();
 });
+
+const VALID_BROKER = "test.mosquitto.org:1884";
+const INVALID_BROKER = "unknown.example.org";
+const VALID_TOPIC = "my-topic";
+const VALID_CLIENT_ID = "my-client-id";
+const VALID_USERNAME = "rw";
+const VALID_PASSWORD = "readwrite";
+
 test.describe("mqtt", async () => {
   test("mqtt not configured", async ({ page }) => {
     await expect(page.getByTestId("mqtt")).toBeVisible();
@@ -26,10 +34,12 @@ test.describe("mqtt", async () => {
     await page.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
     const modal = await page.getByTestId("mqtt-modal");
 
-    await modal.getByLabel("Broker").fill("unknown.example.org");
-    await modal.getByLabel("Topic").fill("  my-topic "); // whitespace should be trimmed
-    await modal.getByLabel("Client ID").fill("my-client-id");
-
+    // setup with invalid broker
+    await modal.getByLabel("Broker").fill(INVALID_BROKER);
+    await modal.getByLabel("Topic").fill("  " + VALID_TOPIC + " "); // whitespace should be trimmed
+    await modal.getByLabel("Client ID").fill(VALID_CLIENT_ID);
+    await modal.getByLabel("Username").fill(VALID_USERNAME);
+    await modal.getByLabel("Password").fill(VALID_PASSWORD);
     await page.getByRole("button", { name: "Save" }).click();
     await expect(modal.getByTestId("error")).not.toBeVisible();
     await expect(modal).not.toBeVisible();
@@ -46,17 +56,29 @@ test.describe("mqtt", async () => {
     await expect(restartButton).not.toBeVisible();
     await expect(page.getByTestId("mqtt")).toHaveClass(/round-box--error/);
     await expect(page.getByTestId("mqtt")).toContainText(
-      ["Broker", "unknown.example.org", "Topic", "my-topic"].join("")
+      ["Broker", INVALID_BROKER, "Topic", VALID_TOPIC].join("")
     );
-    await expect(page.getByTestId("bottom-banner")).toContainText("failed configuring mqtt");
+    await expect(page.getByTestId("fatal-error")).toContainText("failed configuring mqtt");
 
     await page.getByTestId("mqtt").getByRole("button", { name: "edit" }).click();
-    await page.getByRole("button", { name: "Remove" }).click();
-    await expect(page.getByTestId("mqtt")).toContainText(["Configured", "no"].join(""));
-    await expect(restartButton).toBeVisible();
+    await expect(modal.getByLabel("Broker")).toHaveValue(INVALID_BROKER);
+    await expect(modal.getByLabel("Topic")).toHaveValue(VALID_TOPIC); // whitespace has been trimmed
+    await expect(modal.getByLabel("Client ID")).toHaveValue(VALID_CLIENT_ID);
+    await expect(modal.getByLabel("Username")).toHaveValue(VALID_USERNAME);
+    await expect(modal.getByLabel("Password")).toHaveValue("***");
+
+    // use valid broker
+    await modal.getByLabel("Broker").fill(VALID_BROKER);
+    await modal.getByRole("button", { name: "Save" }).click();
+    await expect(page.getByTestId("mqtt")).toContainText(
+      ["Broker", VALID_BROKER, "Topic", VALID_TOPIC].join("")
+    );
     await restart(CONFIG);
-    await expect(restartButton).not.toBeVisible();
+
+    await expect(page.getByTestId("fatal-error")).not.toBeVisible();
     await expect(page.getByTestId("mqtt")).not.toHaveClass(/round-box--error/);
-    await expect(page.getByTestId("mqtt")).toContainText(["Configured", "no"].join(""));
+    await expect(page.getByTestId("mqtt")).toContainText(
+      ["Broker", VALID_BROKER, "Topic", VALID_TOPIC].join("")
+    );
   });
 });